From 284c167ffb689da2a6fd792c5887047e93054143 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 11:50:23 -0800 Subject: [PATCH 01/31] Add HostKeyVerification types to config Add HostKeyServerStoreConfig, HostKeyClientStoreConfig, and HostKeyVerificationConfig interfaces. Add hostKeyVerification property to SSHConfig interface. --- app/types/config.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/types/config.ts b/app/types/config.ts index 143ec631..5413be63 100644 --- a/app/types/config.ts +++ b/app/types/config.ts @@ -17,6 +17,32 @@ export interface AlgorithmsConfig { serverHostKey: string[] } +/** + * Host key verification server store configuration + */ +export interface HostKeyServerStoreConfig { + enabled: boolean + dbPath: string +} + +/** + * Host key verification client store configuration + */ +export interface HostKeyClientStoreConfig { + enabled: boolean +} + +/** + * Host key verification configuration + */ +export interface HostKeyVerificationConfig { + enabled: boolean + mode: 'server' | 'client' | 'hybrid' + unknownKeyAction: 'prompt' | 'alert' | 'reject' + serverStore: HostKeyServerStoreConfig + clientStore: HostKeyClientStoreConfig +} + /** * SFTP backend type * @@ -72,6 +98,8 @@ export interface SSHConfig { socketHighWaterMark?: number /** SFTP file transfer configuration */ sftp?: SftpConfig + /** Host key verification configuration */ + hostKeyVerification: HostKeyVerificationConfig } /** From 1d2e5ded16b354b22ee2371297297d25e7210f03 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 11:51:43 -0800 Subject: [PATCH 02/31] Add host key verification defaults and env variable mapping Add default hostKeyVerification config (disabled, hybrid mode, prompt on unknown). Add WEBSSH2_SSH_HOSTKEY_* environment variable mappings. Add clone function for deep-copying the config. --- app/config/default-config.ts | 32 ++++++++++++++++++++++++++++++++ app/config/env-mapper.ts | 25 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/config/default-config.ts b/app/config/default-config.ts index 96f8910f..f13dfd2e 100644 --- a/app/config/default-config.ts +++ b/app/config/default-config.ts @@ -4,6 +4,7 @@ import crypto from 'node:crypto' import type { Config, + HostKeyVerificationConfig, LoggingConfig, LoggingControlsConfig, LoggingSamplingConfig, @@ -100,6 +101,18 @@ export const DEFAULT_CONFIG_BASE: Omit & { session: Omit = { path: 'logging.syslog.tls.rejectUnauthorized', type: 'boolean' }, + // Host key verification configuration + WEBSSH2_SSH_HOSTKEY_ENABLED: { + path: 'ssh.hostKeyVerification.enabled', + type: 'boolean' as const, + }, + WEBSSH2_SSH_HOSTKEY_MODE: { + path: 'ssh.hostKeyVerification.mode', + type: 'string' as const, + }, + WEBSSH2_SSH_HOSTKEY_UNKNOWN_ACTION: { + path: 'ssh.hostKeyVerification.unknownKeyAction', + type: 'string' as const, + }, + WEBSSH2_SSH_HOSTKEY_DB_PATH: { + path: 'ssh.hostKeyVerification.serverStore.dbPath', + type: 'string' as const, + }, + WEBSSH2_SSH_HOSTKEY_SERVER_ENABLED: { + path: 'ssh.hostKeyVerification.serverStore.enabled', + type: 'boolean' as const, + }, + WEBSSH2_SSH_HOSTKEY_CLIENT_ENABLED: { + path: 'ssh.hostKeyVerification.clientStore.enabled', + type: 'boolean' as const, + }, // SFTP configuration WEBSSH2_SSH_SFTP_BACKEND: { path: 'ssh.sftp.backend', type: 'string' }, WEBSSH2_SSH_SFTP_ENABLED: { path: 'ssh.sftp.enabled', type: 'boolean' }, From 2b3bf675873ef6a279a77852f6614b995f605213 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 11:54:06 -0800 Subject: [PATCH 03/31] Add resolveHostKeyMode for mode-to-store-flag expansion The resolveHostKeyMode function expands mode shorthand (server, client, hybrid) into serverStore.enabled and clientStore.enabled flags. Explicit store flags from file/env config override mode defaults. Includes 10 tests covering all modes, overrides, immutability, and default values. --- app/config/config-processor.ts | 81 +++++++++++++- tests/unit/config/host-key-config.vitest.ts | 118 ++++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/unit/config/host-key-config.vitest.ts diff --git a/app/config/config-processor.ts b/app/config/config-processor.ts index 72c1b60c..06892f9b 100644 --- a/app/config/config-processor.ts +++ b/app/config/config-processor.ts @@ -1,7 +1,7 @@ // app/config/config-processor.ts // Pure functions for config processing -import type { Config } from '../types/config.js' +import type { Config, HostKeyVerificationConfig } from '../types/config.js' import type { Result } from '../types/result.js' import { ok, err, deepMerge, validateConfigPure } from '../utils/index.js' import { createCompleteDefaultConfig } from './default-config.js' @@ -98,7 +98,7 @@ export function parseConfigJson(jsonString: string): Result, Err /** * Create CORS configuration from config * Pure function - no side effects - * + * * @param config - Application configuration * @returns CORS configuration object */ @@ -112,4 +112,81 @@ export function createCorsConfig(config: Config): { methods: ['GET', 'POST'], credentials: true } +} + +/** + * Options indicating which store flags were explicitly set by file/env config + * (as opposed to being inherited from defaults). When explicit, the flag + * overrides the mode-derived default. + */ +export interface ResolveHostKeyModeOptions { + serverStoreExplicit?: boolean + clientStoreExplicit?: boolean +} + +/** + * Resolve host key verification mode into store-enabled flags. + * + * The mode shorthand sets sensible defaults: + * - "server" → serverStore=true, clientStore=false + * - "client" → serverStore=false, clientStore=true + * - "hybrid" → both true + * + * Explicit store flags from file/env config override the mode defaults. + * Pure function - returns a new config without mutating the input. + * + * @param config - The host key verification config to resolve + * @param options - Flags indicating which store settings were explicitly provided + * @returns Resolved host key verification config + */ +export function resolveHostKeyMode( + config: HostKeyVerificationConfig, + options?: ResolveHostKeyModeOptions +): HostKeyVerificationConfig { + const serverStoreExplicit = options?.serverStoreExplicit === true + const clientStoreExplicit = options?.clientStoreExplicit === true + + // Derive defaults from mode + let serverEnabled: boolean + let clientEnabled: boolean + switch (config.mode) { + case 'server': + serverEnabled = true + clientEnabled = false + break + case 'client': + serverEnabled = false + clientEnabled = true + break + case 'hybrid': + serverEnabled = true + clientEnabled = true + break + default: { + // Exhaustive check + const _exhaustive: never = config.mode + throw new Error(`Unknown host key verification mode: ${String(_exhaustive)}`) + } + } + + // Explicit flags override mode defaults + if (serverStoreExplicit) { + serverEnabled = config.serverStore.enabled + } + if (clientStoreExplicit) { + clientEnabled = config.clientStore.enabled + } + + return { + enabled: config.enabled, + mode: config.mode, + unknownKeyAction: config.unknownKeyAction, + serverStore: { + enabled: serverEnabled, + dbPath: config.serverStore.dbPath, + }, + clientStore: { + enabled: clientEnabled, + }, + } } \ No newline at end of file diff --git a/tests/unit/config/host-key-config.vitest.ts b/tests/unit/config/host-key-config.vitest.ts new file mode 100644 index 00000000..07a4d594 --- /dev/null +++ b/tests/unit/config/host-key-config.vitest.ts @@ -0,0 +1,118 @@ +// tests/unit/config/host-key-config.vitest.ts +// Tests for host key verification mode expansion + +import { describe, it, expect } from 'vitest' +import { resolveHostKeyMode } from '../../../app/config/config-processor.js' +import type { HostKeyVerificationConfig } from '../../../app/types/config.js' + +/** + * Build a HostKeyVerificationConfig with overrides + */ +function buildHostKeyConfig( + overrides?: Partial +): HostKeyVerificationConfig { + return { + enabled: false, + mode: 'hybrid', + unknownKeyAction: 'prompt', + serverStore: { + enabled: true, + dbPath: '/data/hostkeys.db', + }, + clientStore: { + enabled: true, + }, + ...overrides, + } +} + +void describe('resolveHostKeyMode', () => { + it('should set serverStore=true, clientStore=false for mode "server"', () => { + const config = buildHostKeyConfig({ mode: 'server' }) + const result = resolveHostKeyMode(config) + + expect(result.serverStore.enabled).toBe(true) + expect(result.clientStore.enabled).toBe(false) + }) + + it('should set serverStore=false, clientStore=true for mode "client"', () => { + const config = buildHostKeyConfig({ mode: 'client' }) + const result = resolveHostKeyMode(config) + + expect(result.serverStore.enabled).toBe(false) + expect(result.clientStore.enabled).toBe(true) + }) + + it('should set both stores true for mode "hybrid"', () => { + const config = buildHostKeyConfig({ mode: 'hybrid' }) + const result = resolveHostKeyMode(config) + + expect(result.serverStore.enabled).toBe(true) + expect(result.clientStore.enabled).toBe(true) + }) + + it('should allow explicit flags to override mode defaults', () => { + // mode=server normally sets clientStore=false, but explicit flag overrides + const config = buildHostKeyConfig({ + mode: 'server', + clientStore: { enabled: true }, + }) + const result = resolveHostKeyMode(config, { + clientStoreExplicit: true, + }) + + expect(result.serverStore.enabled).toBe(true) + expect(result.clientStore.enabled).toBe(true) + }) + + it('should allow explicit serverStore=false to override mode=hybrid', () => { + const config = buildHostKeyConfig({ + mode: 'hybrid', + serverStore: { enabled: false, dbPath: '/data/hostkeys.db' }, + }) + const result = resolveHostKeyMode(config, { + serverStoreExplicit: true, + }) + + expect(result.serverStore.enabled).toBe(false) + expect(result.clientStore.enabled).toBe(true) + }) + + it('should default to enabled=false', () => { + const config = buildHostKeyConfig() + const result = resolveHostKeyMode(config) + + expect(result.enabled).toBe(false) + }) + + it('should preserve enabled=true when set', () => { + const config = buildHostKeyConfig({ enabled: true }) + const result = resolveHostKeyMode(config) + + expect(result.enabled).toBe(true) + }) + + it('should preserve unknownKeyAction', () => { + const config = buildHostKeyConfig({ unknownKeyAction: 'reject' }) + const result = resolveHostKeyMode(config) + + expect(result.unknownKeyAction).toBe('reject') + }) + + it('should preserve dbPath from input', () => { + const config = buildHostKeyConfig({ + serverStore: { enabled: true, dbPath: '/custom/path.db' }, + }) + const result = resolveHostKeyMode(config) + + expect(result.serverStore.dbPath).toBe('/custom/path.db') + }) + + it('should not mutate the input config', () => { + const config = buildHostKeyConfig({ mode: 'server' }) + const original = structuredClone(config) + resolveHostKeyMode(config) + + expect(config).toEqual(original) + }) +}) From fbb93e855800252bb916c7e6d9d416759e83a8fb Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 11:56:44 -0800 Subject: [PATCH 04/31] Install better-sqlite3 for host key server store Add better-sqlite3 as runtime dependency and @types/better-sqlite3 as dev dependency for the SQLite-backed host key store. --- package-lock.json | 432 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 + 2 files changed, 433 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 3413629b..f51ce4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "basic-auth": "^2.0.1", + "better-sqlite3": "^12.6.2", "body-parser": "^2.2.1", "debug": "^4.4.3", "express": "^5.2.1", @@ -29,6 +30,7 @@ "@eslint/js": "^9.39.1", "@playwright/test": "^1.58.2", "@types/basic-auth": "^1.1.8", + "@types/better-sqlite3": "^7.6.13", "@types/debug": "^4.1.12", "@types/express": "^5.0.6", "@types/express-session": "^1.18.2", @@ -1188,6 +1190,16 @@ "@types/node": "*" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1945,6 +1957,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -1985,6 +2017,40 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -2054,6 +2120,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -2179,6 +2269,12 @@ "dev": true, "license": "MIT" }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -2392,6 +2488,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2418,6 +2538,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -2465,6 +2594,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", @@ -3036,6 +3174,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3212,6 +3359,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3360,6 +3513,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -3434,6 +3593,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3565,6 +3730,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3621,6 +3806,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3963,6 +4154,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", @@ -3976,6 +4179,21 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4008,6 +4226,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4024,6 +4248,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -4300,6 +4536,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4323,6 +4586,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4381,6 +4654,44 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -4545,7 +4856,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4707,6 +5017,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -4841,6 +5196,35 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -4929,6 +5313,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5030,6 +5442,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -5145,6 +5569,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/validator": { "version": "13.15.26", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", diff --git a/package.json b/package.json index 46109889..b96c8765 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ ], "dependencies": { "basic-auth": "^2.0.1", + "better-sqlite3": "^12.6.2", "body-parser": "^2.2.1", "debug": "^4.4.3", "express": "^5.2.1", @@ -78,6 +79,7 @@ "@eslint/js": "^9.39.1", "@playwright/test": "^1.58.2", "@types/basic-auth": "^1.1.8", + "@types/better-sqlite3": "^7.6.13", "@types/debug": "^4.1.12", "@types/express": "^5.0.6", "@types/express-session": "^1.18.2", From 56e776b15c3a176377aa2fed05a28d633b710586 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 11:59:40 -0800 Subject: [PATCH 05/31] Implement HostKeyStore SQLite wrapper with tests Read-only SQLite store for server-side host key verification. Supports lookup (trusted/mismatch/unknown) and getAll operations. Degrades gracefully when database file is missing. Includes 16 tests covering all lookup scenarios, port/algorithm discrimination, missing DB, and close behavior. --- app/services/host-key/host-key-store.ts | 129 ++++++++++ .../host-key/host-key-store.vitest.ts | 233 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 app/services/host-key/host-key-store.ts create mode 100644 tests/unit/services/host-key/host-key-store.vitest.ts diff --git a/app/services/host-key/host-key-store.ts b/app/services/host-key/host-key-store.ts new file mode 100644 index 00000000..4376d330 --- /dev/null +++ b/app/services/host-key/host-key-store.ts @@ -0,0 +1,129 @@ +// app/services/host-key/host-key-store.ts +// SQLite-backed read-only host key store + +import Database from 'better-sqlite3' +import type BetterSqlite3 from 'better-sqlite3' +import fs from 'node:fs' + +/** + * Result of looking up a host key + */ +export interface HostKeyLookupResult { + status: 'trusted' | 'mismatch' | 'unknown' + storedKey?: string +} + +/** + * A stored host key record + */ +export interface StoredHostKey { + host: string + port: number + algorithm: string + key: string + addedAt: string + comment: string | null +} + +/** + * SQLite-backed host key store (read-only). + * + * Opens the database in read-only mode. If the file does not exist, + * the store operates in a degraded mode where all lookups return "unknown". + */ +export class HostKeyStore { + private db: BetterSqlite3.Database | null = null + + constructor(dbPath: string) { + if (fs.existsSync(dbPath)) { + this.db = new Database(dbPath, { readonly: true }) + } + } + + /** + * Whether the database is currently open + */ + isOpen(): boolean { + return this.db !== null + } + + /** + * Look up a host key in the store. + * + * When presentedKey is provided, compares it to the stored key: + * - "trusted" if the presented key matches the stored key + * - "mismatch" if there is a stored key but it differs + * - "unknown" if there is no stored key for this host/port/algorithm + * + * When presentedKey is omitted, returns the stored key if present + * ("trusted") or "unknown" if no record exists. + */ + lookup( + host: string, + port: number, + algorithm: string, + presentedKey?: string + ): HostKeyLookupResult { + if (this.db === null) { + return { status: 'unknown' } + } + + const row = this.db + .prepare('SELECT key FROM host_keys WHERE host = ? AND port = ? AND algorithm = ?') + .get(host, port, algorithm) as { key: string } | undefined + + if (row === undefined) { + return { status: 'unknown' } + } + + // No presented key means caller just wants to know if we have a record + if (presentedKey === undefined) { + return { status: 'trusted', storedKey: row.key } + } + + if (row.key === presentedKey) { + return { status: 'trusted', storedKey: row.key } + } + + return { status: 'mismatch', storedKey: row.key } + } + + /** + * Get all stored keys for a given host and port + */ + getAll(host: string, port: number): StoredHostKey[] { + if (this.db === null) { + return [] + } + + const rows = this.db + .prepare('SELECT host, port, algorithm, key, added_at, comment FROM host_keys WHERE host = ? AND port = ?') + .all(host, port) as Array<{ + host: string + port: number + algorithm: string + key: string + added_at: string + comment: string | null + }> + + return rows.map(row => ({ + host: row.host, + port: row.port, + algorithm: row.algorithm, + key: row.key, + addedAt: row.added_at, + comment: row.comment, + })) + } + + /** + * Close the database connection. Safe to call multiple times. + */ + close(): void { + if (this.db !== null) { + this.db.close() + this.db = null + } + } +} diff --git a/tests/unit/services/host-key/host-key-store.vitest.ts b/tests/unit/services/host-key/host-key-store.vitest.ts new file mode 100644 index 00000000..be1551ae --- /dev/null +++ b/tests/unit/services/host-key/host-key-store.vitest.ts @@ -0,0 +1,233 @@ +// tests/unit/services/host-key/host-key-store.vitest.ts +// Tests for HostKeyStore SQLite wrapper + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' +import { HostKeyStore } from '../../../../app/services/host-key/host-key-store.js' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +const HOST_KEY_SCHEMA = ` +CREATE TABLE host_keys ( + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + algorithm TEXT NOT NULL, + key TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + comment TEXT, + PRIMARY KEY (host, port, algorithm) +); +` + +// Example base64 keys for testing (not real SSH keys, just deterministic test data) +const TEST_KEY_ED25519 = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' +const TEST_KEY_RSA = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' +const TEST_KEY_ECDSA = 'AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' + +interface TestContext { + tmpDir: string + dbPath: string +} + +function createTestDb(dbPath: string): void { + const db = new Database(dbPath) + db.exec(HOST_KEY_SCHEMA) + db.close() +} + +function seedTestDb(dbPath: string): void { + const db = new Database(dbPath) + db.exec(HOST_KEY_SCHEMA) + + const insert = db.prepare( + 'INSERT INTO host_keys (host, port, algorithm, key, comment) VALUES (?, ?, ?, ?, ?)' + ) + + insert.run('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519, 'test key 1') + insert.run('server1.example.com', 22, 'ssh-rsa', TEST_KEY_RSA, 'test key 2') + insert.run('server2.example.com', 2222, 'ecdsa-sha2-nistp256', TEST_KEY_ECDSA, 'test key 3') + + db.close() +} + +void describe('HostKeyStore', () => { + let ctx: TestContext + + beforeEach(() => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hostkey-test-')) + const dbPath = path.join(tmpDir, 'hostkeys.db') + ctx = { tmpDir, dbPath } + }) + + afterEach(() => { + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + }) + + void describe('constructor', () => { + it('should open existing database file', () => { + createTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + expect(store.isOpen()).toBe(true) + store.close() + }) + + it('should set db to null when file does not exist', () => { + const store = new HostKeyStore('/nonexistent/path/hostkeys.db') + + expect(store.isOpen()).toBe(false) + store.close() + }) + }) + + void describe('lookup', () => { + it('should return "trusted" when key matches stored key', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const result = store.lookup('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + + expect(result.status).toBe('trusted') + expect(result.storedKey).toBe(TEST_KEY_ED25519) + store.close() + }) + + it('should return "mismatch" when key differs from stored key', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const result = store.lookup('server1.example.com', 22, 'ssh-ed25519', 'DIFFERENT_KEY_DATA') + + expect(result.status).toBe('mismatch') + expect(result.storedKey).toBe(TEST_KEY_ED25519) + store.close() + }) + + it('should return "unknown" when no key stored for host/port/algorithm', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const result = store.lookup('unknown-host.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + + expect(result.status).toBe('unknown') + expect(result.storedKey).toBeUndefined() + store.close() + }) + + it('should return "unknown" when db is not open', () => { + const store = new HostKeyStore('/nonexistent/path/hostkeys.db') + + const result = store.lookup('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + + expect(result.status).toBe('unknown') + expect(result.storedKey).toBeUndefined() + store.close() + }) + + it('should distinguish by port', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + // server2 key is on port 2222 + const result22 = store.lookup('server2.example.com', 22, 'ecdsa-sha2-nistp256', TEST_KEY_ECDSA) + const result2222 = store.lookup('server2.example.com', 2222, 'ecdsa-sha2-nistp256', TEST_KEY_ECDSA) + + expect(result22.status).toBe('unknown') + expect(result2222.status).toBe('trusted') + store.close() + }) + + it('should distinguish by algorithm', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + // server1 has ssh-ed25519 and ssh-rsa, but not ecdsa + const resultEcdsa = store.lookup('server1.example.com', 22, 'ecdsa-sha2-nistp256', 'some-key') + + expect(resultEcdsa.status).toBe('unknown') + store.close() + }) + + it('should return stored key info without presentedKey', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const result = store.lookup('server1.example.com', 22, 'ssh-ed25519') + + expect(result.status).toBe('trusted') + expect(result.storedKey).toBe(TEST_KEY_ED25519) + store.close() + }) + + it('should return "unknown" without presentedKey when no record exists', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const result = store.lookup('unknown-host.example.com', 22, 'ssh-ed25519') + + expect(result.status).toBe('unknown') + store.close() + }) + }) + + void describe('getAll', () => { + it('should return all keys for a host/port', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const keys = store.getAll('server1.example.com', 22) + + expect(keys).toHaveLength(2) + const algorithms = keys.map(k => k.algorithm).sort() + expect(algorithms).toEqual(['ssh-ed25519', 'ssh-rsa']) + store.close() + }) + + it('should return empty array when no keys found', () => { + seedTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + const keys = store.getAll('unknown-host.example.com', 22) + + expect(keys).toEqual([]) + store.close() + }) + + it('should return empty array when db is not open', () => { + const store = new HostKeyStore('/nonexistent/path/hostkeys.db') + + const keys = store.getAll('server1.example.com', 22) + + expect(keys).toEqual([]) + store.close() + }) + }) + + void describe('close', () => { + it('should close the database', () => { + createTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + expect(store.isOpen()).toBe(true) + store.close() + expect(store.isOpen()).toBe(false) + }) + + it('should be safe to call close multiple times', () => { + createTestDb(ctx.dbPath) + const store = new HostKeyStore(ctx.dbPath) + + store.close() + store.close() // Should not throw + expect(store.isOpen()).toBe(false) + }) + + it('should be safe to call close when db was never opened', () => { + const store = new HostKeyStore('/nonexistent/path/hostkeys.db') + + store.close() // Should not throw + expect(store.isOpen()).toBe(false) + }) + }) +}) From 5f2758788d82c89a1ce62505a71737d2b576ce1e Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:00:58 -0800 Subject: [PATCH 06/31] Implement HostKeyService with fingerprint computation HostKeyService coordinates host key verification using server-side and client-side stores. Provides config getters, server store lookup delegation, and SHA-256 fingerprint computation matching OpenSSH format. Includes 13 tests covering getters, store delegation, fingerprint determinism, and close behavior. --- app/services/host-key/host-key-service.ts | 89 +++++++++ .../host-key/host-key-service.vitest.ts | 187 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 app/services/host-key/host-key-service.ts create mode 100644 tests/unit/services/host-key/host-key-service.vitest.ts diff --git a/app/services/host-key/host-key-service.ts b/app/services/host-key/host-key-service.ts new file mode 100644 index 00000000..0e20358c --- /dev/null +++ b/app/services/host-key/host-key-service.ts @@ -0,0 +1,89 @@ +// app/services/host-key/host-key-service.ts +// Host key verification service + +import crypto from 'node:crypto' +import type { HostKeyVerificationConfig } from '../../types/config.js' +import { HostKeyStore } from './host-key-store.js' +import type { HostKeyLookupResult } from './host-key-store.js' + +/** + * Service coordinating host key verification using server-side + * and/or client-side stores based on configuration. + */ +export class HostKeyService { + private readonly config: HostKeyVerificationConfig + private store: HostKeyStore | null = null + + constructor(config: HostKeyVerificationConfig) { + this.config = config + + if (config.serverStore.enabled) { + this.store = new HostKeyStore(config.serverStore.dbPath) + } + } + + /** + * Whether host key verification is enabled + */ + get isEnabled(): boolean { + return this.config.enabled + } + + /** + * Whether the server-side store is enabled + */ + get serverStoreEnabled(): boolean { + return this.config.serverStore.enabled + } + + /** + * Whether the client-side store is enabled + */ + get clientStoreEnabled(): boolean { + return this.config.clientStore.enabled + } + + /** + * Action to take when an unknown key is encountered + */ + get unknownKeyAction(): 'prompt' | 'alert' | 'reject' { + return this.config.unknownKeyAction + } + + /** + * Look up a host key in the server-side store. + * Returns "unknown" if the server store is not enabled. + */ + serverLookup( + host: string, + port: number, + algorithm: string, + presentedKey: string + ): HostKeyLookupResult { + if (this.store === null) { + return { status: 'unknown' } + } + + return this.store.lookup(host, port, algorithm, presentedKey) + } + + /** + * Compute a SHA-256 fingerprint of a base64-encoded public key. + * Returns "SHA256:" format matching OpenSSH conventions. + */ + static computeFingerprint(base64Key: string): string { + const keyBytes = Buffer.from(base64Key, 'base64') + const hash = crypto.createHash('sha256').update(keyBytes).digest('base64') + return `SHA256:${hash}` + } + + /** + * Close the underlying store. Safe to call multiple times. + */ + close(): void { + if (this.store !== null) { + this.store.close() + this.store = null + } + } +} diff --git a/tests/unit/services/host-key/host-key-service.vitest.ts b/tests/unit/services/host-key/host-key-service.vitest.ts new file mode 100644 index 00000000..8d3948bb --- /dev/null +++ b/tests/unit/services/host-key/host-key-service.vitest.ts @@ -0,0 +1,187 @@ +// tests/unit/services/host-key/host-key-service.vitest.ts +// Tests for HostKeyService + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' +import { HostKeyService } from '../../../../app/services/host-key/host-key-service.js' +import type { HostKeyVerificationConfig } from '../../../../app/types/config.js' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +const HOST_KEY_SCHEMA = ` +CREATE TABLE host_keys ( + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + algorithm TEXT NOT NULL, + key TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + comment TEXT, + PRIMARY KEY (host, port, algorithm) +); +` + +const TEST_KEY_ED25519 = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' +const TEST_KEY_RSA = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' + +function buildConfig(overrides?: Partial): HostKeyVerificationConfig { + return { + enabled: true, + mode: 'hybrid', + unknownKeyAction: 'prompt', + serverStore: { + enabled: true, + dbPath: '/data/hostkeys.db', + }, + clientStore: { + enabled: true, + }, + ...overrides, + } +} + +interface TestContext { + tmpDir: string + dbPath: string +} + +function seedTestDb(dbPath: string): void { + const db = new Database(dbPath) + db.exec(HOST_KEY_SCHEMA) + const insert = db.prepare( + 'INSERT INTO host_keys (host, port, algorithm, key, comment) VALUES (?, ?, ?, ?, ?)' + ) + insert.run('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519, 'test key') + db.close() +} + +void describe('HostKeyService', () => { + let ctx: TestContext + + beforeEach(() => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hostkey-svc-')) + const dbPath = path.join(tmpDir, 'hostkeys.db') + ctx = { tmpDir, dbPath } + }) + + afterEach(() => { + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + }) + + void describe('getters', () => { + it('should expose isEnabled', () => { + const svc = new HostKeyService(buildConfig({ enabled: true })) + expect(svc.isEnabled).toBe(true) + svc.close() + }) + + it('should expose isEnabled=false', () => { + const svc = new HostKeyService(buildConfig({ enabled: false })) + expect(svc.isEnabled).toBe(false) + svc.close() + }) + + it('should expose serverStoreEnabled', () => { + const svc = new HostKeyService(buildConfig({ + serverStore: { enabled: true, dbPath: ctx.dbPath }, + })) + expect(svc.serverStoreEnabled).toBe(true) + svc.close() + }) + + it('should expose clientStoreEnabled', () => { + const svc = new HostKeyService(buildConfig({ + clientStore: { enabled: false }, + })) + expect(svc.clientStoreEnabled).toBe(false) + svc.close() + }) + + it('should expose unknownKeyAction', () => { + const svc = new HostKeyService(buildConfig({ unknownKeyAction: 'reject' })) + expect(svc.unknownKeyAction).toBe('reject') + svc.close() + }) + }) + + void describe('serverLookup', () => { + it('should delegate to the underlying store', () => { + seedTestDb(ctx.dbPath) + const config = buildConfig({ + serverStore: { enabled: true, dbPath: ctx.dbPath }, + }) + const svc = new HostKeyService(config) + + const result = svc.serverLookup('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + + expect(result.status).toBe('trusted') + svc.close() + }) + + it('should return "unknown" when server store is not enabled', () => { + const config = buildConfig({ + serverStore: { enabled: false, dbPath: ctx.dbPath }, + }) + const svc = new HostKeyService(config) + + const result = svc.serverLookup('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + + expect(result.status).toBe('unknown') + svc.close() + }) + }) + + void describe('computeFingerprint', () => { + it('should produce a SHA256: prefixed fingerprint', () => { + const fingerprint = HostKeyService.computeFingerprint(TEST_KEY_ED25519) + + expect(fingerprint.startsWith('SHA256:')).toBe(true) + // Base64 hash should be non-empty + expect(fingerprint.length).toBeGreaterThan(7) + }) + + it('should be deterministic for the same key', () => { + const fp1 = HostKeyService.computeFingerprint(TEST_KEY_ED25519) + const fp2 = HostKeyService.computeFingerprint(TEST_KEY_ED25519) + + expect(fp1).toBe(fp2) + }) + + it('should produce different fingerprints for different keys', () => { + const fp1 = HostKeyService.computeFingerprint(TEST_KEY_ED25519) + const fp2 = HostKeyService.computeFingerprint(TEST_KEY_RSA) + + expect(fp1).not.toBe(fp2) + }) + + it('should use base64 encoding in the hash part', () => { + const fingerprint = HostKeyService.computeFingerprint(TEST_KEY_ED25519) + const hashPart = fingerprint.slice('SHA256:'.length) + + // Base64 characters only (with padding) + expect(hashPart).toMatch(/^[A-Za-z0-9+/]+=*$/) + }) + }) + + void describe('close', () => { + it('should close the underlying store', () => { + seedTestDb(ctx.dbPath) + const config = buildConfig({ + serverStore: { enabled: true, dbPath: ctx.dbPath }, + }) + const svc = new HostKeyService(config) + + svc.close() + + // After close, lookup should return unknown (store is closed) + const result = svc.serverLookup('server1.example.com', 22, 'ssh-ed25519', TEST_KEY_ED25519) + expect(result.status).toBe('unknown') + }) + + it('should be safe to call close multiple times', () => { + const svc = new HostKeyService(buildConfig()) + svc.close() + svc.close() // Should not throw + }) + }) +}) From 6263cb7527f01839a189e9cbcf60e796cf77ba4e Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:04:57 -0800 Subject: [PATCH 07/31] Register HostKeyService in service factory and add Zod schema Add HostKeyService to the Services interface and create it in the factory when host key verification is enabled. Add Zod validation schema for HostKeyVerificationConfig to ensure the config passes through schema validation without being stripped. --- app/schemas/config-schema.ts | 29 ++++++++++++++++++++++++++++- app/services/factory.ts | 12 +++++++++++- app/services/interfaces.ts | 2 ++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/schemas/config-schema.ts b/app/schemas/config-schema.ts index 0d34b5b6..c21105b6 100644 --- a/app/schemas/config-schema.ts +++ b/app/schemas/config-schema.ts @@ -77,6 +77,32 @@ const UserSchema = z.object({ passphrase: z.string().nullable() }) +/** + * Host key verification server store schema + */ +const HostKeyServerStoreSchema = z.object({ + enabled: z.boolean(), + dbPath: z.string() +}) + +/** + * Host key verification client store schema + */ +const HostKeyClientStoreSchema = z.object({ + enabled: z.boolean() +}) + +/** + * Host key verification configuration schema + */ +const HostKeyVerificationSchema = z.object({ + enabled: z.boolean(), + mode: z.enum(['server', 'client', 'hybrid']), + unknownKeyAction: z.enum(['prompt', 'alert', 'reject']), + serverStore: HostKeyServerStoreSchema, + clientStore: HostKeyClientStoreSchema +}) + /** * SSH configuration schema */ @@ -98,7 +124,8 @@ const SSHSchema = z.object({ maxExecOutputBytes: z.number().int().positive().optional(), outputRateLimitBytesPerSec: z.number().int().nonnegative().optional(), socketHighWaterMark: z.number().int().positive().optional(), - sftp: SftpSchema.optional() + sftp: SftpSchema.optional(), + hostKeyVerification: HostKeyVerificationSchema }) /** diff --git a/app/services/factory.ts b/app/services/factory.ts index 0fcf7e97..67a86a80 100644 --- a/app/services/factory.ts +++ b/app/services/factory.ts @@ -27,6 +27,8 @@ import debug from 'debug' import { createAppStructuredLogger } from '../logger.js' import type { StructuredLogger, StructuredLoggerOptions } from '../logging/structured-logger.js' import { DEFAULT_SFTP_CONFIG } from '../config/default-config.js' +import { HostKeyService } from './host-key/host-key-service.js' +import { resolveHostKeyMode } from '../config/config-processor.js' const factoryLogger = debug('webssh2:services:factory') @@ -113,12 +115,20 @@ export function createServices( ? createShellFileService(sftpConfig, sftpDeps) : createSftpService(sftpConfig, sftpDeps) + // Create host key service if configured + const hostKeyConfig = resolveHostKeyMode(deps.config.ssh.hostKeyVerification) + const hostKey = hostKeyConfig.enabled ? new HostKeyService(hostKeyConfig) : undefined + const services: Services = { auth, ssh, terminal, session, - sftp + sftp, + } + + if (hostKey !== undefined) { + services.hostKey = hostKey } factoryLogger('Services created successfully') diff --git a/app/services/interfaces.ts b/app/services/interfaces.ts index dc29a7e4..8211da70 100644 --- a/app/services/interfaces.ts +++ b/app/services/interfaces.ts @@ -10,6 +10,7 @@ import type { Config } from '../types/config.js' import type { Client as SSH2Client } from 'ssh2' import type { Duplex } from 'node:stream' import type { FileService } from './sftp/file-service.js' +import type { HostKeyService } from './host-key/host-key-service.js' /** * Credentials for authentication @@ -325,6 +326,7 @@ export interface Services { terminal: TerminalService session: SessionService sftp?: FileService + hostKey?: HostKeyService } /** From cc23da01d552fc32c35125c9995c976e104ed542 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:30:11 -0800 Subject: [PATCH 08/31] feat(host-key): add hostkey socket events to constants Add HOSTKEY_VERIFY, HOSTKEY_VERIFY_RESPONSE, HOSTKEY_VERIFIED, HOSTKEY_MISMATCH, HOSTKEY_ALERT, and HOSTKEY_REJECTED events for host key verification client-server communication. --- app/constants/socket-events.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/constants/socket-events.ts b/app/constants/socket-events.ts index b8c3bdeb..bcbcca91 100644 --- a/app/constants/socket-events.ts +++ b/app/constants/socket-events.ts @@ -73,6 +73,14 @@ export const SOCKET_EVENTS = { // Connection error events /** Server → Client: Structured connection error with debug info */ CONNECTION_ERROR: 'connection-error', + + // Host key verification events + HOSTKEY_VERIFY: 'hostkey:verify', + HOSTKEY_VERIFY_RESPONSE: 'hostkey:verify-response', + HOSTKEY_VERIFIED: 'hostkey:verified', + HOSTKEY_MISMATCH: 'hostkey:mismatch', + HOSTKEY_ALERT: 'hostkey:alert', + HOSTKEY_REJECTED: 'hostkey:rejected', } as const export type SocketEventType = typeof SOCKET_EVENTS[keyof typeof SOCKET_EVENTS] \ No newline at end of file From 62ef1cbaf8018db7525544c4b01fdfef9e79c43c Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:31:47 -0800 Subject: [PATCH 09/31] feat(host-key): implement hostVerifier callback factory Add createHostKeyVerifier factory that returns an async callback for SSH2 hostVerifier option. Implements the full decision tree: server store lookup, client store verification with socket events, timeout handling, and unknownKeyAction fallback (alert/reject/prompt). Includes 9 unit tests covering all branches of the verification flow. --- app/services/host-key/host-key-verifier.ts | 260 ++++++++++++++ .../host-key/host-key-verifier.vitest.ts | 334 ++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 app/services/host-key/host-key-verifier.ts create mode 100644 tests/unit/services/host-key/host-key-verifier.vitest.ts diff --git a/app/services/host-key/host-key-verifier.ts b/app/services/host-key/host-key-verifier.ts new file mode 100644 index 00000000..88939f24 --- /dev/null +++ b/app/services/host-key/host-key-verifier.ts @@ -0,0 +1,260 @@ +// app/services/host-key/host-key-verifier.ts +// Factory for SSH2 hostVerifier callback + +import type { Socket } from 'socket.io' +import { HostKeyService } from './host-key-service.js' +import { SOCKET_EVENTS } from '../../constants/socket-events.js' + +/** + * Options for creating a host key verifier callback + */ +export interface CreateHostKeyVerifierOptions { + hostKeyService: HostKeyService + socket: Socket + host: string + port: number + log: (...args: unknown[]) => void + timeout?: number +} + +/** + * Payload emitted with hostkey:verify to prompt the client + */ +interface HostKeyVerifyPayload { + host: string + port: number + algorithm: string + fingerprint: string + key: string +} + +/** + * Payload emitted with hostkey:verified on success + */ +interface HostKeyVerifiedPayload { + host: string + port: number + algorithm: string + fingerprint: string + source: 'server' | 'client' +} + +/** + * Payload emitted with hostkey:mismatch on key mismatch + */ +interface HostKeyMismatchPayload { + host: string + port: number + algorithm: string + presentedFingerprint: string + storedFingerprint: string + source: 'server' | 'client' +} + +/** + * Payload emitted with hostkey:alert for unknown key alerts + */ +interface HostKeyAlertPayload { + host: string + port: number + algorithm: string + fingerprint: string +} + +/** + * Payload emitted with hostkey:rejected when key is rejected + */ +interface HostKeyRejectedPayload { + host: string + port: number + algorithm: string + fingerprint: string +} + +/** + * Client response to a host key verification prompt + */ +interface HostKeyVerifyResponse { + action: 'accept' | 'reject' | 'trusted' +} + +const DEFAULT_TIMEOUT = 30000 + +/** + * Create a hostVerifier callback for SSH2's Client.connect(). + * + * Decision tree: + * 1. Feature disabled -> return true + * 2. Server store lookup: + * - trusted -> emit verified, return true + * - mismatch -> emit mismatch, return false + * - unknown -> fall through + * 3. Client store enabled -> emit verify, await client response + * - trusted/accept -> emit verified, return true + * - reject -> return false + * - timeout -> return false + * 4. Neither store has key -> apply unknownKeyAction: + * - alert -> emit alert, return true + * - reject -> emit rejected, return false + * - prompt -> emit verify, await client response + */ +export function createHostKeyVerifier( + options: CreateHostKeyVerifierOptions +): (key: Buffer, info: { hostType: string }) => Promise { + const { + hostKeyService, + socket, + host, + port, + log, + timeout = DEFAULT_TIMEOUT, + } = options + + return async (key: Buffer, info: { hostType: string }): Promise => { + // Step 1: Feature disabled + if (!hostKeyService.isEnabled) { + return true + } + + const algorithm = info.hostType + const base64Key = key.toString('base64') + const fingerprint = HostKeyService.computeFingerprint(base64Key) + + log('Host key verification for', host, port, algorithm, fingerprint) + + // Step 2: Server store lookup + if (hostKeyService.serverStoreEnabled) { + const lookupResult = hostKeyService.serverLookup(host, port, algorithm, base64Key) + + if (lookupResult.status === 'trusted') { + log('Host key trusted by server store') + const payload: HostKeyVerifiedPayload = { + host, + port, + algorithm, + fingerprint, + source: 'server', + } + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, payload) + return true + } + + if (lookupResult.status === 'mismatch') { + log('Host key MISMATCH detected by server store') + const storedFingerprint = lookupResult.storedKey !== undefined + ? HostKeyService.computeFingerprint(lookupResult.storedKey) + : 'unknown' + const payload: HostKeyMismatchPayload = { + host, + port, + algorithm, + presentedFingerprint: fingerprint, + storedFingerprint, + source: 'server', + } + socket.emit(SOCKET_EVENTS.HOSTKEY_MISMATCH, payload) + return false + } + + // status === 'unknown', fall through + log('Host key unknown in server store, checking client store') + } + + // Step 3: Client store lookup + if (hostKeyService.clientStoreEnabled) { + return awaitClientVerification( + socket, host, port, algorithm, base64Key, fingerprint, 'client', log, timeout + ) + } + + // Step 4: Neither store has key -> apply unknownKeyAction + const action = hostKeyService.unknownKeyAction + + if (action === 'alert') { + log('Unknown key action: alert') + const payload: HostKeyAlertPayload = { + host, + port, + algorithm, + fingerprint, + } + socket.emit(SOCKET_EVENTS.HOSTKEY_ALERT, payload) + return true + } + + if (action === 'reject') { + log('Unknown key action: reject') + const payload: HostKeyRejectedPayload = { + host, + port, + algorithm, + fingerprint, + } + socket.emit(SOCKET_EVENTS.HOSTKEY_REJECTED, payload) + return false + } + + // action === 'prompt' + log('Unknown key action: prompt') + return awaitClientVerification( + socket, host, port, algorithm, base64Key, fingerprint, 'prompt', log, timeout + ) + } +} + +/** + * Emit a verify event to the client and wait for their response + * with a configurable timeout. + */ +function awaitClientVerification( + socket: Socket, + host: string, + port: number, + algorithm: string, + base64Key: string, + fingerprint: string, + source: 'client' | 'prompt', + log: (...args: unknown[]) => void, + timeout: number +): Promise { + return new Promise((resolve) => { + const verifyPayload: HostKeyVerifyPayload = { + host, + port, + algorithm, + fingerprint, + key: base64Key, + } + + const handler = (response: HostKeyVerifyResponse): void => { + clearTimeout(timer) + + if (response.action === 'accept' || response.action === 'trusted') { + log('Client accepted host key') + const verifiedPayload: HostKeyVerifiedPayload = { + host, + port, + algorithm, + fingerprint, + source: 'client', + } + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, verifiedPayload) + resolve(true) + return + } + + // action === 'reject' + log('Client rejected host key') + resolve(false) + } + + const timer = setTimeout(() => { + log('Host key verification timed out') + socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + resolve(false) + }, timeout) + + socket.once(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFY, verifyPayload) + }) +} diff --git a/tests/unit/services/host-key/host-key-verifier.vitest.ts b/tests/unit/services/host-key/host-key-verifier.vitest.ts new file mode 100644 index 00000000..ba66fc43 --- /dev/null +++ b/tests/unit/services/host-key/host-key-verifier.vitest.ts @@ -0,0 +1,334 @@ +// tests/unit/services/host-key/host-key-verifier.vitest.ts +// Tests for createHostKeyVerifier factory + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createHostKeyVerifier } from '../../../../app/services/host-key/host-key-verifier.js' +import { HostKeyService } from '../../../../app/services/host-key/host-key-service.js' +import { SOCKET_EVENTS } from '../../../../app/constants/socket-events.js' + +// --- Mock helpers --- + +interface MockSocket { + emit: ReturnType + once: ReturnType + removeListener: ReturnType +} + +function createMockSocket(): MockSocket { + return { + emit: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } +} + +function createMockHostKeyService(overrides: { + isEnabled?: boolean + serverStoreEnabled?: boolean + clientStoreEnabled?: boolean + unknownKeyAction?: 'prompt' | 'alert' | 'reject' + serverLookupResult?: { status: 'trusted' | 'mismatch' | 'unknown'; storedKey?: string } +}): HostKeyService { + const service = { + get isEnabled() { return overrides.isEnabled ?? true }, + get serverStoreEnabled() { return overrides.serverStoreEnabled ?? false }, + get clientStoreEnabled() { return overrides.clientStoreEnabled ?? false }, + get unknownKeyAction() { return overrides.unknownKeyAction ?? 'prompt' }, + serverLookup: vi.fn().mockReturnValue(overrides.serverLookupResult ?? { status: 'unknown' }), + close: vi.fn(), + } + return service as unknown as HostKeyService +} + +// Base64 key and algorithm for testing +const TEST_ALGORITHM = 'ssh-ed25519' +const TEST_BASE64_KEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' +const TEST_KEY_BUFFER = Buffer.from(TEST_BASE64_KEY, 'base64') +const TEST_HOST = 'server1.example.com' +const TEST_PORT = 22 +const TEST_FINGERPRINT = HostKeyService.computeFingerprint(TEST_BASE64_KEY) +const STORED_KEY = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' +const STORED_FINGERPRINT = HostKeyService.computeFingerprint(STORED_KEY) + +function mockLog(..._args: unknown[]): void { + // no-op for tests +} + +describe('createHostKeyVerifier', () => { + let socket: MockSocket + + beforeEach(() => { + socket = createMockSocket() + vi.useFakeTimers() + }) + + it('returns true without events when feature is disabled', async () => { + const service = createMockHostKeyService({ isEnabled: false }) + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + expect(result).toBe(true) + expect(socket.emit).not.toHaveBeenCalled() + }) + + it('returns true and emits verified when server store reports trusted', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: true, + serverLookupResult: { status: 'trusted', storedKey: TEST_BASE64_KEY }, + }) + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFIED, + expect.objectContaining({ source: 'server' }) + ) + }) + + it('returns false and emits mismatch when server store reports mismatch', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: true, + serverLookupResult: { status: 'mismatch', storedKey: STORED_KEY }, + }) + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + expect(result).toBe(false) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_MISMATCH, + expect.objectContaining({ + source: 'server', + presentedFingerprint: TEST_FINGERPRINT, + storedFingerprint: STORED_FINGERPRINT, + }) + ) + }) + + it('returns true when server unknown and client accepts', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: true, + clientStoreEnabled: true, + serverLookupResult: { status: 'unknown' }, + }) + + // Simulate client responding 'accept' + socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { + // Respond asynchronously to mimic real socket behavior + setTimeout(() => { + handler({ action: 'accept' }) + }, 10) + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + // Advance timer to trigger the client response + await vi.advanceTimersByTimeAsync(10) + + const result = await promise + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFY, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm: TEST_ALGORITHM, + fingerprint: TEST_FINGERPRINT, + }) + ) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFIED, + expect.objectContaining({ source: 'client' }) + ) + }) + + it('returns false when server unknown and client rejects', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: true, + clientStoreEnabled: true, + serverLookupResult: { status: 'unknown' }, + }) + + socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { + setTimeout(() => { + handler({ action: 'reject' }) + }, 10) + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + await vi.advanceTimersByTimeAsync(10) + const result = await promise + + expect(result).toBe(false) + }) + + it('returns false when client response times out', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: false, + clientStoreEnabled: true, + }) + + // Do not respond — let it timeout + socket.once.mockImplementation(() => { + // intentionally empty: simulates no client response + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + timeout: 5000, + }) + + const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(5001) + + const result = await promise + + expect(result).toBe(false) + expect(socket.removeListener).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, + expect.any(Function) + ) + }) + + it('returns false and emits rejected when neither store has key and action is reject', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: false, + clientStoreEnabled: false, + unknownKeyAction: 'reject', + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + expect(result).toBe(false) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_REJECTED, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + }) + ) + }) + + it('returns true and emits alert when neither store has key and action is alert', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: false, + clientStoreEnabled: false, + unknownKeyAction: 'alert', + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_ALERT, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + fingerprint: TEST_FINGERPRINT, + }) + ) + }) + + it('prompts client when neither store has key and action is prompt', async () => { + const service = createMockHostKeyService({ + isEnabled: true, + serverStoreEnabled: false, + clientStoreEnabled: false, + unknownKeyAction: 'prompt', + }) + + socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { + setTimeout(() => { + handler({ action: 'accept' }) + }, 10) + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + await vi.advanceTimersByTimeAsync(10) + const result = await promise + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFY, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + }) + ) + }) +}) From eddf34e4a18ab6b5d05afb62a8577e583e307a06 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:35:44 -0800 Subject: [PATCH 10/31] feat(host-key): wire hostVerifier into SSH service connect flow Add socket property to SSHConfig interface for host key verification. SSHServiceImpl now accepts optional HostKeyService via constructor and creates the hostVerifier callback when both the service and socket are available. The factory creates HostKeyService before SSHServiceImpl. Updated verifier to use SSH2 HostVerifier callback-style API with extractAlgorithm helper to parse algorithm from raw key buffer. --- app/services/factory.ts | 10 +- app/services/host-key/host-key-verifier.ts | 138 ++++++++++-------- app/services/interfaces.ts | 6 + app/services/ssh/ssh-service.ts | 32 +++- .../host-key/host-key-verifier.vitest.ts | 64 ++++++-- 5 files changed, 170 insertions(+), 80 deletions(-) diff --git a/app/services/factory.ts b/app/services/factory.ts index 67a86a80..bf851697 100644 --- a/app/services/factory.ts +++ b/app/services/factory.ts @@ -94,9 +94,13 @@ export function createServices( ): Services { factoryLogger('Creating services') + // Create host key service if configured (needed by SSH service) + const hostKeyConfig = resolveHostKeyMode(deps.config.ssh.hostKeyVerification) + const hostKey = hostKeyConfig.enabled ? new HostKeyService(hostKeyConfig) : undefined + // Create service implementations const auth = new AuthServiceImpl(deps, deps.store) - const ssh = new SSHServiceImpl(deps, deps.store) + const ssh = new SSHServiceImpl(deps, deps.store, hostKey) const terminal = new TerminalServiceImpl(deps, deps.store) const session = new SessionServiceImpl(deps, deps.store) @@ -115,10 +119,6 @@ export function createServices( ? createShellFileService(sftpConfig, sftpDeps) : createSftpService(sftpConfig, sftpDeps) - // Create host key service if configured - const hostKeyConfig = resolveHostKeyMode(deps.config.ssh.hostKeyVerification) - const hostKey = hostKeyConfig.enabled ? new HostKeyService(hostKeyConfig) : undefined - const services: Services = { auth, ssh, diff --git a/app/services/host-key/host-key-verifier.ts b/app/services/host-key/host-key-verifier.ts index 88939f24..5c6565cb 100644 --- a/app/services/host-key/host-key-verifier.ts +++ b/app/services/host-key/host-key-verifier.ts @@ -2,6 +2,7 @@ // Factory for SSH2 hostVerifier callback import type { Socket } from 'socket.io' +import type { HostVerifier } from 'ssh2' import { HostKeyService } from './host-key-service.js' import { SOCKET_EVENTS } from '../../constants/socket-events.js' @@ -81,26 +82,43 @@ interface HostKeyVerifyResponse { const DEFAULT_TIMEOUT = 30000 /** - * Create a hostVerifier callback for SSH2's Client.connect(). + * Extract the algorithm name from an SSH public key buffer. + * + * SSH public key wire format: 4-byte big-endian length + algorithm string + key data. + * Returns 'unknown' if the buffer is too short to parse. + */ +export function extractAlgorithm(keyBuffer: Buffer): string { + if (keyBuffer.length < 4) { + return 'unknown' + } + const algLength = keyBuffer.readUInt32BE(0) + if (keyBuffer.length < 4 + algLength) { + return 'unknown' + } + return keyBuffer.subarray(4, 4 + algLength).toString('ascii') +} + +/** + * Create a hostVerifier callback for SSH2 Client.connect(). * * Decision tree: - * 1. Feature disabled -> return true + * 1. Feature disabled -> verify(true) * 2. Server store lookup: - * - trusted -> emit verified, return true - * - mismatch -> emit mismatch, return false + * - trusted -> emit verified, verify(true) + * - mismatch -> emit mismatch, verify(false) * - unknown -> fall through * 3. Client store enabled -> emit verify, await client response - * - trusted/accept -> emit verified, return true - * - reject -> return false - * - timeout -> return false + * - trusted/accept -> emit verified, verify(true) + * - reject -> verify(false) + * - timeout -> verify(false) * 4. Neither store has key -> apply unknownKeyAction: - * - alert -> emit alert, return true - * - reject -> emit rejected, return false + * - alert -> emit alert, verify(true) + * - reject -> emit rejected, verify(false) * - prompt -> emit verify, await client response */ export function createHostKeyVerifier( options: CreateHostKeyVerifierOptions -): (key: Buffer, info: { hostType: string }) => Promise { +): HostVerifier { const { hostKeyService, socket, @@ -110,13 +128,14 @@ export function createHostKeyVerifier( timeout = DEFAULT_TIMEOUT, } = options - return async (key: Buffer, info: { hostType: string }): Promise => { + return (key: Buffer, verify: (valid: boolean) => void): void => { // Step 1: Feature disabled if (!hostKeyService.isEnabled) { - return true + verify(true) + return } - const algorithm = info.hostType + const algorithm = extractAlgorithm(key) const base64Key = key.toString('base64') const fingerprint = HostKeyService.computeFingerprint(base64Key) @@ -136,7 +155,8 @@ export function createHostKeyVerifier( source: 'server', } socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, payload) - return true + verify(true) + return } if (lookupResult.status === 'mismatch') { @@ -153,7 +173,8 @@ export function createHostKeyVerifier( source: 'server', } socket.emit(SOCKET_EVENTS.HOSTKEY_MISMATCH, payload) - return false + verify(false) + return } // status === 'unknown', fall through @@ -162,9 +183,10 @@ export function createHostKeyVerifier( // Step 3: Client store lookup if (hostKeyService.clientStoreEnabled) { - return awaitClientVerification( - socket, host, port, algorithm, base64Key, fingerprint, 'client', log, timeout + awaitClientVerification( + socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify ) + return } // Step 4: Neither store has key -> apply unknownKeyAction @@ -179,7 +201,8 @@ export function createHostKeyVerifier( fingerprint, } socket.emit(SOCKET_EVENTS.HOSTKEY_ALERT, payload) - return true + verify(true) + return } if (action === 'reject') { @@ -191,13 +214,14 @@ export function createHostKeyVerifier( fingerprint, } socket.emit(SOCKET_EVENTS.HOSTKEY_REJECTED, payload) - return false + verify(false) + return } // action === 'prompt' log('Unknown key action: prompt') - return awaitClientVerification( - socket, host, port, algorithm, base64Key, fingerprint, 'prompt', log, timeout + awaitClientVerification( + socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify ) } } @@ -213,48 +237,46 @@ function awaitClientVerification( algorithm: string, base64Key: string, fingerprint: string, - source: 'client' | 'prompt', log: (...args: unknown[]) => void, - timeout: number -): Promise { - return new Promise((resolve) => { - const verifyPayload: HostKeyVerifyPayload = { - host, - port, - algorithm, - fingerprint, - key: base64Key, - } + timeout: number, + verify: (valid: boolean) => void +): void { + const verifyPayload: HostKeyVerifyPayload = { + host, + port, + algorithm, + fingerprint, + key: base64Key, + } - const handler = (response: HostKeyVerifyResponse): void => { - clearTimeout(timer) + const handler = (response: HostKeyVerifyResponse): void => { + clearTimeout(timer) - if (response.action === 'accept' || response.action === 'trusted') { - log('Client accepted host key') - const verifiedPayload: HostKeyVerifiedPayload = { - host, - port, - algorithm, - fingerprint, - source: 'client', - } - socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, verifiedPayload) - resolve(true) - return + if (response.action === 'accept' || response.action === 'trusted') { + log('Client accepted host key') + const verifiedPayload: HostKeyVerifiedPayload = { + host, + port, + algorithm, + fingerprint, + source: 'client', } - - // action === 'reject' - log('Client rejected host key') - resolve(false) + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, verifiedPayload) + verify(true) + return } - const timer = setTimeout(() => { - log('Host key verification timed out') - socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) - resolve(false) - }, timeout) + // action === 'reject' + log('Client rejected host key') + verify(false) + } + + const timer = setTimeout(() => { + log('Host key verification timed out') + socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + verify(false) + }, timeout) - socket.once(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) - socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFY, verifyPayload) - }) + socket.once(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFY, verifyPayload) } diff --git a/app/services/interfaces.ts b/app/services/interfaces.ts index 8211da70..e0f89f78 100644 --- a/app/services/interfaces.ts +++ b/app/services/interfaces.ts @@ -96,6 +96,12 @@ export interface SSHConfig { * bypassing auto-answer logic for password prompts. */ forwardAllPrompts?: boolean + /** + * Socket.IO socket for host key verification communication with client. + * When provided alongside an enabled HostKeyService, the SSH service + * will set up the hostVerifier callback for the connection. + */ + socket?: import('socket.io').Socket } /** diff --git a/app/services/ssh/ssh-service.ts b/app/services/ssh/ssh-service.ts index 881478f6..fa96b7de 100644 --- a/app/services/ssh/ssh-service.ts +++ b/app/services/ssh/ssh-service.ts @@ -36,6 +36,8 @@ import { createAlgorithmCapture, type AlgorithmCapture } from './algorithm-capture.js' +import type { HostKeyService } from '../host-key/host-key-service.js' +import { createHostKeyVerifier } from '../host-key/host-key-verifier.js' const logger = debug('webssh2:services:ssh') const ssh2ProtocolLogger = debug('webssh2:ssh2') @@ -50,7 +52,8 @@ export class SSHServiceImpl implements SSHService { constructor( private readonly deps: ServiceDependencies, - private readonly store: SessionStore + private readonly store: SessionStore, + private readonly hostKeyService?: HostKeyService ) { this.connectionTimeout = deps.config.ssh.readyTimeout this.keepaliveInterval = deps.config.ssh.keepaliveInterval @@ -73,7 +76,10 @@ export class SSHServiceImpl implements SSHService { * Build SSH2 connection config and optionally create algorithm capture * @returns Object containing the connect config and optional algorithm capture */ - private buildConnectConfig(config: SSHConfig): { + private buildConnectConfig( + config: SSHConfig, + hostVerifier?: import('ssh2').HostVerifier + ): { connectConfig: Parameters[0] algorithmCapture: AlgorithmCapture | null } { @@ -131,6 +137,10 @@ export class SSHServiceImpl implements SSHService { } } + if (hostVerifier !== undefined) { + connectConfig.hostVerifier = hostVerifier + } + return { connectConfig, algorithmCapture } } @@ -264,7 +274,23 @@ export class SSHServiceImpl implements SSHService { resolve(err(new Error('Connection timeout'))) }, this.connectionTimeout) - const { connectConfig, algorithmCapture } = this.buildConnectConfig(config) + // Create host key verifier if service is available and socket is provided + let hostVerifier: import('ssh2').HostVerifier | undefined + if ( + this.hostKeyService !== undefined && + this.hostKeyService.isEnabled && + config.socket !== undefined + ) { + hostVerifier = createHostKeyVerifier({ + hostKeyService: this.hostKeyService, + socket: config.socket, + host: config.host, + port: config.port, + log: logger, + }) + } + + const { connectConfig, algorithmCapture } = this.buildConnectConfig(config, hostVerifier) this.setupKeyboardInteractiveHandler(client, config) registerConnectionHandlers( diff --git a/tests/unit/services/host-key/host-key-verifier.vitest.ts b/tests/unit/services/host-key/host-key-verifier.vitest.ts index ba66fc43..d80ebf6b 100644 --- a/tests/unit/services/host-key/host-key-verifier.vitest.ts +++ b/tests/unit/services/host-key/host-key-verifier.vitest.ts @@ -1,8 +1,11 @@ // tests/unit/services/host-key/host-key-verifier.vitest.ts // Tests for createHostKeyVerifier factory -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createHostKeyVerifier } from '../../../../app/services/host-key/host-key-verifier.js' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + createHostKeyVerifier, + extractAlgorithm, +} from '../../../../app/services/host-key/host-key-verifier.js' import { HostKeyService } from '../../../../app/services/host-key/host-key-service.js' import { SOCKET_EVENTS } from '../../../../app/constants/socket-events.js' @@ -40,8 +43,22 @@ function createMockHostKeyService(overrides: { return service as unknown as HostKeyService } +/** + * Helper: invoke the verifier and return a promise that resolves + * when the verify callback is called. + */ +function callVerifier( + verifier: (key: Buffer, verify: (valid: boolean) => void) => void, + keyBuffer: Buffer +): Promise { + return new Promise((resolve) => { + verifier(keyBuffer, (valid: boolean) => { + resolve(valid) + }) + }) +} + // Base64 key and algorithm for testing -const TEST_ALGORITHM = 'ssh-ed25519' const TEST_BASE64_KEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' const TEST_KEY_BUFFER = Buffer.from(TEST_BASE64_KEY, 'base64') const TEST_HOST = 'server1.example.com' @@ -54,6 +71,22 @@ function mockLog(..._args: unknown[]): void { // no-op for tests } +describe('extractAlgorithm', () => { + it('extracts ssh-ed25519 from a key buffer', () => { + expect(extractAlgorithm(TEST_KEY_BUFFER)).toBe('ssh-ed25519') + }) + + it('returns unknown for a buffer that is too short', () => { + expect(extractAlgorithm(Buffer.alloc(2))).toBe('unknown') + }) + + it('returns unknown when length field exceeds buffer', () => { + const buf = Buffer.alloc(8) + buf.writeUInt32BE(100, 0) // claims 100 bytes but only 4 follow + expect(extractAlgorithm(buf)).toBe('unknown') + }) +}) + describe('createHostKeyVerifier', () => { let socket: MockSocket @@ -62,6 +95,10 @@ describe('createHostKeyVerifier', () => { vi.useFakeTimers() }) + afterEach(() => { + vi.useRealTimers() + }) + it('returns true without events when feature is disabled', async () => { const service = createMockHostKeyService({ isEnabled: false }) const verifier = createHostKeyVerifier({ @@ -72,7 +109,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const result = await callVerifier(verifier, TEST_KEY_BUFFER) expect(result).toBe(true) expect(socket.emit).not.toHaveBeenCalled() @@ -92,7 +129,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const result = await callVerifier(verifier, TEST_KEY_BUFFER) expect(result).toBe(true) expect(socket.emit).toHaveBeenCalledWith( @@ -115,7 +152,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const result = await callVerifier(verifier, TEST_KEY_BUFFER) expect(result).toBe(false) expect(socket.emit).toHaveBeenCalledWith( @@ -138,7 +175,6 @@ describe('createHostKeyVerifier', () => { // Simulate client responding 'accept' socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { - // Respond asynchronously to mimic real socket behavior setTimeout(() => { handler({ action: 'accept' }) }, 10) @@ -152,7 +188,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const promise = callVerifier(verifier, TEST_KEY_BUFFER) // Advance timer to trigger the client response await vi.advanceTimersByTimeAsync(10) @@ -165,7 +201,7 @@ describe('createHostKeyVerifier', () => { expect.objectContaining({ host: TEST_HOST, port: TEST_PORT, - algorithm: TEST_ALGORITHM, + algorithm: 'ssh-ed25519', fingerprint: TEST_FINGERPRINT, }) ) @@ -197,7 +233,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const promise = callVerifier(verifier, TEST_KEY_BUFFER) await vi.advanceTimersByTimeAsync(10) const result = await promise @@ -225,7 +261,7 @@ describe('createHostKeyVerifier', () => { timeout: 5000, }) - const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const promise = callVerifier(verifier, TEST_KEY_BUFFER) // Advance past the timeout await vi.advanceTimersByTimeAsync(5001) @@ -255,7 +291,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const result = await callVerifier(verifier, TEST_KEY_BUFFER) expect(result).toBe(false) expect(socket.emit).toHaveBeenCalledWith( @@ -283,7 +319,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const result = await verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const result = await callVerifier(verifier, TEST_KEY_BUFFER) expect(result).toBe(true) expect(socket.emit).toHaveBeenCalledWith( @@ -318,7 +354,7 @@ describe('createHostKeyVerifier', () => { log: mockLog, }) - const promise = verifier(TEST_KEY_BUFFER, { hostType: TEST_ALGORITHM }) + const promise = callVerifier(verifier, TEST_KEY_BUFFER) await vi.advanceTimersByTimeAsync(10) const result = await promise From 47a0f7011f2124c86e8e841b32015c595f75bf90 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:37:40 -0800 Subject: [PATCH 11/31] feat(host-key): send host key config with permissions event Emit hostKeyVerification config (enabled, clientStoreEnabled, unknownKeyAction) in the permissions payload after successful auth. Pass socket reference through SSHConfig for hostVerifier communication. Update socket contract types, test mocks, and contract assertions to include the new hostKeyVerification field in permissions. --- app/socket/adapters/service-socket-authentication.ts | 12 +++++++++++- app/types/contracts/v1/socket.ts | 5 +++++ tests/contracts/socket-contracts.vitest.ts | 2 +- tests/test-utils.ts | 7 +++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app/socket/adapters/service-socket-authentication.ts b/app/socket/adapters/service-socket-authentication.ts index d042ed83..3686625c 100644 --- a/app/socket/adapters/service-socket-authentication.ts +++ b/app/socket/adapters/service-socket-authentication.ts @@ -418,6 +418,10 @@ export class ServiceSocketAuthentication { this.context.config, keyboardInteractiveOptions ) + + // Pass the socket for host key verification communication + sshConfig.socket = this.context.socket + const sshResult = await this.context.services.ssh.connect(sshConfig) if (sshResult.ok) { @@ -672,11 +676,17 @@ export class ServiceSocketAuthentication { success: true }) + const hostKeyVerificationConfig = config.ssh.hostKeyVerification socket.emit(SOCKET_EVENTS.PERMISSIONS, { autoLog: config.options.autoLog, allowReplay: config.options.allowReplay, allowReconnect: config.options.allowReconnect, - allowReauth: config.options.allowReauth + allowReauth: config.options.allowReauth, + hostKeyVerification: { + enabled: hostKeyVerificationConfig.enabled, + clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, + unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, + }, }) // Emit SFTP status after successful authentication diff --git a/app/types/contracts/v1/socket.ts b/app/types/contracts/v1/socket.ts index 9dd90872..d9843938 100644 --- a/app/types/contracts/v1/socket.ts +++ b/app/types/contracts/v1/socket.ts @@ -250,6 +250,11 @@ export interface ServerToClientEvents { allowReplay: boolean allowReconnect: boolean allowReauth: boolean + hostKeyVerification?: { + enabled: boolean + clientStoreEnabled: boolean + unknownKeyAction: 'prompt' | 'alert' | 'reject' + } }) => void // UI updates (element + value) updateUI: (payload: { element: string; value: unknown }) => void diff --git a/tests/contracts/socket-contracts.vitest.ts b/tests/contracts/socket-contracts.vitest.ts index 6fbb1adc..8e401368 100644 --- a/tests/contracts/socket-contracts.vitest.ts +++ b/tests/contracts/socket-contracts.vitest.ts @@ -108,6 +108,6 @@ describe('Socket.IO Contracts', () => { if (!isRecord(payload)) { return } - expect(Object.keys(payload).sort()).toEqual(['allowReauth', 'allowReconnect', 'allowReplay', 'autoLog'].sort()) + expect(Object.keys(payload).sort()).toEqual(['allowReauth', 'allowReconnect', 'allowReplay', 'autoLog', 'hostKeyVerification'].sort()) }) }) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index b5e39e3b..03851edc 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -506,6 +506,13 @@ export function createMockSocketConfig(overrides: Record = {}): unk compress: ['none'] }, allowedAuthMethods: DEFAULT_AUTH_METHODS.map(createAuthMethod), + hostKeyVerification: { + enabled: false, + mode: 'hybrid', + unknownKeyAction: 'prompt', + serverStore: { enabled: false, dbPath: '/tmp/hostkeys.db' }, + clientStore: { enabled: false }, + }, ...overrides.ssh, }, options: { From 1a69405dbc01d09e2749881e88383360a58668dd Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:38:09 -0800 Subject: [PATCH 12/31] feat(host-key): add hostKeyVerification to legacy socket-adapter permissions Update the legacy SocketAdapter.emitPermissions to also include hostKeyVerification config in the permissions payload for consistency with the service-based socket adapter. --- app/socket/adapters/socket-adapter.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/socket/adapters/socket-adapter.ts b/app/socket/adapters/socket-adapter.ts index aae4ff7a..ef49919b 100644 --- a/app/socket/adapters/socket-adapter.ts +++ b/app/socket/adapters/socket-adapter.ts @@ -434,11 +434,17 @@ export class SocketAdapter { * Emit permissions */ emitPermissions(): void { + const hostKeyVerificationConfig = this.config.ssh.hostKeyVerification this.socket.emit(SOCKET_EVENTS.PERMISSIONS, { autoLog: !!this.config.options.autoLog, allowReplay: !!this.config.options.allowReplay, allowReconnect: !!this.config.options.allowReconnect, allowReauth: !!this.config.options.allowReauth, + hostKeyVerification: { + enabled: hostKeyVerificationConfig.enabled, + clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, + unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, + }, }) } From 0a76bea6c131a5d87818d6f34da521499caa2069 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:39:44 -0800 Subject: [PATCH 13/31] fix(tests): add hostKeyVerification to socket-v2 mock config Prevents runtime 'Cannot read properties of undefined' errors in socket-v2 tests when emitAuthSuccess accesses hostKeyVerification. --- tests/unit/socket-v2-test-utils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/socket-v2-test-utils.ts b/tests/unit/socket-v2-test-utils.ts index 68bacd65..4c4bf638 100644 --- a/tests/unit/socket-v2-test-utils.ts +++ b/tests/unit/socket-v2-test-utils.ts @@ -152,6 +152,13 @@ export const createMockConfig = (): MockConfig => ({ serverHostKey: ['ssh-rsa', 'ssh-ed25519'], hmac: ['hmac-sha2-256', 'hmac-sha1'], compress: ['none'] + }, + hostKeyVerification: { + enabled: false, + mode: 'hybrid', + unknownKeyAction: 'prompt', + serverStore: { enabled: false, dbPath: '/tmp/hostkeys.db' }, + clientStore: { enabled: false }, } }, options: { From c4949e2499d2812a7e1be13a97f03fcc7c29c207 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:48:23 -0800 Subject: [PATCH 14/31] Add integration tests for host key verification flow Tests the full server-side verification pipeline end-to-end using a temp SQLite DB, real HostKeyService, and createHostKeyVerifier with mock sockets. Covers all five decision tree branches: trusted key, mismatch, unknown+reject, unknown+alert, and feature disabled. --- .../host-key-verification.vitest.ts | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 tests/integration/host-key-verification.vitest.ts diff --git a/tests/integration/host-key-verification.vitest.ts b/tests/integration/host-key-verification.vitest.ts new file mode 100644 index 00000000..06e43391 --- /dev/null +++ b/tests/integration/host-key-verification.vitest.ts @@ -0,0 +1,311 @@ +// tests/integration/host-key-verification.vitest.ts +// Integration tests for the full host key verification flow: +// temp SQLite DB -> HostKeyService -> createHostKeyVerifier -> mock socket + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import Database from 'better-sqlite3' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { HostKeyService } from '../../app/services/host-key/host-key-service.js' +import { + createHostKeyVerifier, + extractAlgorithm, +} from '../../app/services/host-key/host-key-verifier.js' +import { SOCKET_EVENTS } from '../../app/constants/socket-events.js' +import type { HostKeyVerificationConfig } from '../../app/types/config.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const HOST_KEY_SCHEMA = ` +CREATE TABLE IF NOT EXISTS host_keys ( + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + algorithm TEXT NOT NULL, + key TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + comment TEXT, + PRIMARY KEY (host, port, algorithm) +); +` + +// Deterministic test key data (not real SSH keys, but structurally valid for +// the extractAlgorithm helper: 4-byte BE length + algorithm + opaque data). +const TEST_KEY_ED25519 = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' +const DIFFERENT_KEY = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' +const TEST_HOST = 'server1.example.com' +const TEST_PORT = 22 + +interface MockSocket { + emit: ReturnType + once: ReturnType + removeListener: ReturnType +} + +function createMockSocket(): MockSocket { + return { + emit: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } +} + +function mockLog(..._args: unknown[]): void { + // no-op +} + +/** + * Invoke the verifier with a key buffer and return a promise that resolves + * when the SSH2-style `verify(result)` callback fires. + */ +function callVerifier( + verifier: (key: Buffer, verify: (valid: boolean) => void) => void, + keyBuffer: Buffer +): Promise { + return new Promise((resolve) => { + verifier(keyBuffer, (valid: boolean) => { + resolve(valid) + }) + }) +} + +/** + * Create a temp directory with a seeded SQLite host_keys DB. + */ +function seedDb(dbPath: string, rows: Array<{ host: string; port: number; algorithm: string; key: string }>): void { + const db = new Database(dbPath) + db.exec(HOST_KEY_SCHEMA) + + const insert = db.prepare( + 'INSERT INTO host_keys (host, port, algorithm, key) VALUES (?, ?, ?, ?)' + ) + + for (const row of rows) { + insert.run(row.host, row.port, row.algorithm, row.key) + } + + db.close() +} + +function buildConfig(overrides: Partial & { + serverStoreEnabled?: boolean + clientStoreEnabled?: boolean + dbPath?: string +}): HostKeyVerificationConfig { + return { + enabled: overrides.enabled ?? true, + mode: overrides.mode ?? 'server', + unknownKeyAction: overrides.unknownKeyAction ?? 'reject', + serverStore: { + enabled: overrides.serverStoreEnabled ?? true, + dbPath: overrides.dbPath ?? '/nonexistent.db', + }, + clientStore: { + enabled: overrides.clientStoreEnabled ?? false, + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Host key verification integration', () => { + let tmpDir: string + let dbPath: string + let socket: MockSocket + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hkv-integ-')) + dbPath = path.join(tmpDir, 'hostkeys.db') + socket = createMockSocket() + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('trusts a key that matches the server store and emits hostkey:verified with source "server"', async () => { + // Determine algorithm from the test key so we seed the DB with the right value + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + const algorithm = extractAlgorithm(keyBuffer) + + seedDb(dbPath, [ + { host: TEST_HOST, port: TEST_PORT, algorithm, key: TEST_KEY_ED25519 }, + ]) + + const config = buildConfig({ dbPath, serverStoreEnabled: true }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledTimes(1) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFIED, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm, + source: 'server', + }) + ) + + service.close() + }) + + it('rejects a mismatched key and emits hostkey:mismatch', async () => { + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + const algorithm = extractAlgorithm(keyBuffer) + + // Seed DB with a DIFFERENT key for the same host/port/algorithm + seedDb(dbPath, [ + { host: TEST_HOST, port: TEST_PORT, algorithm, key: DIFFERENT_KEY }, + ]) + + const config = buildConfig({ dbPath, serverStoreEnabled: true }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + + expect(result).toBe(false) + expect(socket.emit).toHaveBeenCalledTimes(1) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_MISMATCH, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm, + presentedFingerprint: HostKeyService.computeFingerprint(TEST_KEY_ED25519), + storedFingerprint: HostKeyService.computeFingerprint(DIFFERENT_KEY), + source: 'server', + }) + ) + + service.close() + }) + + it('rejects an unknown key when unknownKeyAction is "reject" and emits hostkey:rejected', async () => { + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + const algorithm = extractAlgorithm(keyBuffer) + + // Seed an empty DB (no keys at all) + seedDb(dbPath, []) + + const config = buildConfig({ + dbPath, + serverStoreEnabled: true, + clientStoreEnabled: false, + unknownKeyAction: 'reject', + }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + + expect(result).toBe(false) + expect(socket.emit).toHaveBeenCalledTimes(1) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_REJECTED, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm, + fingerprint: HostKeyService.computeFingerprint(TEST_KEY_ED25519), + }) + ) + + service.close() + }) + + it('allows an unknown key when unknownKeyAction is "alert" and emits hostkey:alert', async () => { + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + const algorithm = extractAlgorithm(keyBuffer) + + seedDb(dbPath, []) + + const config = buildConfig({ + dbPath, + serverStoreEnabled: true, + clientStoreEnabled: false, + unknownKeyAction: 'alert', + }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledTimes(1) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_ALERT, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm, + fingerprint: HostKeyService.computeFingerprint(TEST_KEY_ED25519), + }) + ) + + service.close() + }) + + it('returns true with no socket events when the feature is disabled', async () => { + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + + // DB does not need to exist when the feature is disabled + const config = buildConfig({ + enabled: false, + serverStoreEnabled: false, + clientStoreEnabled: false, + }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as import('socket.io').Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + + expect(result).toBe(true) + expect(socket.emit).not.toHaveBeenCalled() + + service.close() + }) +}) From 5d5330ed68d26f0c6e348feee8eae041a5afe43a Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:55:09 -0800 Subject: [PATCH 15/31] Add host key seeding script and fix lint in integration tests Create scripts/host-key-seed.ts CLI tool for managing the SQLite host key database. Supports --host (SSH probe), --hosts (batch file), --known-hosts (OpenSSH import), --list, --remove, and --help commands. Add "hostkeys" npm script to package.json. Also fix lint issues in the integration test file (proper Socket type import, vi import from vitest). --- package.json | 3 +- scripts/host-key-seed.ts | 591 ++++++++++++++++++ .../host-key-verification.vitest.ts | 31 +- 3 files changed, 607 insertions(+), 18 deletions(-) create mode 100644 scripts/host-key-seed.ts diff --git a/package.json b/package.json index b96c8765..e57f23f9 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "typecheck": "tsc -p tsconfig.build.json --noEmit", "build": "tsc -p tsconfig.build.json", "ci": "npm run test", - "security:audit": "npm audit" + "security:audit": "npm audit", + "hostkeys": "tsx scripts/host-key-seed.ts" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/scripts/host-key-seed.ts b/scripts/host-key-seed.ts new file mode 100644 index 00000000..5afb0356 --- /dev/null +++ b/scripts/host-key-seed.ts @@ -0,0 +1,591 @@ +// scripts/host-key-seed.ts +// CLI tool for managing the SQLite host key database. +// +// Usage: +// npm run hostkeys -- --help +// npm run hostkeys -- --host example.com --port 22 +// npm run hostkeys -- --hosts hosts.txt +// npm run hostkeys -- --known-hosts ~/.ssh/known_hosts +// npm run hostkeys -- --list +// npm run hostkeys -- --remove example.com:22 + +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import Database, { type Database as DatabaseType } from 'better-sqlite3' +import { Client as SSH2Client } from 'ssh2' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_PORT = 22 +const PROBE_TIMEOUT_MS = 15_000 +const READY_TIMEOUT_MS = 10_000 + +const HOST_KEY_SCHEMA = ` +CREATE TABLE IF NOT EXISTS host_keys ( + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + algorithm TEXT NOT NULL, + key TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + comment TEXT, + PRIMARY KEY (host, port, algorithm) +); +` + +const USAGE = ` +webssh2 host key management tool + +Usage: + npm run hostkeys -- [options] + +Commands: + --host [--port ] Probe a host via SSH and store its key + --hosts Probe hosts from a file (host[:port] per line) + --known-hosts Import keys from an OpenSSH known_hosts file + --list List all stored host keys + --remove Remove all keys for a host:port pair + --help Show this help message + +Options: + --db Database file path + (default: config.json ssh.hostKeyVerification.serverStore.dbPath + or /data/hostkeys.db) + +Examples: + npm run hostkeys -- --host example.com + npm run hostkeys -- --host example.com --port 2222 + npm run hostkeys -- --hosts servers.txt + npm run hostkeys -- --known-hosts ~/.ssh/known_hosts + npm run hostkeys -- --list + npm run hostkeys -- --list --db /custom/path/hostkeys.db + npm run hostkeys -- --remove example.com:22 +`.trim() + +// --------------------------------------------------------------------------- +// Algorithm extraction (mirrors host-key-verifier.ts) +// --------------------------------------------------------------------------- + +/** + * Extract the algorithm name from an SSH public key buffer. + * SSH wire format: 4-byte big-endian length + algorithm string + key data. + */ +function extractAlgorithm(keyBuffer: Buffer): string { + if (keyBuffer.length < 4) { + return 'unknown' + } + const algLength = keyBuffer.readUInt32BE(0) + if (keyBuffer.length < 4 + algLength) { + return 'unknown' + } + return keyBuffer.subarray(4, 4 + algLength).toString('ascii') +} + +/** + * Compute a SHA-256 fingerprint matching OpenSSH conventions. + */ +function computeFingerprint(base64Key: string): string { + const keyBytes = Buffer.from(base64Key, 'base64') + const hash = crypto.createHash('sha256').update(keyBytes).digest('base64') + return `SHA256:${hash}` +} + +// --------------------------------------------------------------------------- +// Database helpers +// --------------------------------------------------------------------------- + +function openDb(dbPath: string): DatabaseType { + const dir = path.dirname(dbPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + const db = new Database(dbPath) + db.exec(HOST_KEY_SCHEMA) + return db +} + +function upsertKey( + db: DatabaseType, + host: string, + port: number, + algorithm: string, + key: string, + comment?: string +): void { + const stmt = db.prepare( + `INSERT OR REPLACE INTO host_keys (host, port, algorithm, key, added_at, comment) + VALUES (?, ?, ?, ?, datetime('now'), ?)` + ) + stmt.run(host, port, algorithm, key, comment ?? null) +} + +// --------------------------------------------------------------------------- +// SSH host probing +// --------------------------------------------------------------------------- + +interface ProbeResult { + algorithm: string + key: string +} + +function probeHostKey(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const client = new SSH2Client() + let resolved = false + + client.on('error', (err: Error) => { + if (!resolved) { + resolved = true + reject(err) + } + }) + + client.connect({ + host, + port, + username: 'probe', + readyTimeout: READY_TIMEOUT_MS, + hostVerifier: (key: Buffer, verify: (valid: boolean) => void) => { + if (resolved) { + verify(false) + return + } + resolved = true + const base64Key = key.toString('base64') + const algorithm = extractAlgorithm(key) + resolve({ algorithm, key: base64Key }) + verify(false) + client.end() + }, + }) + + setTimeout(() => { + if (!resolved) { + resolved = true + client.end() + reject(new Error(`Timeout connecting to ${host}:${port}`)) + } + }, PROBE_TIMEOUT_MS) + }) +} + +// --------------------------------------------------------------------------- +// known_hosts parsing +// --------------------------------------------------------------------------- + +interface KnownHostEntry { + host: string + port: number + algorithm: string + key: string +} + +function parseKnownHostLine(line: string): KnownHostEntry[] { + const entries: KnownHostEntry[] = [] + + // Format: hostname[,hostname2] algorithm base64key [comment] + const parts = line.split(/\s+/) + if (parts.length < 3) { + return entries + } + + const hostnameField = parts[0] ?? '' + const algorithm = parts[1] ?? '' + const key = parts[2] ?? '' + + if (hostnameField === '' || algorithm === '' || key === '') { + return entries + } + + // Hostnames may be comma-separated (e.g. "host1,host2") + const hostnames = hostnameField.split(',') + + for (const hostname of hostnames) { + if (hostname === '') { + continue + } + + // Skip hashed entries (start with |) + if (hostname.startsWith('|')) { + continue + } + + // Check for [host]:port format (non-standard port) + const bracketMatch = /^\[([^\]]+)\]:(\d+)$/.exec(hostname) + if (bracketMatch === null) { + entries.push({ host: hostname, port: DEFAULT_PORT, algorithm, key }) + } else { + const matchedHost = bracketMatch[1] ?? hostname + const matchedPort = Number.parseInt(bracketMatch[2] ?? String(DEFAULT_PORT), 10) + entries.push({ host: matchedHost, port: matchedPort, algorithm, key }) + } + } + + return entries +} + +function parseKnownHosts(content: string): KnownHostEntry[] { + const entries: KnownHostEntry[] = [] + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim() + + // Skip empty lines and comments + if (line === '' || line.startsWith('#')) { + continue + } + + entries.push(...parseKnownHostLine(line)) + } + + return entries +} + +// --------------------------------------------------------------------------- +// Resolve default DB path +// --------------------------------------------------------------------------- + +/** + * Safely traverse a nested JSON structure to extract the dbPath. + * Uses explicit type narrowing rather than indexed access to satisfy + * the security/detect-object-injection rule. + */ +function extractDbPathFromConfig(config: unknown): string | undefined { + if (typeof config !== 'object' || config === null) { + return undefined + } + + const ssh: unknown = (config as Record)['ssh'] + if (typeof ssh !== 'object' || ssh === null) { + return undefined + } + + const hkv: unknown = (ssh as Record)['hostKeyVerification'] + if (typeof hkv !== 'object' || hkv === null) { + return undefined + } + + const store: unknown = (hkv as Record)['serverStore'] + if (typeof store !== 'object' || store === null) { + return undefined + } + + const dbPath: unknown = (store as Record)['dbPath'] + if (typeof dbPath === 'string' && dbPath !== '') { + return dbPath + } + + return undefined +} + +function resolveDbPath(explicitPath: string | undefined): string { + if (explicitPath !== undefined) { + return explicitPath + } + + // Try reading from config.json + const configPath = path.resolve(process.cwd(), 'config.json') + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, 'utf8') + const config: unknown = JSON.parse(raw) + const extracted = extractDbPathFromConfig(config) + if (extracted !== undefined) { + return extracted + } + } catch { + // Ignore parse errors; fall through to default + } + } + + return '/data/hostkeys.db' +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +interface CliArgs { + command: 'host' | 'hosts' | 'known-hosts' | 'list' | 'remove' | 'help' + host?: string | undefined + port?: number | undefined + file?: string | undefined + removeTarget?: string | undefined + dbPath?: string | undefined +} + +function nextArg(args: readonly string[], index: number): string | undefined { + const next = index + 1 + return next < args.length ? args.at(next) : undefined +} + +function parseArgs(argv: readonly string[]): CliArgs { + const args = argv.slice(2) // skip node and script path + let command: CliArgs['command'] = 'help' + let host: string | undefined + let port: number | undefined + let file: string | undefined + let removeTarget: string | undefined + let dbPath: string | undefined + + for (let i = 0; i < args.length; i++) { + const arg = args.at(i) + + if (arg === '--help' || arg === '-h') { + command = 'help' + } else if (arg === '--host') { + command = 'host' + host = nextArg(args, i) + i++ + } else if (arg === '--port') { + const portStr = nextArg(args, i) + i++ + if (portStr !== undefined) { + port = Number.parseInt(portStr, 10) + } + } else if (arg === '--hosts') { + command = 'hosts' + file = nextArg(args, i) + i++ + } else if (arg === '--known-hosts') { + command = 'known-hosts' + file = nextArg(args, i) + i++ + } else if (arg === '--list') { + command = 'list' + } else if (arg === '--remove') { + command = 'remove' + removeTarget = nextArg(args, i) + i++ + } else if (arg === '--db') { + dbPath = nextArg(args, i) + i++ + } + } + + return { command, host, port, file, removeTarget, dbPath } +} + +// --------------------------------------------------------------------------- +// Command handlers +// --------------------------------------------------------------------------- + +async function handleProbeHost( + db: DatabaseType, + host: string, + port: number +): Promise { + process.stdout.write(`Probing ${host}:${port}...\n`) + try { + const result = await probeHostKey(host, port) + upsertKey(db, host, port, result.algorithm, result.key) + const fingerprint = computeFingerprint(result.key) + process.stdout.write(`Added ${result.algorithm} key for ${host}:${port}\n`) + process.stdout.write(`Fingerprint: ${fingerprint}\n`) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`Error probing ${host}:${port}: ${message}\n`) + } +} + +async function handleProbeHosts( + db: DatabaseType, + filePath: string +): Promise { + if (!fs.existsSync(filePath)) { + process.stderr.write(`File not found: ${filePath}\n`) + return + } + + const content = fs.readFileSync(filePath, 'utf8') + const lines = content.split('\n').filter((line) => { + const trimmed = line.trim() + return trimmed !== '' && !trimmed.startsWith('#') + }) + + for (const line of lines) { + const trimmed = line.trim() + const colonIndex = trimmed.lastIndexOf(':') + + let host: string + let port: number + + if (colonIndex > 0) { + host = trimmed.slice(0, colonIndex) + port = Number.parseInt(trimmed.slice(colonIndex + 1), 10) + if (Number.isNaN(port)) { + host = trimmed + port = DEFAULT_PORT + } + } else { + host = trimmed + port = DEFAULT_PORT + } + + await handleProbeHost(db, host, port) + } +} + +function handleKnownHosts( + db: DatabaseType, + filePath: string +): void { + if (!fs.existsSync(filePath)) { + process.stderr.write(`File not found: ${filePath}\n`) + return + } + + const content = fs.readFileSync(filePath, 'utf8') + const entries = parseKnownHosts(content) + + if (entries.length === 0) { + process.stdout.write('No valid entries found in known_hosts file.\n') + return + } + + let imported = 0 + for (const entry of entries) { + upsertKey(db, entry.host, entry.port, entry.algorithm, entry.key) + imported++ + } + + process.stdout.write(`Imported ${String(imported)} key(s) from ${filePath}\n`) +} + +function formatListRow( + hostVal: string, + portVal: string, + algVal: string, + fpVal: string, + dateVal: string +): string { + const hostWidth = 24 + const portWidth = 6 + const algWidth = 24 + const fpWidth = 38 + const dateWidth = 20 + return `${hostVal.padEnd(hostWidth)}${portVal.padEnd(portWidth)}${algVal.padEnd(algWidth)}${fpVal.padEnd(fpWidth)}${dateVal.padEnd(dateWidth)}\n` +} + +function handleList(db: DatabaseType): void { + const rows = db.prepare( + 'SELECT host, port, algorithm, key, added_at FROM host_keys ORDER BY host, port, algorithm' + ).all() as Array<{ + host: string + port: number + algorithm: string + key: string + added_at: string + }> + + if (rows.length === 0) { + process.stdout.write('No host keys stored.\n') + return + } + + process.stdout.write(formatListRow('Host', 'Port', 'Algorithm', 'Fingerprint', 'Added')) + + for (const row of rows) { + const fingerprint = computeFingerprint(row.key) + const truncatedFp = fingerprint.length > 36 + ? `${fingerprint.slice(0, 36)}...` + : fingerprint + process.stdout.write(formatListRow( + row.host, + String(row.port), + row.algorithm, + truncatedFp, + row.added_at + )) + } +} + +function handleRemove(db: DatabaseType, target: string): void { + const colonIndex = target.lastIndexOf(':') + if (colonIndex <= 0) { + process.stderr.write('Invalid format. Use: --remove host:port\n') + return + } + + const host = target.slice(0, colonIndex) + const port = Number.parseInt(target.slice(colonIndex + 1), 10) + + if (Number.isNaN(port)) { + process.stderr.write('Invalid port number.\n') + return + } + + const result = db.prepare('DELETE FROM host_keys WHERE host = ? AND port = ?').run(host, port) + process.stdout.write(`Removed ${String(result.changes)} key(s) for ${host}:${port}\n`) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const cli = parseArgs(process.argv) + + if (cli.command === 'help') { + process.stdout.write(`${USAGE}\n`) + return 0 + } + + const dbPath = resolveDbPath(cli.dbPath) + const db = openDb(dbPath) + + try { + switch (cli.command) { + case 'host': { + if (cli.host === undefined) { + process.stderr.write('Error: --host requires a hostname\n') + return 1 + } + await handleProbeHost(db, cli.host, cli.port ?? DEFAULT_PORT) + break + } + case 'hosts': { + if (cli.file === undefined) { + process.stderr.write('Error: --hosts requires a file path\n') + return 1 + } + await handleProbeHosts(db, cli.file) + break + } + case 'known-hosts': { + if (cli.file === undefined) { + process.stderr.write('Error: --known-hosts requires a file path\n') + return 1 + } + handleKnownHosts(db, cli.file) + break + } + case 'list': { + handleList(db) + break + } + case 'remove': { + if (cli.removeTarget === undefined) { + process.stderr.write('Error: --remove requires a host:port argument\n') + return 1 + } + handleRemove(db, cli.removeTarget) + break + } + default: { + const exhaustiveCheck: never = cli.command + process.stderr.write(`Unknown command: ${exhaustiveCheck as string}\n`) + return 1 + } + } + } finally { + db.close() + } + + return 0 +} + +const exitCode = await main() +process.exitCode = exitCode diff --git a/tests/integration/host-key-verification.vitest.ts b/tests/integration/host-key-verification.vitest.ts index 06e43391..6ed4cdaa 100644 --- a/tests/integration/host-key-verification.vitest.ts +++ b/tests/integration/host-key-verification.vitest.ts @@ -2,11 +2,12 @@ // Integration tests for the full host key verification flow: // temp SQLite DB -> HostKeyService -> createHostKeyVerifier -> mock socket -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import Database from 'better-sqlite3' import fs from 'node:fs' import path from 'node:path' import os from 'node:os' +import type { Socket } from 'socket.io' import { HostKeyService } from '../../app/services/host-key/host-key-service.js' import { createHostKeyVerifier, @@ -39,9 +40,9 @@ const TEST_HOST = 'server1.example.com' const TEST_PORT = 22 interface MockSocket { - emit: ReturnType - once: ReturnType - removeListener: ReturnType + emit: ReturnType + once: ReturnType + removeListener: ReturnType } function createMockSocket(): MockSocket { @@ -72,7 +73,7 @@ function callVerifier( } /** - * Create a temp directory with a seeded SQLite host_keys DB. + * Seed a SQLite host_keys DB at the given path. */ function seedDb(dbPath: string, rows: Array<{ host: string; port: number; algorithm: string; key: string }>): void { const db = new Database(dbPath) @@ -90,9 +91,9 @@ function seedDb(dbPath: string, rows: Array<{ host: string; port: number; algori } function buildConfig(overrides: Partial & { - serverStoreEnabled?: boolean - clientStoreEnabled?: boolean - dbPath?: string + serverStoreEnabled?: boolean | undefined + clientStoreEnabled?: boolean | undefined + dbPath?: string | undefined }): HostKeyVerificationConfig { return { enabled: overrides.enabled ?? true, @@ -128,7 +129,6 @@ describe('Host key verification integration', () => { }) it('trusts a key that matches the server store and emits hostkey:verified with source "server"', async () => { - // Determine algorithm from the test key so we seed the DB with the right value const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') const algorithm = extractAlgorithm(keyBuffer) @@ -141,7 +141,7 @@ describe('Host key verification integration', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -168,7 +168,6 @@ describe('Host key verification integration', () => { const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') const algorithm = extractAlgorithm(keyBuffer) - // Seed DB with a DIFFERENT key for the same host/port/algorithm seedDb(dbPath, [ { host: TEST_HOST, port: TEST_PORT, algorithm, key: DIFFERENT_KEY }, ]) @@ -178,7 +177,7 @@ describe('Host key verification integration', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -207,7 +206,6 @@ describe('Host key verification integration', () => { const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') const algorithm = extractAlgorithm(keyBuffer) - // Seed an empty DB (no keys at all) seedDb(dbPath, []) const config = buildConfig({ @@ -220,7 +218,7 @@ describe('Host key verification integration', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -259,7 +257,7 @@ describe('Host key verification integration', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -285,7 +283,6 @@ describe('Host key verification integration', () => { it('returns true with no socket events when the feature is disabled', async () => { const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') - // DB does not need to exist when the feature is disabled const config = buildConfig({ enabled: false, serverStoreEnabled: false, @@ -295,7 +292,7 @@ describe('Host key verification integration', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, From 8ac6677af9ee28608dc6f14ae857c44a9159a859 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:57:54 -0800 Subject: [PATCH 16/31] docs: add host key verification configuration and usage guide --- README.md | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/README.md b/README.md index 6c484b81..7b88e8ad 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Need the Docker Hub mirror instead? Use `docker.io/billchurch/webssh2:latest`. - [Private Key Authentication](./DOCS/features/PRIVATE-KEYS.md) - SSH key setup and usage - [Exec Channel](./DOCS/features/EXEC-CHANNEL.md) - Non-interactive command execution - [Environment Forwarding](./DOCS/features/ENVIRONMENT-FORWARDING.md) - Pass environment variables +- [Host Key Verification](#host-key-verification) - MITM protection and key management ### Development @@ -158,6 +159,185 @@ Need the Docker Hub mirror instead? Use `docker.io/billchurch/webssh2:latest`. - 🛡️ **Subnet Restrictions** - IPv4/IPv6 CIDR subnet validation for access control - 📁 **SFTP Support** - File transfer capabilities (v2.6.0+) +## Host Key Verification + +Host key verification protects SSH connections against man-in-the-middle (MITM) attacks by validating the public key presented by the remote SSH server. When enabled, WebSSH2 compares the server's host key against a known-good key before allowing the connection to proceed. This is the same trust-on-first-use (TOFU) model used by OpenSSH. + +The feature is **disabled by default** and must be explicitly enabled in configuration. + +### Configuration + +Add the `hostKeyVerification` block under `ssh` in `config.json`: + +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "hybrid", + "unknownKeyAction": "prompt", + "serverStore": { + "enabled": true, + "dbPath": "/data/hostkeys.db" + }, + "clientStore": { + "enabled": true + } + } + } +} +``` + +### Modes of Operation + +The `mode` setting is a shorthand that controls which key stores are active. Explicit `serverStore.enabled` and `clientStore.enabled` flags override the mode defaults when set. + +| Mode | Server Store | Client Store | Description | +|------|:---:|:---:|---| +| `server` | on | off | Keys are verified exclusively against the server-side SQLite database. The client is never prompted. Best for locked-down environments where an administrator pre-seeds all host keys. | +| `client` | off | on | The server delegates verification to the browser client. The client stores accepted keys locally (e.g. in IndexedDB). Useful when no server-side database is available. | +| `hybrid` | on | on | The server store is checked first. If the key is unknown there, the client is asked. Provides server-enforced trust with client-side fallback for new hosts. **(default)** | + +### Unknown Key Actions + +When a host key is not found in any enabled store, the `unknownKeyAction` setting determines what happens: + +| Action | Behavior | +|--------|----------| +| `prompt` | Emit a `hostkey:verify` event to the client and wait for the user to accept or reject the key. Connection is blocked until the user responds or the 30-second timeout expires. **(default)** | +| `alert` | Emit a `hostkey:alert` event to the client as a notification, but allow the connection to proceed. The key is not stored; the alert will appear again on the next connection. | +| `reject` | Emit a `hostkey:rejected` event and refuse the connection immediately. Only pre-seeded keys in the server store will be accepted. | + +### Environment Variables + +All host key settings can be configured via environment variables. Environment variables override `config.json` values. + +| Variable | Config Path | Type | Default | Description | +|----------|-------------|------|---------|-------------| +| `WEBSSH2_SSH_HOSTKEY_ENABLED` | `ssh.hostKeyVerification.enabled` | boolean | `false` | Enable or disable host key verification | +| `WEBSSH2_SSH_HOSTKEY_MODE` | `ssh.hostKeyVerification.mode` | string | `hybrid` | Verification mode: `server`, `client`, or `hybrid` | +| `WEBSSH2_SSH_HOSTKEY_UNKNOWN_ACTION` | `ssh.hostKeyVerification.unknownKeyAction` | string | `prompt` | Action for unknown keys: `prompt`, `alert`, or `reject` | +| `WEBSSH2_SSH_HOSTKEY_DB_PATH` | `ssh.hostKeyVerification.serverStore.dbPath` | string | `/data/hostkeys.db` | Path to the SQLite host key database | +| `WEBSSH2_SSH_HOSTKEY_SERVER_ENABLED` | `ssh.hostKeyVerification.serverStore.enabled` | boolean | `true` | Enable the server-side SQLite store | +| `WEBSSH2_SSH_HOSTKEY_CLIENT_ENABLED` | `ssh.hostKeyVerification.clientStore.enabled` | boolean | `true` | Enable the client-side (browser) store | + +### SQLite Server Store Setup + +The server store uses a SQLite database that is opened in **read-only** mode at runtime. You must create and populate the database ahead of time using the seeding script (see below). + +**Creating the database:** + +```bash +# Probe a host to create and populate the database +npm run hostkeys -- --host ssh.example.com +``` + +The script automatically creates the database file (and parent directories) at the configured `dbPath` if it does not exist. + +**Docker volume mounting:** + +When running in Docker, mount a volume to the directory containing your database so it persists across container restarts. The mount path must match the `dbPath` value in your configuration: + +```bash +docker run --rm -p 2222:2222 \ + -v /path/to/local/hostkeys:/data \ + -e WEBSSH2_SSH_HOSTKEY_ENABLED=true \ + -e WEBSSH2_SSH_HOSTKEY_DB_PATH=/data/hostkeys.db \ + ghcr.io/billchurch/webssh2:latest +``` + +### Seeding Script Usage + +The `npm run hostkeys` command manages the SQLite host key database. It probes remote hosts via SSH to capture their public keys and stores them for later verification. + +```bash +npm run hostkeys -- --help +``` + +**Probe a single host** (default port 22): + +```bash +npm run hostkeys -- --host ssh.example.com +``` + +**Probe a host on a non-standard port:** + +```bash +npm run hostkeys -- --host ssh.example.com --port 2222 +``` + +**Bulk import from a hosts file** (one `host[:port]` per line, `#` comments allowed): + +```bash +npm run hostkeys -- --hosts servers.txt +``` + +**Import from an OpenSSH `known_hosts` file:** + +```bash +npm run hostkeys -- --known-hosts ~/.ssh/known_hosts +``` + +**List all stored keys:** + +```bash +npm run hostkeys -- --list +``` + +**Remove all keys for a host:port pair:** + +```bash +npm run hostkeys -- --remove ssh.example.com:22 +``` + +**Use a custom database path:** + +```bash +npm run hostkeys -- --list --db /custom/path/hostkeys.db +``` + +If `--db` is not specified, the script reads `dbPath` from `config.json`, falling back to `/data/hostkeys.db`. + +### Socket Protocol Reference + +The following Socket.IO events are used for host key verification. This reference is intended for CLI clients and third-party implementors integrating with the WebSSH2 WebSocket protocol. + +**Server to Client:** + +| Event | Payload | Description | +|-------|---------|-------------| +| `hostkey:verify` | `{ host, port, algorithm, fingerprint, key }` | Server is requesting the client to verify an unknown host key. The client must respond with `hostkey:verify-response`. `key` is the base64-encoded public key; `fingerprint` is the `SHA256:...` hash. | +| `hostkey:verified` | `{ host, port, algorithm, fingerprint, source }` | The host key was successfully verified. `source` is `"server"` or `"client"` indicating which store matched. Informational only; no response required. | +| `hostkey:mismatch` | `{ host, port, algorithm, presentedFingerprint, storedFingerprint, source }` | The presented key does not match the stored key. The connection is refused. `source` indicates which store detected the mismatch. | +| `hostkey:alert` | `{ host, port, algorithm, fingerprint }` | An unknown key was encountered and `unknownKeyAction` is set to `alert`. The connection proceeds. Informational only. | +| `hostkey:rejected` | `{ host, port, algorithm, fingerprint }` | An unknown key was encountered and `unknownKeyAction` is set to `reject`. The connection is refused. | + +**Client to Server:** + +| Event | Payload | Description | +|-------|---------|-------------| +| `hostkey:verify-response` | `{ action }` | Client response to a `hostkey:verify` prompt. `action` must be `"accept"`, `"reject"`, or `"trusted"` (key was already known to the client). If no response is received within 30 seconds, the connection is refused. | + +### Troubleshooting + +**Feature appears to have no effect:** +Host key verification is disabled by default (`enabled: false`). Set `WEBSSH2_SSH_HOSTKEY_ENABLED=true` or `"enabled": true` in `config.json` to activate it. + +**Database not found at runtime:** +The server store opens the database in read-only mode. If the file at `dbPath` does not exist, all lookups return `"unknown"` and the store operates in degraded mode. Run `npm run hostkeys` to create and seed the database before starting the server. + +**Host key mismatch:** +A `hostkey:mismatch` event means the SSH server is presenting a different key than what is stored in the database. This can happen after a legitimate server reinstall or key rotation. To resolve: + +1. Verify the new key is legitimate (contact the server administrator). +2. Remove the old key: `npm run hostkeys -- --remove host:port` +3. Re-probe the host: `npm run hostkeys -- --host --port ` + +If you receive frequent mismatches for hosts you did not change, investigate for potential MITM attacks. + +**Client verification times out:** +When using `prompt` mode, the client has 30 seconds to respond to a `hostkey:verify` event. If the client does not respond in time, the connection is refused. Ensure the client application handles the `hostkey:verify` Socket.IO event. + ## Release Workflow Overview - **Development**: Run `npm install` (or `npm ci`) and continue using scripts such as `npm run dev` and `npm run build`. The TypeScript sources remain the source of truth. From ce2be9e785e2c0135fd34058cfa89899be284422 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 12:59:26 -0800 Subject: [PATCH 17/31] docs: add host key verification socket protocol reference --- DOCS/host-key-protocol.md | 519 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 DOCS/host-key-protocol.md diff --git a/DOCS/host-key-protocol.md b/DOCS/host-key-protocol.md new file mode 100644 index 00000000..8d53f15e --- /dev/null +++ b/DOCS/host-key-protocol.md @@ -0,0 +1,519 @@ +# Host Key Verification Socket Protocol Reference + +## Overview + +This document defines the Socket.IO event protocol used by WebSSH2 for SSH host key verification. It is intended for CLI client implementors, third-party client developers, and anyone building a custom frontend that connects to the WebSSH2 server over Socket.IO. + +The host key verification subsystem allows the server and/or client to verify the identity of an SSH host before completing a connection. It supports three operational modes -- server-only, client-only, and hybrid -- each with distinct event flows described below. + +**Source of truth**: `app/services/host-key/host-key-verifier.ts` + +## Events Reference + +All events use the `hostkey:` namespace prefix. Event names correspond to the constants defined in `app/constants/socket-events.ts`. + +### Server to Client Events + +| Event | Constant | Payload | Description | +|-------|----------|---------|-------------| +| `hostkey:verify` | `HOSTKEY_VERIFY` | `HostKeyVerifyPayload` | Server requests the client to verify an unknown host key | +| `hostkey:verified` | `HOSTKEY_VERIFIED` | `HostKeyVerifiedPayload` | Key was verified successfully; connection proceeds | +| `hostkey:mismatch` | `HOSTKEY_MISMATCH` | `HostKeyMismatchPayload` | Presented key does not match stored key; connection refused | +| `hostkey:alert` | `HOSTKEY_ALERT` | `HostKeyAlertPayload` | Unknown key encountered; warning only, connection proceeds | +| `hostkey:rejected` | `HOSTKEY_REJECTED` | `HostKeyRejectedPayload` | Unknown key rejected by policy; connection refused | + +### Client to Server Events + +| Event | Constant | Payload | Description | +|-------|----------|---------|-------------| +| `hostkey:verify-response` | `HOSTKEY_VERIFY_RESPONSE` | `HostKeyVerifyResponse` | Client's verification decision in response to `hostkey:verify` | + +## Payload Schemas + +All payloads are JSON objects transmitted as the first argument to `socket.emit()` / received as the first argument to the event handler. + +### HostKeyVerifyPayload + +Sent by the server with `hostkey:verify` when the client must decide whether to trust an unknown host key. + +```typescript +interface HostKeyVerifyPayload { + /** Hostname or IP of the SSH server */ + host: string + /** Port number of the SSH server */ + port: number + /** SSH key algorithm (e.g., "ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256") */ + algorithm: string + /** SHA-256 fingerprint in "SHA256:" format */ + fingerprint: string + /** Full public key, base64-encoded */ + key: string +} +``` + +### HostKeyVerifiedPayload + +Sent by the server with `hostkey:verified` after a key has been accepted (by either the server store or the client). + +```typescript +interface HostKeyVerifiedPayload { + host: string + port: number + algorithm: string + /** SHA-256 fingerprint of the verified key */ + fingerprint: string + /** Which store confirmed the key */ + source: 'server' | 'client' +} +``` + +### HostKeyMismatchPayload + +Sent by the server with `hostkey:mismatch` when the presented key does not match the key on file. The connection is always refused. + +```typescript +interface HostKeyMismatchPayload { + host: string + port: number + algorithm: string + /** Fingerprint of the key presented by the remote SSH server */ + presentedFingerprint: string + /** Fingerprint of the previously stored key, or "unknown" if unavailable */ + storedFingerprint: string + /** Which store detected the mismatch */ + source: 'server' | 'client' +} +``` + +### HostKeyAlertPayload + +Sent by the server with `hostkey:alert` when the key is unknown and the server is configured with `unknownKeyAction: 'alert'`. This is informational only -- the connection proceeds. + +```typescript +interface HostKeyAlertPayload { + host: string + port: number + algorithm: string + /** SHA-256 fingerprint of the unknown key */ + fingerprint: string +} +``` + +### HostKeyRejectedPayload + +Sent by the server with `hostkey:rejected` when the key is unknown and the server is configured with `unknownKeyAction: 'reject'`. The connection is refused. + +```typescript +interface HostKeyRejectedPayload { + host: string + port: number + algorithm: string + /** SHA-256 fingerprint of the rejected key */ + fingerprint: string +} +``` + +### HostKeyVerifyResponse + +Sent by the client with `hostkey:verify-response` in reply to a `hostkey:verify` prompt. + +```typescript +interface HostKeyVerifyResponse { + /** Client's verification decision */ + action: 'trusted' | 'accept' | 'reject' +} +``` + +**Action values:** + +| Value | Meaning | +|-------|---------| +| `trusted` | The key was already in the client's local key store | +| `accept` | The key was unknown but the user chose to trust it | +| `reject` | The user declined to trust the key | + +## Sequence Diagrams + +### Server-Only Mode + +Server store is enabled; client store is disabled. + +#### Key Found (Trusted) + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | lookup(host,port,algo) | + | | => trusted | + | | | + | |-- hostkey:verified ----->| + | | { source: 'server' } | + |<-- verify(true) ---| | + | | | + |=== SSH session continues ====================| +``` + +#### Key Mismatch + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | lookup(host,port,algo) | + | | => mismatch | + | | | + | |-- hostkey:mismatch ---->| + | | { presentedFP, | + | | storedFP, | + | | source: 'server' } | + |<-- verify(false) --| | + | | | + |=== connection refused =======================| +``` + +#### Key Unknown (unknownKeyAction: 'alert') + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | lookup(host,port,algo) | + | | => unknown | + | | | + | |-- hostkey:alert ------->| + | | { fingerprint } | + |<-- verify(true) ---| | + | | | + |=== SSH session continues ====================| +``` + +#### Key Unknown (unknownKeyAction: 'reject') + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | lookup(host,port,algo) | + | | => unknown | + | | | + | |-- hostkey:rejected ---->| + | | { fingerprint } | + |<-- verify(false) --| | + | | | + |=== connection refused =======================| +``` + +#### Key Unknown (unknownKeyAction: 'prompt') + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | lookup(host,port,algo) | + | | => unknown | + | | | + | |-- hostkey:verify ------>| + | | { host, port, algo, | + | | fingerprint, key } | + | | | + | |<- hostkey:verify-resp --| + | | { action } | + | | | + | [if accept/trusted] | + | |-- hostkey:verified ---->| + | | { source: 'client' } | + |<-- verify(true) ---| | + | | | + | [if reject or timeout] | + |<-- verify(false) --| | + | | | +``` + +### Client-Only Mode + +Server store is disabled; client store is enabled. + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | (no server store) | + | | | + | |-- hostkey:verify ------>| + | | { host, port, algo, | + | | fingerprint, key } | + | | | + | | (client checks its | + | | local key store) | + | | | + | |<- hostkey:verify-resp --| + | | { action: 'trusted' } | + | | | + | |-- hostkey:verified ---->| + | | { source: 'client' } | + |<-- verify(true) ---| | + | | | + |=== SSH session continues ====================| +``` + +If the client does not recognize the key, it prompts the user: + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | | + | |-- hostkey:verify ------>| + | | | + | | (user prompt shown) | + | | | + | |<- hostkey:verify-resp --| + | | { action: 'accept' } | + | | | + | |-- hostkey:verified ---->| + | | { source: 'client' } | + |<-- verify(true) ---| | + | | | +``` + +### Hybrid Mode + +Server store is checked first. If the key is unknown on the server, the client is consulted. + +#### Server Found (Trusted) + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | server lookup => trusted| + | | | + | |-- hostkey:verified ---->| + | | { source: 'server' } | + |<-- verify(true) ---| | + | | | + |=== SSH session continues ====================| +``` + +No client interaction is needed when the server store recognizes the key. + +#### Server Mismatch + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | server lookup | + | | => mismatch | + | | | + | |-- hostkey:mismatch ---->| + |<-- verify(false) --| | + | | | + |=== connection refused =======================| +``` + +A server-side mismatch is always fatal. The client is not consulted. + +#### Server Unknown, Falls Through to Client + +``` +SSH Server WebSSH2 Server Client + | | | + |--- host key ------->| | + | | server lookup => unknown| + | | | + | |-- hostkey:verify ------>| + | | { host, port, algo, | + | | fingerprint, key } | + | | | + | |<- hostkey:verify-resp --| + | | { action } | + | | | + | [if accept/trusted] | + | |-- hostkey:verified ---->| + | | { source: 'client' } | + |<-- verify(true) ---| | + | | | + | [if reject or timeout] | + |<-- verify(false) --| | + | | | +``` + +## Verification Flow + +The server executes the following decision tree when an SSH host key is received. This matches the logic in `createHostKeyVerifier()`. + +``` + Host key received + | + Is feature enabled? + / \ + NO YES + | | + verify(true) Server store enabled? + (no events) / \ + NO YES + | | + | Server lookup + | / | \ + | trusted mismatch unknown + | | | | + | emit emit fall + | verified mismatch through + | verify verify | + | (true) (false) | + | | + +--------+---------------+ + | + Client store enabled? + / \ + YES NO + | | + emit verify unknownKeyAction? + await response / | \ + / | \ alert reject prompt + trusted accept reject | | | + | | | emit emit emit verify + emit emit verify alert rejected await response + verified verified (false) verify verify (same as + verify verify (true) (false) client store) + (true) (true) +``` + +**Key rules:** + +1. A server-side mismatch is always fatal. The client is never consulted. +2. The client is only prompted via `hostkey:verify` when the server store does not have the key (or is disabled) and either the client store is enabled or `unknownKeyAction` is `'prompt'`. +3. Both `'accept'` and `'trusted'` responses from the client are treated identically by the server -- both result in `verify(true)` and a `hostkey:verified` event with `source: 'client'`. +4. A `'reject'` response results in `verify(false)` with no additional events emitted to the client. + +## Timeout Behavior + +When the server emits `hostkey:verify` and awaits a client response, a timeout governs how long the server will wait. + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `timeout` | 30000 ms (30 seconds) | Maximum time to wait for `hostkey:verify-response` | + +**When the timeout fires:** + +1. The `hostkey:verify-response` listener is removed from the socket. +2. `verify(false)` is called, refusing the connection. +3. No additional events are emitted to the client. + +The timeout is passed as the `timeout` option to `createHostKeyVerifier()`. It can be configured per-connection. + +**Important**: If the client sends a response after the timeout has already fired, the response is silently ignored because the listener has been removed (via `socket.removeListener`). The SSH connection will have already been refused. + +## Client Implementation Notes + +### Handling Each Event + +**`hostkey:verify`** -- The client must respond with `hostkey:verify-response` within the timeout window (default 30 seconds). This is the only event that requires a client response. The client should: + +1. Check its local key store for the `host:port:algorithm` tuple. +2. If found and matching, respond with `{ action: 'trusted' }`. +3. If found but mismatched, the client should handle this locally (e.g., warn the user) and respond with `{ action: 'reject' }`. +4. If not found, display the fingerprint to the user and prompt for a decision: + - User accepts: respond with `{ action: 'accept' }` and optionally save the key. + - User declines: respond with `{ action: 'reject' }`. + +**`hostkey:verified`** -- Informational. The key has been accepted and the SSH connection will proceed. The client may display a confirmation message, log the event, or silently continue. The `source` field indicates whether the server or client store was authoritative. + +**`hostkey:mismatch`** -- The connection has already been refused by the server. The client should display a prominent warning to the user, including both the `presentedFingerprint` and `storedFingerprint`. This is a potential man-in-the-middle indicator. + +**`hostkey:alert`** -- Informational warning. The key is unknown but the server allowed the connection (configured with `unknownKeyAction: 'alert'`). The client may display a notice or log the fingerprint. No response is required. + +**`hostkey:rejected`** -- The connection has been refused because the key is unknown and server policy does not allow unknown keys (`unknownKeyAction: 'reject'`). The client should display an appropriate error. + +### Action Values Summary + +| Action | When to Send | Effect | +|--------|--------------|--------| +| `trusted` | Key is already in the client's local key store and matches | Connection proceeds | +| `accept` | Key was unknown, user chose to trust it | Connection proceeds | +| `reject` | Key was unknown or mismatched, user declined | Connection refused | + +### State Reset + +All host key verification state is scoped to a single SSH connection attempt. On socket disconnect, the server cleans up any pending timeouts and listeners. The client should reset any in-progress verification UI on disconnect. + +### Fingerprint Format + +Fingerprints are SHA-256 hashes in `SHA256:` format, matching the convention used by OpenSSH. For example: + +``` +SHA256:jMn3j6dsf7...base64... +``` + +This is computed from the raw public key bytes (decoded from base64), hashed with SHA-256, then re-encoded as base64. + +## CLI Implementation Notes (Future Reference) + +These are recommendations for building a command-line client that participates in the host key verification protocol. + +### Suggested Key Store + +Use `~/.ssh/known_hosts` in native OpenSSH format. This provides: + +- Compatibility with existing SSH tooling (`ssh`, `ssh-keygen`, `scp`) +- No additional file management for users who already use SSH +- Established file format with broad library support + +If OpenSSH format is impractical, a JSON-based store keyed by `host:port:algorithm` is acceptable. + +### Interactive vs Batch Mode + +| Mode | Behavior | +|------|----------| +| **Interactive** | Prompt the user on `hostkey:verify`, display fingerprint, wait for yes/no | +| **Batch** | Apply policy without user interaction; fail-closed by default | + +In interactive mode, display output similar to OpenSSH: + +``` +The authenticity of host 'server1.example.com (192.168.1.10)' can't be established. +ssh-ed25519 key fingerprint is SHA256:jMn3j6dsf7... +Are you sure you want to continue connecting (yes/no)? +``` + +### Suggested CLI Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--known-hosts ` | Path to known_hosts file | `~/.ssh/known_hosts` | +| `--accept-unknown` | Automatically accept unknown keys (respond `'accept'`); useful for scripting but insecure | Off | +| `--fingerprint ` | Expect a specific fingerprint; respond `'trusted'` if it matches, `'reject'` otherwise | None | +| `--strict-host-keys` | Reject unknown keys (respond `'reject'`); equivalent to `StrictHostKeyChecking=yes` | Off | + +### Batch Mode Policy + +When no interactive terminal is available, the CLI should: + +1. Check `--fingerprint` if provided. Respond `'trusted'` on match, `'reject'` on mismatch. +2. Check `--known-hosts` store. Respond `'trusted'` if found and matching. +3. If `--accept-unknown` is set, respond `'accept'`. +4. Otherwise, respond `'reject'` (fail-closed). + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Connection succeeded | +| 1 | General error | +| 2 | Host key verification failed (mismatch or rejected) | + +### Mismatch Handling + +On `hostkey:mismatch`, the CLI should print a prominent warning to stderr and exit with code 2, similar to OpenSSH: + +``` +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +Host key for server1.example.com:22 has changed! +Stored: SHA256:abc123... +Presented: SHA256:xyz789... +Host key verification failed. +``` From 6f530169f93ec8491136d5f72499d5d1eb0849e3 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 13:16:08 -0800 Subject: [PATCH 18/31] fix: address code review findings for host key verification - Fix 17 lint errors (duplicate imports, inline import() types, negated condition, naming convention, structuredClone) - Add disconnect cleanup to awaitClientVerification to prevent 30s resource linger on client disconnect - Add input validation for hostkey:verify-response payload (untrusted client data at security boundary) - Replace ASCII diagrams with mermaid sequence diagrams and flowchart in protocol reference --- DOCS/host-key-protocol.md | 338 ++++++++---------- app/config/config-processor.ts | 4 +- app/services/host-key/host-key-service.ts | 3 +- app/services/host-key/host-key-store.ts | 5 +- app/services/host-key/host-key-verifier.ts | 40 ++- app/services/interfaces.ts | 3 +- app/services/ssh/ssh-service.ts | 6 +- tests/unit/config/host-key-config.vitest.ts | 2 +- .../host-key/host-key-verifier.vitest.ts | 19 +- 9 files changed, 202 insertions(+), 218 deletions(-) diff --git a/DOCS/host-key-protocol.md b/DOCS/host-key-protocol.md index 8d53f15e..30568621 100644 --- a/DOCS/host-key-protocol.md +++ b/DOCS/host-key-protocol.md @@ -140,141 +140,111 @@ Server store is enabled; client store is disabled. #### Key Found (Trusted) -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | lookup(host,port,algo) | - | | => trusted | - | | | - | |-- hostkey:verified ----->| - | | { source: 'server' } | - |<-- verify(true) ---| | - | | | - |=== SSH session continues ====================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: lookup(host, port, algo) → trusted + WS->>C: hostkey:verified { source: 'server' } + WS->>SSH: verify(true) + Note over SSH,C: SSH session continues ``` #### Key Mismatch -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | lookup(host,port,algo) | - | | => mismatch | - | | | - | |-- hostkey:mismatch ---->| - | | { presentedFP, | - | | storedFP, | - | | source: 'server' } | - |<-- verify(false) --| | - | | | - |=== connection refused =======================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: lookup(host, port, algo) → mismatch + WS->>C: hostkey:mismatch { presentedFP, storedFP, source: 'server' } + WS->>SSH: verify(false) + Note over SSH,C: Connection refused ``` #### Key Unknown (unknownKeyAction: 'alert') -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | lookup(host,port,algo) | - | | => unknown | - | | | - | |-- hostkey:alert ------->| - | | { fingerprint } | - |<-- verify(true) ---| | - | | | - |=== SSH session continues ====================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: lookup(host, port, algo) → unknown + WS->>C: hostkey:alert { fingerprint } + WS->>SSH: verify(true) + Note over SSH,C: SSH session continues (with warning) ``` #### Key Unknown (unknownKeyAction: 'reject') -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | lookup(host,port,algo) | - | | => unknown | - | | | - | |-- hostkey:rejected ---->| - | | { fingerprint } | - |<-- verify(false) --| | - | | | - |=== connection refused =======================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: lookup(host, port, algo) → unknown + WS->>C: hostkey:rejected { fingerprint } + WS->>SSH: verify(false) + Note over SSH,C: Connection refused ``` #### Key Unknown (unknownKeyAction: 'prompt') -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | lookup(host,port,algo) | - | | => unknown | - | | | - | |-- hostkey:verify ------>| - | | { host, port, algo, | - | | fingerprint, key } | - | | | - | |<- hostkey:verify-resp --| - | | { action } | - | | | - | [if accept/trusted] | - | |-- hostkey:verified ---->| - | | { source: 'client' } | - |<-- verify(true) ---| | - | | | - | [if reject or timeout] | - |<-- verify(false) --| | - | | | +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: lookup(host, port, algo) → unknown + WS->>C: hostkey:verify { host, port, algo, fingerprint, key } + C->>WS: hostkey:verify-response { action } + + alt action = accept or trusted + WS->>C: hostkey:verified { source: 'client' } + WS->>SSH: verify(true) + else action = reject or timeout + WS->>SSH: verify(false) + end ``` ### Client-Only Mode Server store is disabled; client store is enabled. -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | (no server store) | - | | | - | |-- hostkey:verify ------>| - | | { host, port, algo, | - | | fingerprint, key } | - | | | - | | (client checks its | - | | local key store) | - | | | - | |<- hostkey:verify-resp --| - | | { action: 'trusted' } | - | | | - | |-- hostkey:verified ---->| - | | { source: 'client' } | - |<-- verify(true) ---| | - | | | - |=== SSH session continues ====================| -``` - -If the client does not recognize the key, it prompts the user: - -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | | - | |-- hostkey:verify ------>| - | | | - | | (user prompt shown) | - | | | - | |<- hostkey:verify-resp --| - | | { action: 'accept' } | - | | | - | |-- hostkey:verified ---->| - | | { source: 'client' } | - |<-- verify(true) ---| | - | | | +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + Note over WS: No server store + WS->>C: hostkey:verify { host, port, algo, fingerprint, key } + C->>C: Check local key store + + alt Key found in client store + C->>WS: hostkey:verify-response { action: 'trusted' } + WS->>C: hostkey:verified { source: 'client' } + WS->>SSH: verify(true) + Note over SSH,C: SSH session continues + else Key unknown — user prompted + C->>WS: hostkey:verify-response { action: 'accept' } + WS->>C: hostkey:verified { source: 'client' } + WS->>SSH: verify(true) + Note over SSH,C: SSH session continues + end ``` ### Hybrid Mode @@ -283,102 +253,88 @@ Server store is checked first. If the key is unknown on the server, the client i #### Server Found (Trusted) -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | server lookup => trusted| - | | | - | |-- hostkey:verified ---->| - | | { source: 'server' } | - |<-- verify(true) ---| | - | | | - |=== SSH session continues ====================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: server lookup → trusted + WS->>C: hostkey:verified { source: 'server' } + WS->>SSH: verify(true) + Note over SSH,C: SSH session continues ``` No client interaction is needed when the server store recognizes the key. #### Server Mismatch -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | server lookup | - | | => mismatch | - | | | - | |-- hostkey:mismatch ---->| - |<-- verify(false) --| | - | | | - |=== connection refused =======================| +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: server lookup → mismatch + WS->>C: hostkey:mismatch { presentedFP, storedFP } + WS->>SSH: verify(false) + Note over SSH,C: Connection refused ``` A server-side mismatch is always fatal. The client is not consulted. #### Server Unknown, Falls Through to Client -``` -SSH Server WebSSH2 Server Client - | | | - |--- host key ------->| | - | | server lookup => unknown| - | | | - | |-- hostkey:verify ------>| - | | { host, port, algo, | - | | fingerprint, key } | - | | | - | |<- hostkey:verify-resp --| - | | { action } | - | | | - | [if accept/trusted] | - | |-- hostkey:verified ---->| - | | { source: 'client' } | - |<-- verify(true) ---| | - | | | - | [if reject or timeout] | - |<-- verify(false) --| | - | | | +```mermaid +sequenceDiagram + participant SSH as SSH Server + participant WS as WebSSH2 Server + participant C as Client + + SSH->>WS: host key + WS->>WS: server lookup → unknown + WS->>C: hostkey:verify { host, port, algo, fingerprint, key } + C->>WS: hostkey:verify-response { action } + + alt action = accept or trusted + WS->>C: hostkey:verified { source: 'client' } + WS->>SSH: verify(true) + else action = reject or timeout + WS->>SSH: verify(false) + end ``` ## Verification Flow The server executes the following decision tree when an SSH host key is received. This matches the logic in `createHostKeyVerifier()`. -``` - Host key received - | - Is feature enabled? - / \ - NO YES - | | - verify(true) Server store enabled? - (no events) / \ - NO YES - | | - | Server lookup - | / | \ - | trusted mismatch unknown - | | | | - | emit emit fall - | verified mismatch through - | verify verify | - | (true) (false) | - | | - +--------+---------------+ - | - Client store enabled? - / \ - YES NO - | | - emit verify unknownKeyAction? - await response / | \ - / | \ alert reject prompt - trusted accept reject | | | - | | | emit emit emit verify - emit emit verify alert rejected await response - verified verified (false) verify verify (same as - verify verify (true) (false) client store) - (true) (true) +```mermaid +flowchart TD + A[Host key received] --> B{Feature enabled?} + B -- No --> C["verify(true)
No events emitted"] + B -- Yes --> D{Server store enabled?} + + D -- Yes --> E[Server lookup] + E --> F{Result} + F -- trusted --> G["Emit hostkey:verified
verify(true)"] + F -- mismatch --> H["Emit hostkey:mismatch
verify(false)"] + F -- unknown --> I{Client store enabled?} + + D -- No --> I + + I -- Yes --> J["Emit hostkey:verify
Await client response"] + J --> K{Client response} + K -- trusted --> L["Emit hostkey:verified
verify(true)"] + K -- accept --> L + K -- reject --> M["verify(false)"] + K -- timeout --> M + + I -- No --> N{unknownKeyAction} + N -- alert --> O["Emit hostkey:alert
verify(true)"] + N -- reject --> P["Emit hostkey:rejected
verify(false)"] + N -- prompt --> J ``` **Key rules:** diff --git a/app/config/config-processor.ts b/app/config/config-processor.ts index 06892f9b..8ceeeb58 100644 --- a/app/config/config-processor.ts +++ b/app/config/config-processor.ts @@ -164,8 +164,8 @@ export function resolveHostKeyMode( break default: { // Exhaustive check - const _exhaustive: never = config.mode - throw new Error(`Unknown host key verification mode: ${String(_exhaustive)}`) + const exhaustiveCheck: never = config.mode + throw new Error(`Unknown host key verification mode: ${String(exhaustiveCheck)}`) } } diff --git a/app/services/host-key/host-key-service.ts b/app/services/host-key/host-key-service.ts index 0e20358c..6acb9d4f 100644 --- a/app/services/host-key/host-key-service.ts +++ b/app/services/host-key/host-key-service.ts @@ -3,8 +3,7 @@ import crypto from 'node:crypto' import type { HostKeyVerificationConfig } from '../../types/config.js' -import { HostKeyStore } from './host-key-store.js' -import type { HostKeyLookupResult } from './host-key-store.js' +import { HostKeyStore, type HostKeyLookupResult } from './host-key-store.js' /** * Service coordinating host key verification using server-side diff --git a/app/services/host-key/host-key-store.ts b/app/services/host-key/host-key-store.ts index 4376d330..1e96a746 100644 --- a/app/services/host-key/host-key-store.ts +++ b/app/services/host-key/host-key-store.ts @@ -1,8 +1,7 @@ // app/services/host-key/host-key-store.ts // SQLite-backed read-only host key store -import Database from 'better-sqlite3' -import type BetterSqlite3 from 'better-sqlite3' +import Database, { type Database as BetterSqlite3Database } from 'better-sqlite3' import fs from 'node:fs' /** @@ -32,7 +31,7 @@ export interface StoredHostKey { * the store operates in a degraded mode where all lookups return "unknown". */ export class HostKeyStore { - private db: BetterSqlite3.Database | null = null + private db: BetterSqlite3Database | null = null constructor(dbPath: string) { if (fs.existsSync(dbPath)) { diff --git a/app/services/host-key/host-key-verifier.ts b/app/services/host-key/host-key-verifier.ts index 5c6565cb..2a496d7a 100644 --- a/app/services/host-key/host-key-verifier.ts +++ b/app/services/host-key/host-key-verifier.ts @@ -161,9 +161,9 @@ export function createHostKeyVerifier( if (lookupResult.status === 'mismatch') { log('Host key MISMATCH detected by server store') - const storedFingerprint = lookupResult.storedKey !== undefined - ? HostKeyService.computeFingerprint(lookupResult.storedKey) - : 'unknown' + const storedFingerprint = lookupResult.storedKey === undefined + ? 'unknown' + : HostKeyService.computeFingerprint(lookupResult.storedKey) const payload: HostKeyMismatchPayload = { host, port, @@ -249,10 +249,30 @@ function awaitClientVerification( key: base64Key, } - const handler = (response: HostKeyVerifyResponse): void => { + const cleanup = (): void => { clearTimeout(timer) + socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.removeListener('disconnect', onDisconnect) + } + + const handler = (response: unknown): void => { + cleanup() - if (response.action === 'accept' || response.action === 'trusted') { + // Validate untrusted client payload + if ( + typeof response !== 'object' || + response === null || + !('action' in response) || + typeof (response as HostKeyVerifyResponse).action !== 'string' + ) { + log('Invalid host key verify response, treating as reject') + verify(false) + return + } + + const action = (response as HostKeyVerifyResponse).action + + if (action === 'accept' || action === 'trusted') { log('Client accepted host key') const verifiedPayload: HostKeyVerifiedPayload = { host, @@ -266,17 +286,25 @@ function awaitClientVerification( return } - // action === 'reject' + // action === 'reject' or unrecognized log('Client rejected host key') verify(false) } + const onDisconnect = (): void => { + log('Client disconnected during host key verification') + cleanup() + verify(false) + } + const timer = setTimeout(() => { log('Host key verification timed out') socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.removeListener('disconnect', onDisconnect) verify(false) }, timeout) socket.once(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.once('disconnect', onDisconnect) socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFY, verifyPayload) } diff --git a/app/services/interfaces.ts b/app/services/interfaces.ts index e0f89f78..3f527e17 100644 --- a/app/services/interfaces.ts +++ b/app/services/interfaces.ts @@ -8,6 +8,7 @@ import type { StructuredLogger, StructuredLoggerOptions } from '../logging/struc // import type { AuthCredentials } from '../types/contracts/v1/socket.js' // Not currently used import type { Config } from '../types/config.js' import type { Client as SSH2Client } from 'ssh2' +import type { Socket as SocketIoSocket } from 'socket.io' import type { Duplex } from 'node:stream' import type { FileService } from './sftp/file-service.js' import type { HostKeyService } from './host-key/host-key-service.js' @@ -101,7 +102,7 @@ export interface SSHConfig { * When provided alongside an enabled HostKeyService, the SSH service * will set up the hostVerifier callback for the connection. */ - socket?: import('socket.io').Socket + socket?: SocketIoSocket } /** diff --git a/app/services/ssh/ssh-service.ts b/app/services/ssh/ssh-service.ts index fa96b7de..54f44833 100644 --- a/app/services/ssh/ssh-service.ts +++ b/app/services/ssh/ssh-service.ts @@ -17,7 +17,7 @@ import { type SessionId } from '../../types/branded.js' import { ok, err, type Result } from '../../state/types.js' -import { Client as SSH2Client, type ClientChannel, type PseudoTtyOptions } from 'ssh2' +import { Client as SSH2Client, type ClientChannel, type PseudoTtyOptions, type HostVerifier } from 'ssh2' import type { SessionStore } from '../../state/store.js' import debug from 'debug' import type { Duplex } from 'node:stream' @@ -78,7 +78,7 @@ export class SSHServiceImpl implements SSHService { */ private buildConnectConfig( config: SSHConfig, - hostVerifier?: import('ssh2').HostVerifier + hostVerifier?: HostVerifier ): { connectConfig: Parameters[0] algorithmCapture: AlgorithmCapture | null @@ -275,7 +275,7 @@ export class SSHServiceImpl implements SSHService { }, this.connectionTimeout) // Create host key verifier if service is available and socket is provided - let hostVerifier: import('ssh2').HostVerifier | undefined + let hostVerifier: HostVerifier | undefined if ( this.hostKeyService !== undefined && this.hostKeyService.isEnabled && diff --git a/tests/unit/config/host-key-config.vitest.ts b/tests/unit/config/host-key-config.vitest.ts index 07a4d594..62846c64 100644 --- a/tests/unit/config/host-key-config.vitest.ts +++ b/tests/unit/config/host-key-config.vitest.ts @@ -110,7 +110,7 @@ void describe('resolveHostKeyMode', () => { it('should not mutate the input config', () => { const config = buildHostKeyConfig({ mode: 'server' }) - const original = structuredClone(config) + const original = JSON.parse(JSON.stringify(config)) as typeof config resolveHostKeyMode(config) expect(config).toEqual(original) diff --git a/tests/unit/services/host-key/host-key-verifier.vitest.ts b/tests/unit/services/host-key/host-key-verifier.vitest.ts index d80ebf6b..f2f84d9c 100644 --- a/tests/unit/services/host-key/host-key-verifier.vitest.ts +++ b/tests/unit/services/host-key/host-key-verifier.vitest.ts @@ -2,6 +2,7 @@ // Tests for createHostKeyVerifier factory import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Socket } from 'socket.io' import { createHostKeyVerifier, extractAlgorithm, @@ -103,7 +104,7 @@ describe('createHostKeyVerifier', () => { const service = createMockHostKeyService({ isEnabled: false }) const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -123,7 +124,7 @@ describe('createHostKeyVerifier', () => { }) const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -146,7 +147,7 @@ describe('createHostKeyVerifier', () => { }) const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -182,7 +183,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -227,7 +228,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -254,7 +255,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -285,7 +286,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -313,7 +314,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, @@ -348,7 +349,7 @@ describe('createHostKeyVerifier', () => { const verifier = createHostKeyVerifier({ hostKeyService: service, - socket: socket as unknown as import('socket.io').Socket, + socket: socket as unknown as Socket, host: TEST_HOST, port: TEST_PORT, log: mockLog, From c1c83bad67412932b94ecf55983bf4663b8d5370 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 13:38:55 -0800 Subject: [PATCH 19/31] docs: add host key verification to configuration reference - Add hostKeyVerification section to CONFIG-JSON.md with all options, default config, use case examples, and seeding script usage - Add 6 WEBSSH2_SSH_HOSTKEY_* env vars to ENVIRONMENT-VARIABLES.md with mode behavior table and Docker example - Add SOCKET_EVENTS host key constants to CONSTANTS.md - Add hostKeyVerification block to default config example --- DOCS/configuration/CONFIG-JSON.md | 171 +++++++++++++++++++- DOCS/configuration/CONSTANTS.md | 18 +++ DOCS/configuration/ENVIRONMENT-VARIABLES.md | 61 +++++++ 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/DOCS/configuration/CONFIG-JSON.md b/DOCS/configuration/CONFIG-JSON.md index 648acaf0..55d9497c 100644 --- a/DOCS/configuration/CONFIG-JSON.md +++ b/DOCS/configuration/CONFIG-JSON.md @@ -185,7 +185,19 @@ These settings are now managed client-side. "envAllowlist": ["ONLY_THIS", "AND_THAT"], "maxExecOutputBytes": 10485760, "outputRateLimitBytesPerSec": 0, - "socketHighWaterMark": 16384 + "socketHighWaterMark": 16384, + "hostKeyVerification": { + "enabled": false, + "mode": "hybrid", + "unknownKeyAction": "prompt", + "serverStore": { + "enabled": true, + "dbPath": "/data/hostkeys.db" + }, + "clientStore": { + "enabled": true + } + } }, "options": { "challengeButton": true, @@ -419,3 +431,160 @@ These options can also be configured via environment variables: - `WEBSSH2_SSH_SFTP_TIMEOUT` See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details on environment variable format and examples. + +### Host Key Verification + +SSH host key verification provides TOFU (Trust On First Use) protection against man-in-the-middle attacks. It supports three modes of operation: server-only (SQLite store), client-only (browser localStorage), and hybrid (server-first with client fallback). + +#### Configuration Options + +- `ssh.hostKeyVerification.enabled` (boolean, default: `false`): Enable or disable host key verification. When disabled (the default), all host keys are accepted without verification. + +- `ssh.hostKeyVerification.mode` (`'server'` | `'client'` | `'hybrid'`, default: `'hybrid'`): Operational mode. `server` uses only the SQLite store, `client` uses only the browser localStorage store, `hybrid` checks the server store first and falls back to the client store for unknown keys. The mode sets sensible defaults for which stores are enabled, but explicit store flags override mode defaults. + +- `ssh.hostKeyVerification.unknownKeyAction` (`'prompt'` | `'alert'` | `'reject'`, default: `'prompt'`): Action when an unknown key is encountered (no match in any enabled store). `prompt` asks the user to accept or reject, `alert` allows the connection with a warning, `reject` blocks the connection. + +- `ssh.hostKeyVerification.serverStore.enabled` (boolean): Whether the server-side SQLite store is active. Defaults are derived from `mode` but can be overridden explicitly. + +- `ssh.hostKeyVerification.serverStore.dbPath` (string, default: `'/data/hostkeys.db'`): Path to the SQLite database file. The application opens it read-only. Use `npm run hostkeys` to manage keys. + +- `ssh.hostKeyVerification.clientStore.enabled` (boolean): Whether the client-side browser localStorage store is active. Defaults are derived from `mode` but can be overridden explicitly. + +#### Default Host Key Verification Configuration + +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": false, + "mode": "hybrid", + "unknownKeyAction": "prompt", + "serverStore": { + "enabled": true, + "dbPath": "/data/hostkeys.db" + }, + "clientStore": { + "enabled": true + } + } + } +} +``` + +> **Note:** Host key verification is disabled by default. Set `enabled` to `true` to activate it. + +#### Use Cases + +**Enable with hybrid mode (recommended):** +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "hybrid" + } + } +} +``` +Server store is checked first. If the key is unknown on the server, the client's browser store is consulted. Unknown keys prompt the user. + +**Server-only mode (centrally managed keys):** +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "server", + "unknownKeyAction": "reject" + } + } +} +``` +Only the server SQLite store is used. Unknown keys are rejected — administrators must pre-seed keys via `npm run hostkeys`. + +**Client-only mode (no server database):** +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "client" + } + } +} +``` +Only the client browser store is used. Users manage their own trusted keys via the settings UI. + +**Alert-only (log but don't block):** +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "server", + "unknownKeyAction": "alert" + } + } +} +``` +Unknown keys show a warning indicator but connections proceed. Useful for monitoring before enforcing. + +**Override mode defaults with explicit flags:** +```json +{ + "ssh": { + "hostKeyVerification": { + "enabled": true, + "mode": "server", + "serverStore": { "enabled": true, "dbPath": "/data/hostkeys.db" }, + "clientStore": { "enabled": true } + } + } +} +``` +Mode is `server` but `clientStore.enabled` is explicitly set to `true`, making it behave like hybrid. Explicit flags always take precedence over mode defaults. + +#### Seeding the Server Store + +Use the built-in CLI tool to manage the SQLite database: + +```bash +# Probe a host and add its key +npm run hostkeys -- --host server1.example.com + +# Probe a host on a non-standard port +npm run hostkeys -- --host server1.example.com:2222 + +# Import from OpenSSH known_hosts file +npm run hostkeys -- --known-hosts ~/.ssh/known_hosts + +# List all stored keys +npm run hostkeys -- --list + +# Remove keys for a host +npm run hostkeys -- --remove server1.example.com + +# Use a custom database path +npm run hostkeys -- --db /custom/path/hostkeys.db --host server1.example.com +``` + +#### Environment Variables + +These options can also be configured via environment variables: +- `WEBSSH2_SSH_HOSTKEY_ENABLED` +- `WEBSSH2_SSH_HOSTKEY_MODE` +- `WEBSSH2_SSH_HOSTKEY_UNKNOWN_ACTION` +- `WEBSSH2_SSH_HOSTKEY_DB_PATH` +- `WEBSSH2_SSH_HOSTKEY_SERVER_ENABLED` +- `WEBSSH2_SSH_HOSTKEY_CLIENT_ENABLED` + +See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details. +- `WEBSSH2_SSH_SFTP_MAX_FILE_SIZE` +- `WEBSSH2_SSH_SFTP_TRANSFER_RATE_LIMIT_BYTES_PER_SEC` +- `WEBSSH2_SSH_SFTP_CHUNK_SIZE` +- `WEBSSH2_SSH_SFTP_MAX_CONCURRENT_TRANSFERS` +- `WEBSSH2_SSH_SFTP_ALLOWED_PATHS` +- `WEBSSH2_SSH_SFTP_BLOCKED_EXTENSIONS` +- `WEBSSH2_SSH_SFTP_TIMEOUT` + +See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details on environment variable format and examples. diff --git a/DOCS/configuration/CONSTANTS.md b/DOCS/configuration/CONSTANTS.md index 0c0ed082..4d8c232e 100644 --- a/DOCS/configuration/CONSTANTS.md +++ b/DOCS/configuration/CONSTANTS.md @@ -56,12 +56,30 @@ Used in: - `app/security-headers.ts` (`SECURITY_HEADERS`, `createCSPMiddleware`) +## SOCKET_EVENTS (Host Key Verification) + +Location: `app/constants/socket-events.ts` + +The following socket events were added for host key verification: + +| Constant | Event Name | Direction | Description | +|----------|-----------|-----------|-------------| +| `HOSTKEY_VERIFY` | `hostkey:verify` | Server → Client | Request client to verify an unknown host key | +| `HOSTKEY_VERIFY_RESPONSE` | `hostkey:verify-response` | Client → Server | Client's accept/reject/trusted decision | +| `HOSTKEY_VERIFIED` | `hostkey:verified` | Server → Client | Key verified successfully, connection proceeds | +| `HOSTKEY_MISMATCH` | `hostkey:mismatch` | Server → Client | Key mismatch detected, connection refused | +| `HOSTKEY_ALERT` | `hostkey:alert` | Server → Client | Unknown key warning (connection proceeds) | +| `HOSTKEY_REJECTED` | `hostkey:rejected` | Server → Client | Unknown key rejected by policy | + +See [host-key-protocol.md](../host-key-protocol.md) for full payload schemas and sequence diagrams. + ## Where These Are Used - Routing and connection setup: `app/routes-v2.ts`, `app/connection/connectionHandler.ts` - Middleware and security: `app/middleware.ts`, `app/security-headers.ts` - SSH behavior and env handling: `app/services/ssh/ssh-service.ts` - Socket behavior: `app/socket-v2.ts`, `app/socket/adapters/service-socket-adapter.ts` +- Host key verification: `app/services/host-key/host-key-verifier.ts` ## Conventions diff --git a/DOCS/configuration/ENVIRONMENT-VARIABLES.md b/DOCS/configuration/ENVIRONMENT-VARIABLES.md index b2ef3dcf..0fe5a7f3 100644 --- a/DOCS/configuration/ENVIRONMENT-VARIABLES.md +++ b/DOCS/configuration/ENVIRONMENT-VARIABLES.md @@ -93,6 +93,67 @@ The server applies security headers and a Content Security Policy (CSP) by defau | `WEBSSH2_SSH_OUTPUT_RATE_LIMIT_BYTES_PER_SEC` | number | `0` (unlimited) | Rate limit for shell output streams (bytes/second). `0` disables rate limiting | | `WEBSSH2_SSH_SOCKET_HIGH_WATER_MARK` | number | `16384` (16KB) | Socket.IO buffer threshold for stream backpressure control | +### Host Key Verification + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `WEBSSH2_SSH_HOSTKEY_ENABLED` | boolean | `false` | Enable or disable SSH host key verification | +| `WEBSSH2_SSH_HOSTKEY_MODE` | string | `hybrid` | Verification mode: `server`, `client`, or `hybrid` | +| `WEBSSH2_SSH_HOSTKEY_UNKNOWN_ACTION` | string | `prompt` | Action for unknown keys: `prompt`, `alert`, or `reject` | +| `WEBSSH2_SSH_HOSTKEY_DB_PATH` | string | `/data/hostkeys.db` | Path to the SQLite host key database (opened read-only by the app) | +| `WEBSSH2_SSH_HOSTKEY_SERVER_ENABLED` | boolean | *(from mode)* | Override: enable/disable server-side SQLite store | +| `WEBSSH2_SSH_HOSTKEY_CLIENT_ENABLED` | boolean | *(from mode)* | Override: enable/disable client-side browser store | + +#### Host Key Verification Examples + +**Enable hybrid mode (server-first, client fallback):** + +```bash +WEBSSH2_SSH_HOSTKEY_ENABLED=true +WEBSSH2_SSH_HOSTKEY_MODE=hybrid +``` + +**Server-only with strict rejection of unknown keys:** + +```bash +WEBSSH2_SSH_HOSTKEY_ENABLED=true +WEBSSH2_SSH_HOSTKEY_MODE=server +WEBSSH2_SSH_HOSTKEY_UNKNOWN_ACTION=reject +WEBSSH2_SSH_HOSTKEY_DB_PATH=/data/hostkeys.db +``` + +**Client-only (no server database needed):** + +```bash +WEBSSH2_SSH_HOSTKEY_ENABLED=true +WEBSSH2_SSH_HOSTKEY_MODE=client +``` + +**Docker with host key database volume:** + +```bash +docker run -d \ + -p 2222:2222 \ + -v /path/to/hostkeys.db:/data/hostkeys.db:ro \ + -e WEBSSH2_SSH_HOSTKEY_ENABLED=true \ + -e WEBSSH2_SSH_HOSTKEY_MODE=server \ + webssh2:latest +``` + +#### Mode Behavior + +The `mode` sets sensible defaults for which stores are enabled: + +| Mode | Server Store | Client Store | +|------|-------------|-------------| +| `server` | enabled | disabled | +| `client` | disabled | enabled | +| `hybrid` | enabled | enabled | + +Explicit `WEBSSH2_SSH_HOSTKEY_SERVER_ENABLED` and `WEBSSH2_SSH_HOSTKEY_CLIENT_ENABLED` override mode defaults. + +See [CONFIG-JSON.md](./CONFIG-JSON.md) for `config.json` examples and the seeding script usage. + ### SFTP Configuration | Variable | Type | Default | Description | From 97cb1730ac7f6eac3525b262e554b651668c5980 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 15:08:11 -0800 Subject: [PATCH 20/31] feat: send host key verification config pre-auth and honor env vars in hostkeys script - Add hostKeyVerification to /ssh/config HTTP endpoint so the client receives the config at page load, before any socket connection. This allows the Trusted Host Keys settings section to be visible immediately rather than only after authenticating. - Also emit hostKeyVerification in an early permissions event on socket connect (before auth) for the socket-connected-but-not-yet-authenticated case. - Make session-related permissions fields optional in the socket contract to support partial pre-auth emissions. - Add WEBSSH2_SSH_HOSTKEY_DB_PATH env var support to the hostkeys CLI script (resolution: --db arg > env var > config.json > default). - Add data/ to .gitignore. --- .gitignore | 1 + app/routes/handlers/ssh-config-handler.ts | 6 ++++++ app/socket/adapters/service-socket-adapter.ts | 16 ++++++++++++++++ app/types/contracts/v1/http.ts | 5 +++++ app/types/contracts/v1/socket.ts | 10 +++++----- scripts/host-key-seed.ts | 13 +++++++++++-- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 1eaa662f..3f13aea7 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ config.json.backup release-artifacts/ +data/ \ No newline at end of file diff --git a/app/routes/handlers/ssh-config-handler.ts b/app/routes/handlers/ssh-config-handler.ts index ca454926..d4ae9061 100644 --- a/app/routes/handlers/ssh-config-handler.ts +++ b/app/routes/handlers/ssh-config-handler.ts @@ -13,10 +13,16 @@ export function createSshConfigResponse( _request: SshRouteRequest, config: Config ): Result { + const hostKeyVerificationConfig = config.ssh.hostKeyVerification const payload: SshConfigResponse = { allowedAuthMethods: config.ssh.allowedAuthMethods.map( (method) => `${method}` as AuthMethodToken ), + hostKeyVerification: { + enabled: hostKeyVerificationConfig.enabled, + clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, + unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, + }, } return ok({ diff --git a/app/socket/adapters/service-socket-adapter.ts b/app/socket/adapters/service-socket-adapter.ts index abc7ea92..942b2686 100644 --- a/app/socket/adapters/service-socket-adapter.ts +++ b/app/socket/adapters/service-socket-adapter.ts @@ -85,6 +85,7 @@ export class ServiceSocketAdapter { this.setupEventHandlers() this.logSessionInit() + this.emitHostKeyVerificationConfig() this.auth.checkInitialAuth() } @@ -192,6 +193,21 @@ export class ServiceSocketAdapter { } }) } + + /** + * Emit host key verification config early (before auth) so the client + * can show the Trusted Host Keys settings section immediately. + */ + private emitHostKeyVerificationConfig(): void { + const hostKeyVerificationConfig = this.config.ssh.hostKeyVerification + this.socket.emit(SOCKET_EVENTS.PERMISSIONS, { + hostKeyVerification: { + enabled: hostKeyVerificationConfig.enabled, + clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, + unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, + }, + }) + } } function extractClientDetails( diff --git a/app/types/contracts/v1/http.ts b/app/types/contracts/v1/http.ts index ce41c1e3..a0f9be66 100644 --- a/app/types/contracts/v1/http.ts +++ b/app/types/contracts/v1/http.ts @@ -5,4 +5,9 @@ import type { AuthMethodToken } from '../../branded.js' export interface SshConfigResponse { allowedAuthMethods: AuthMethodToken[] + hostKeyVerification?: { + enabled: boolean + clientStoreEnabled: boolean + unknownKeyAction: 'prompt' | 'alert' | 'reject' + } } diff --git a/app/types/contracts/v1/socket.ts b/app/types/contracts/v1/socket.ts index d9843938..1c38e22c 100644 --- a/app/types/contracts/v1/socket.ts +++ b/app/types/contracts/v1/socket.ts @@ -244,12 +244,12 @@ export interface ServerToClientEvents { authFailure: (payload: { error: string; method: string }) => void // Connection error (replaces HTML error pages) 'connection-error': (payload: ConnectionErrorPayload) => void - // Permissions negotiated post-auth + // Permissions - hostKeyVerification sent pre-auth, remaining fields post-auth permissions: (p: { - autoLog: boolean - allowReplay: boolean - allowReconnect: boolean - allowReauth: boolean + autoLog?: boolean + allowReplay?: boolean + allowReconnect?: boolean + allowReauth?: boolean hostKeyVerification?: { enabled: boolean clientStoreEnabled: boolean diff --git a/scripts/host-key-seed.ts b/scripts/host-key-seed.ts index 5afb0356..685c2437 100644 --- a/scripts/host-key-seed.ts +++ b/scripts/host-key-seed.ts @@ -51,8 +51,11 @@ Commands: Options: --db Database file path - (default: config.json ssh.hostKeyVerification.serverStore.dbPath - or /data/hostkeys.db) + Resolution order: + 1. --db argument + 2. WEBSSH2_SSH_HOSTKEY_DB_PATH env var + 3. config.json ssh.hostKeyVerification.serverStore.dbPath + 4. /data/hostkeys.db (default) Examples: npm run hostkeys -- --host example.com @@ -285,6 +288,12 @@ function resolveDbPath(explicitPath: string | undefined): string { return explicitPath } + // Try environment variable (same as main app uses) + const envDbPath = process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + if (typeof envDbPath === 'string' && envDbPath !== '') { + return envDbPath + } + // Try reading from config.json const configPath = path.resolve(process.cwd(), 'config.json') if (fs.existsSync(configPath)) { From 486aa42c152367df9b68a877d25570475a79668c Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 15:42:52 -0800 Subject: [PATCH 21/31] test: add host key verification test coverage and fix SonarQube issues Add tests for SSH config handler hostKeyVerification response, socket adapter permissions emission, and host-key-seed CLI helpers. Export extractDbPathFromConfig, resolveDbPath, and parseArgs for testability. Fix S2871 unstable .sort() in contract tests, update socket contract assertions to account for pre-auth permissions emission order. --- scripts/host-key-seed.ts | 19 +- tests/contracts/routes-stack.vitest.ts | 2 +- tests/contracts/socket-contracts.vitest.ts | 22 ++- .../unit/routes/ssh-config-handler.vitest.ts | 85 ++++++++- tests/unit/scripts/host-key-seed.vitest.ts | 178 ++++++++++++++++++ .../socket/service-socket-adapter.vitest.ts | 51 ++++- 6 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 tests/unit/scripts/host-key-seed.vitest.ts diff --git a/scripts/host-key-seed.ts b/scripts/host-key-seed.ts index 685c2437..7804619b 100644 --- a/scripts/host-key-seed.ts +++ b/scripts/host-key-seed.ts @@ -255,7 +255,7 @@ function parseKnownHosts(content: string): KnownHostEntry[] { * Uses explicit type narrowing rather than indexed access to satisfy * the security/detect-object-injection rule. */ -function extractDbPathFromConfig(config: unknown): string | undefined { +export function extractDbPathFromConfig(config: unknown): string | undefined { if (typeof config !== 'object' || config === null) { return undefined } @@ -283,7 +283,7 @@ function extractDbPathFromConfig(config: unknown): string | undefined { return undefined } -function resolveDbPath(explicitPath: string | undefined): string { +export function resolveDbPath(explicitPath: string | undefined): string { if (explicitPath !== undefined) { return explicitPath } @@ -316,7 +316,7 @@ function resolveDbPath(explicitPath: string | undefined): string { // CLI argument parsing // --------------------------------------------------------------------------- -interface CliArgs { +export interface CliArgs { command: 'host' | 'hosts' | 'known-hosts' | 'list' | 'remove' | 'help' host?: string | undefined port?: number | undefined @@ -330,7 +330,7 @@ function nextArg(args: readonly string[], index: number): string | undefined { return next < args.length ? args.at(next) : undefined } -function parseArgs(argv: readonly string[]): CliArgs { +export function parseArgs(argv: readonly string[]): CliArgs { const args = argv.slice(2) // skip node and script path let command: CliArgs['command'] = 'help' let host: string | undefined @@ -596,5 +596,12 @@ async function main(): Promise { return 0 } -const exitCode = await main() -process.exitCode = exitCode +// Only run main() when executed directly (not when imported for testing) +const isDirectExecution = process.argv[1]?.endsWith('host-key-seed') + ?? process.argv[1]?.endsWith('host-key-seed.ts') + ?? false + +if (isDirectExecution) { + const exitCode = await main() + process.exitCode = exitCode +} diff --git a/tests/contracts/routes-stack.vitest.ts b/tests/contracts/routes-stack.vitest.ts index 1eb84f25..c67b8398 100644 --- a/tests/contracts/routes-stack.vitest.ts +++ b/tests/contracts/routes-stack.vitest.ts @@ -43,7 +43,7 @@ function getRouteMap(router: unknown): Record { // eslint-disable-next-line security/detect-object-injection for (const m of methods) { byPath[p].add(m) } } - return Object.fromEntries(Object.entries(byPath).map(([p, s]) => [p, Array.from(s).sort()])) + return Object.fromEntries(Object.entries(byPath).map(([p, s]) => [p, Array.from(s).sort((a, b) => a.localeCompare(b))])) } it('router registers expected paths and methods', () => { diff --git a/tests/contracts/socket-contracts.vitest.ts b/tests/contracts/socket-contracts.vitest.ts index 8e401368..47676d42 100644 --- a/tests/contracts/socket-contracts.vitest.ts +++ b/tests/contracts/socket-contracts.vitest.ts @@ -60,7 +60,13 @@ describe('Socket.IO Contracts', () => { it('emits authentication(request_auth) on new connection without basic auth', () => { const connectionHandler = io.on.mock.calls[0][1] connectionHandler(mockSocket) - const [event, payload] = mockSocket.emit.mock.calls[0] + // permissions with hostKeyVerification is now emitted first, then authentication + const authEvent = mockSocket.emit.mock.calls.find((c) => c[0] === 'authentication') + expect(authEvent).toBeDefined() + if (authEvent === undefined) { + return + } + const [event, payload] = authEvent expect(event).toBe('authentication') expect(payload).toEqual({ action: 'request_auth' }) }) @@ -98,16 +104,20 @@ describe('Socket.IO Contracts', () => { await new Promise((r) => setImmediate(r)) await new Promise((r) => setImmediate(r)) - const permEvent = mockSocket.emit.mock.calls.find((c) => c[0] === 'permissions') - expect(permEvent).toBeDefined() - if (permEvent === undefined) { + // There are two permissions events: the pre-auth one with hostKeyVerification only, + // and the post-auth one with allowReauth, allowReconnect, allowReplay, autoLog, hostKeyVerification. + // Find the post-auth permissions event (the one with allowReplay). + const permEvents = mockSocket.emit.mock.calls.filter((c) => c[0] === 'permissions') + const postAuthPerm = permEvents.find((c) => isRecord(c[1]) && 'allowReplay' in (c[1] as Record)) + expect(postAuthPerm).toBeDefined() + if (postAuthPerm === undefined) { return } - const [, payload] = permEvent + const [, payload] = postAuthPerm expect(isRecord(payload)).toBe(true) if (!isRecord(payload)) { return } - expect(Object.keys(payload).sort()).toEqual(['allowReauth', 'allowReconnect', 'allowReplay', 'autoLog', 'hostKeyVerification'].sort()) + expect(Object.keys(payload).sort((a, b) => a.localeCompare(b))).toEqual(['allowReauth', 'allowReconnect', 'allowReplay', 'autoLog', 'hostKeyVerification'].sort((a, b) => a.localeCompare(b))) }) }) diff --git a/tests/unit/routes/ssh-config-handler.vitest.ts b/tests/unit/routes/ssh-config-handler.vitest.ts index 80583e8a..00dfa5c3 100644 --- a/tests/unit/routes/ssh-config-handler.vitest.ts +++ b/tests/unit/routes/ssh-config-handler.vitest.ts @@ -14,7 +14,7 @@ const createRequest = (): SshRouteRequest => ({ }) describe('createSshConfigResponse', () => { - it('returns allowed auth methods and disables caching', () => { + it('returns allowed auth methods, hostKeyVerification, and disables caching', () => { const config = createDefaultConfig('test-session-secret') config.ssh.allowedAuthMethods = [ createAuthMethod(AUTH_METHOD_TOKENS.PUBLIC_KEY), @@ -32,6 +32,89 @@ describe('createSshConfigResponse', () => { expect(result.value.headers).toEqual({ 'Cache-Control': 'no-store' }) expect(result.value.data).toEqual({ allowedAuthMethods: ['publickey', 'password'], + hostKeyVerification: { + enabled: false, + clientStoreEnabled: true, + unknownKeyAction: 'prompt', + }, }) }) + + it('includes hostKeyVerification reflecting default config', () => { + const config = createDefaultConfig('test-session-secret') + + const result = createSshConfigResponse(createRequest(), config) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + const data = result.value.data as Record + expect(data['hostKeyVerification']).toEqual({ + enabled: false, + clientStoreEnabled: true, + unknownKeyAction: 'prompt', + }) + }) + + it('reflects enabled=true when host key verification is enabled', () => { + const config = createDefaultConfig('test-session-secret') + config.ssh.hostKeyVerification.enabled = true + + const result = createSshConfigResponse(createRequest(), config) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + const data = result.value.data as Record + const hkv = data['hostKeyVerification'] as Record + expect(hkv['enabled']).toBe(true) + }) + + it('reflects clientStoreEnabled=false when client store is disabled', () => { + const config = createDefaultConfig('test-session-secret') + config.ssh.hostKeyVerification.clientStore.enabled = false + + const result = createSshConfigResponse(createRequest(), config) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + const data = result.value.data as Record + const hkv = data['hostKeyVerification'] as Record + expect(hkv['clientStoreEnabled']).toBe(false) + }) + + it('reflects unknownKeyAction=reject when configured', () => { + const config = createDefaultConfig('test-session-secret') + config.ssh.hostKeyVerification.unknownKeyAction = 'reject' + + const result = createSshConfigResponse(createRequest(), config) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + const data = result.value.data as Record + const hkv = data['hostKeyVerification'] as Record + expect(hkv['unknownKeyAction']).toBe('reject') + }) + + it('does not expose serverStore internals (dbPath, mode) to client', () => { + const config = createDefaultConfig('test-session-secret') + + const result = createSshConfigResponse(createRequest(), config) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + const data = result.value.data as Record + const hkv = data['hostKeyVerification'] as Record + expect(hkv).not.toHaveProperty('dbPath') + expect(hkv).not.toHaveProperty('mode') + expect(hkv).not.toHaveProperty('serverStore') + }) }) diff --git a/tests/unit/scripts/host-key-seed.vitest.ts b/tests/unit/scripts/host-key-seed.vitest.ts new file mode 100644 index 00000000..03f9f3f4 --- /dev/null +++ b/tests/unit/scripts/host-key-seed.vitest.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock external dependencies before importing the module under test +vi.mock('better-sqlite3', () => ({ + default: vi.fn() +})) +vi.mock('ssh2', () => ({ + Client: vi.fn() +})) + +const { extractDbPathFromConfig, resolveDbPath, parseArgs } = await import( + '../../../scripts/host-key-seed.js' +) + +// --------------------------------------------------------------------------- +// extractDbPathFromConfig +// --------------------------------------------------------------------------- + +describe('extractDbPathFromConfig', () => { + it('returns dbPath from valid nested config', () => { + const config = { + ssh: { + hostKeyVerification: { + serverStore: { + dbPath: '/custom/path/keys.db' + } + } + } + } + expect(extractDbPathFromConfig(config)).toBe('/custom/path/keys.db') + }) + + it('returns undefined for null', () => { + expect(extractDbPathFromConfig(null)).toBeUndefined() + }) + + it('returns undefined for non-object', () => { + expect(extractDbPathFromConfig('string')).toBeUndefined() + expect(extractDbPathFromConfig(42)).toBeUndefined() + expect(extractDbPathFromConfig(true)).toBeUndefined() + }) + + it('returns undefined for missing nested keys', () => { + expect(extractDbPathFromConfig({})).toBeUndefined() + expect(extractDbPathFromConfig({ ssh: {} })).toBeUndefined() + expect(extractDbPathFromConfig({ ssh: { hostKeyVerification: {} } })).toBeUndefined() + expect( + extractDbPathFromConfig({ ssh: { hostKeyVerification: { serverStore: {} } } }) + ).toBeUndefined() + }) + + it('returns undefined for empty string dbPath', () => { + expect( + extractDbPathFromConfig({ + ssh: { hostKeyVerification: { serverStore: { dbPath: '' } } } + }) + ).toBeUndefined() + }) + + it('returns undefined for non-string dbPath', () => { + expect( + extractDbPathFromConfig({ + ssh: { hostKeyVerification: { serverStore: { dbPath: 123 } } } + }) + ).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// resolveDbPath +// --------------------------------------------------------------------------- + +describe('resolveDbPath', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + delete process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + }) + + afterEach(() => { + // Restore env + if (originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] !== undefined) { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + } else { + delete process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + } + }) + + it('returns explicit path when provided (highest priority)', () => { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = '/env/path.db' + expect(resolveDbPath('/explicit/path.db')).toBe('/explicit/path.db') + }) + + it('returns WEBSSH2_SSH_HOSTKEY_DB_PATH env var when no explicit path', () => { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = '/env/hostkeys.db' + expect(resolveDbPath(undefined)).toBe('/env/hostkeys.db') + }) + + it('ignores empty env var', () => { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = '' + // Falls through to config.json or default + const result = resolveDbPath(undefined) + // Should be either from config.json or the default + expect(typeof result).toBe('string') + expect(result).not.toBe('') + }) + + it('returns default /data/hostkeys.db when all sources empty', () => { + // No explicit path, no env var, and config.json likely does not have the field + // The default fallback should be /data/hostkeys.db + const result = resolveDbPath(undefined) + // It may fall through to config.json if present; in test env default is expected + expect(typeof result).toBe('string') + }) +}) + +// --------------------------------------------------------------------------- +// parseArgs +// --------------------------------------------------------------------------- + +describe('parseArgs', () => { + it('defaults to help command with no args', () => { + const result = parseArgs(['node', 'script']) + expect(result.command).toBe('help') + }) + + it('parses --help', () => { + const result = parseArgs(['node', 'script', '--help']) + expect(result.command).toBe('help') + }) + + it('parses -h', () => { + const result = parseArgs(['node', 'script', '-h']) + expect(result.command).toBe('help') + }) + + it('parses --host', () => { + const result = parseArgs(['node', 'script', '--host', 'example.com']) + expect(result.command).toBe('host') + expect(result.host).toBe('example.com') + }) + + it('parses --host with --port', () => { + const result = parseArgs(['node', 'script', '--host', 'example.com', '--port', '2222']) + expect(result.command).toBe('host') + expect(result.host).toBe('example.com') + expect(result.port).toBe(2222) + }) + + it('parses --list', () => { + const result = parseArgs(['node', 'script', '--list']) + expect(result.command).toBe('list') + }) + + it('parses --remove', () => { + const result = parseArgs(['node', 'script', '--remove', 'example.com:22']) + expect(result.command).toBe('remove') + expect(result.removeTarget).toBe('example.com:22') + }) + + it('parses --hosts', () => { + const result = parseArgs(['node', 'script', '--hosts', 'hosts.txt']) + expect(result.command).toBe('hosts') + expect(result.file).toBe('hosts.txt') + }) + + it('parses --known-hosts', () => { + const result = parseArgs(['node', 'script', '--known-hosts', '~/.ssh/known_hosts']) + expect(result.command).toBe('known-hosts') + expect(result.file).toBe('~/.ssh/known_hosts') + }) + + it('parses --db', () => { + const result = parseArgs(['node', 'script', '--list', '--db', '/custom/path.db']) + expect(result.command).toBe('list') + expect(result.dbPath).toBe('/custom/path.db') + }) +}) diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index c788a89f..c36e037f 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -142,7 +142,19 @@ const createConfig = (): Config => ({ kex: [], serverHostKey: [] }, - allowedAuthMethods: DEFAULT_AUTH_METHODS.map(createAuthMethod) + allowedAuthMethods: DEFAULT_AUTH_METHODS.map(createAuthMethod), + hostKeyVerification: { + enabled: false, + mode: 'hybrid' as const, + unknownKeyAction: 'prompt' as const, + serverStore: { + enabled: true, + dbPath: '/data/hostkeys.db', + }, + clientStore: { + enabled: true, + }, + } }, header: { text: null, @@ -233,4 +245,41 @@ describe('ServiceSocketAdapter', () => { allow_reconnect: true }) }) + + it('emits permissions with hostKeyVerification on construction', async () => { + const { ServiceSocketAdapter } = await import('../../../app/socket/adapters/service-socket-adapter.js') + + const socket = createSocket() + const config = createConfig() + const services = {} as Services + + new ServiceSocketAdapter(socket, config, services) + + expect(socket.emit).toHaveBeenCalledWith('permissions', { + hostKeyVerification: { + enabled: false, + clientStoreEnabled: true, + unknownKeyAction: 'prompt', + }, + }) + }) + + it('emits permissions before auth check (verify emit order)', async () => { + const { ServiceSocketAdapter } = await import('../../../app/socket/adapters/service-socket-adapter.js') + + const socket = createSocket() + const config = createConfig() + config.ssh.hostKeyVerification.enabled = true + const services = {} as Services + + new ServiceSocketAdapter(socket, config, services) + + expect(socket.emit).toHaveBeenCalledWith('permissions', { + hostKeyVerification: { + enabled: true, + clientStoreEnabled: true, + unknownKeyAction: 'prompt', + }, + }) + }) }) From 63158369f5ed1b0bdd8efe3304c3042ce9f75b76 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Wed, 25 Feb 2026 15:53:22 -0800 Subject: [PATCH 22/31] fix: resolve SonarQube quality gate failures on PR #488 - Replace hardcoded /tmp paths with :memory: in test configs (S5443) - Use void for side-effect-only constructor calls (S1848) - Add localeCompare to .sort() for reliable string ordering (S2871) - Extract shared test fixtures to reduce cross-file duplication - Extract helper functions to reduce self-duplication in verifier and integration tests --- .../host-key-verification.vitest.ts | 76 ++++++++---------- tests/test-utils.ts | 2 +- .../host-key/host-key-service.vitest.ts | 32 ++------ .../host-key/host-key-store.vitest.ts | 34 +++----- .../host-key/host-key-test-fixtures.ts | 33 ++++++++ .../host-key/host-key-verifier.vitest.ts | 78 ++++++------------- tests/unit/socket-v2-test-utils.ts | 2 +- .../socket/service-socket-adapter.vitest.ts | 4 +- 8 files changed, 109 insertions(+), 152 deletions(-) create mode 100644 tests/unit/services/host-key/host-key-test-fixtures.ts diff --git a/tests/integration/host-key-verification.vitest.ts b/tests/integration/host-key-verification.vitest.ts index 6ed4cdaa..b0a9399f 100644 --- a/tests/integration/host-key-verification.vitest.ts +++ b/tests/integration/host-key-verification.vitest.ts @@ -128,6 +128,36 @@ describe('Host key verification integration', () => { fs.rmSync(tmpDir, { recursive: true, force: true }) }) + async function runUnknownKeyScenario(unknownKeyAction: 'reject' | 'alert' | 'prompt'): Promise<{ + result: boolean + algorithm: string + service: HostKeyService + }> { + const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') + const algorithm = extractAlgorithm(keyBuffer) + + seedDb(dbPath, []) + + const config = buildConfig({ + dbPath, + serverStoreEnabled: true, + clientStoreEnabled: false, + unknownKeyAction, + }) + const service = new HostKeyService(config) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, keyBuffer) + return { result, algorithm, service } + } + it('trusts a key that matches the server store and emits hostkey:verified with source "server"', async () => { const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') const algorithm = extractAlgorithm(keyBuffer) @@ -203,28 +233,7 @@ describe('Host key verification integration', () => { }) it('rejects an unknown key when unknownKeyAction is "reject" and emits hostkey:rejected', async () => { - const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') - const algorithm = extractAlgorithm(keyBuffer) - - seedDb(dbPath, []) - - const config = buildConfig({ - dbPath, - serverStoreEnabled: true, - clientStoreEnabled: false, - unknownKeyAction: 'reject', - }) - const service = new HostKeyService(config) - - const verifier = createHostKeyVerifier({ - hostKeyService: service, - socket: socket as unknown as Socket, - host: TEST_HOST, - port: TEST_PORT, - log: mockLog, - }) - - const result = await callVerifier(verifier, keyBuffer) + const { result, algorithm, service } = await runUnknownKeyScenario('reject') expect(result).toBe(false) expect(socket.emit).toHaveBeenCalledTimes(1) @@ -242,28 +251,7 @@ describe('Host key verification integration', () => { }) it('allows an unknown key when unknownKeyAction is "alert" and emits hostkey:alert', async () => { - const keyBuffer = Buffer.from(TEST_KEY_ED25519, 'base64') - const algorithm = extractAlgorithm(keyBuffer) - - seedDb(dbPath, []) - - const config = buildConfig({ - dbPath, - serverStoreEnabled: true, - clientStoreEnabled: false, - unknownKeyAction: 'alert', - }) - const service = new HostKeyService(config) - - const verifier = createHostKeyVerifier({ - hostKeyService: service, - socket: socket as unknown as Socket, - host: TEST_HOST, - port: TEST_PORT, - log: mockLog, - }) - - const result = await callVerifier(verifier, keyBuffer) + const { result, algorithm, service } = await runUnknownKeyScenario('alert') expect(result).toBe(true) expect(socket.emit).toHaveBeenCalledTimes(1) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 03851edc..cab1ef17 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -510,7 +510,7 @@ export function createMockSocketConfig(overrides: Record = {}): unk enabled: false, mode: 'hybrid', unknownKeyAction: 'prompt', - serverStore: { enabled: false, dbPath: '/tmp/hostkeys.db' }, + serverStore: { enabled: false, dbPath: ':memory:' }, clientStore: { enabled: false }, }, ...overrides.ssh, diff --git a/tests/unit/services/host-key/host-key-service.vitest.ts b/tests/unit/services/host-key/host-key-service.vitest.ts index 8d3948bb..b22e2b29 100644 --- a/tests/unit/services/host-key/host-key-service.vitest.ts +++ b/tests/unit/services/host-key/host-key-service.vitest.ts @@ -5,21 +5,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import Database from 'better-sqlite3' import { HostKeyService } from '../../../../app/services/host-key/host-key-service.js' import type { HostKeyVerificationConfig } from '../../../../app/types/config.js' -import fs from 'node:fs' -import path from 'node:path' -import os from 'node:os' - -const HOST_KEY_SCHEMA = ` -CREATE TABLE host_keys ( - host TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 22, - algorithm TEXT NOT NULL, - key TEXT NOT NULL, - added_at TEXT NOT NULL DEFAULT (datetime('now')), - comment TEXT, - PRIMARY KEY (host, port, algorithm) -); -` +import { + HOST_KEY_SCHEMA, + createTempDbContext, + cleanupTempDbContext, + type TestContext, +} from './host-key-test-fixtures.js' const TEST_KEY_ED25519 = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' const TEST_KEY_RSA = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' @@ -40,11 +31,6 @@ function buildConfig(overrides?: Partial): HostKeyVer } } -interface TestContext { - tmpDir: string - dbPath: string -} - function seedTestDb(dbPath: string): void { const db = new Database(dbPath) db.exec(HOST_KEY_SCHEMA) @@ -59,13 +45,11 @@ void describe('HostKeyService', () => { let ctx: TestContext beforeEach(() => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hostkey-svc-')) - const dbPath = path.join(tmpDir, 'hostkeys.db') - ctx = { tmpDir, dbPath } + ctx = createTempDbContext('hostkey-svc-') }) afterEach(() => { - fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + cleanupTempDbContext(ctx) }) void describe('getters', () => { diff --git a/tests/unit/services/host-key/host-key-store.vitest.ts b/tests/unit/services/host-key/host-key-store.vitest.ts index be1551ae..8f59ddb0 100644 --- a/tests/unit/services/host-key/host-key-store.vitest.ts +++ b/tests/unit/services/host-key/host-key-store.vitest.ts @@ -4,32 +4,18 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import Database from 'better-sqlite3' import { HostKeyStore } from '../../../../app/services/host-key/host-key-store.js' -import fs from 'node:fs' -import path from 'node:path' -import os from 'node:os' - -const HOST_KEY_SCHEMA = ` -CREATE TABLE host_keys ( - host TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 22, - algorithm TEXT NOT NULL, - key TEXT NOT NULL, - added_at TEXT NOT NULL DEFAULT (datetime('now')), - comment TEXT, - PRIMARY KEY (host, port, algorithm) -); -` +import { + HOST_KEY_SCHEMA, + createTempDbContext, + cleanupTempDbContext, + type TestContext, +} from './host-key-test-fixtures.js' // Example base64 keys for testing (not real SSH keys, just deterministic test data) const TEST_KEY_ED25519 = 'AAAAC3NzaC1lZDI1NTE5AAAAIHVKcNtf2JfGHbMHOiT6VNBBpJIxMZpL' const TEST_KEY_RSA = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQC7lPe5xp0h' const TEST_KEY_ECDSA = 'AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' -interface TestContext { - tmpDir: string - dbPath: string -} - function createTestDb(dbPath: string): void { const db = new Database(dbPath) db.exec(HOST_KEY_SCHEMA) @@ -55,13 +41,11 @@ void describe('HostKeyStore', () => { let ctx: TestContext beforeEach(() => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hostkey-test-')) - const dbPath = path.join(tmpDir, 'hostkeys.db') - ctx = { tmpDir, dbPath } + ctx = createTempDbContext('hostkey-test-') }) afterEach(() => { - fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + cleanupTempDbContext(ctx) }) void describe('constructor', () => { @@ -179,7 +163,7 @@ void describe('HostKeyStore', () => { const keys = store.getAll('server1.example.com', 22) expect(keys).toHaveLength(2) - const algorithms = keys.map(k => k.algorithm).sort() + const algorithms = keys.map(k => k.algorithm).sort((a, b) => a.localeCompare(b)) expect(algorithms).toEqual(['ssh-ed25519', 'ssh-rsa']) store.close() }) diff --git a/tests/unit/services/host-key/host-key-test-fixtures.ts b/tests/unit/services/host-key/host-key-test-fixtures.ts new file mode 100644 index 00000000..1f8588bc --- /dev/null +++ b/tests/unit/services/host-key/host-key-test-fixtures.ts @@ -0,0 +1,33 @@ +// tests/unit/services/host-key/host-key-test-fixtures.ts +// Shared test fixtures for host-key tests to reduce duplication + +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +export const HOST_KEY_SCHEMA = ` +CREATE TABLE host_keys ( + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + algorithm TEXT NOT NULL, + key TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + comment TEXT, + PRIMARY KEY (host, port, algorithm) +); +` + +export interface TestContext { + tmpDir: string + dbPath: string +} + +export function createTempDbContext(prefix: string): TestContext { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + const dbPath = path.join(tmpDir, 'hostkeys.db') + return { tmpDir, dbPath } +} + +export function cleanupTempDbContext(ctx: TestContext): void { + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) +} diff --git a/tests/unit/services/host-key/host-key-verifier.vitest.ts b/tests/unit/services/host-key/host-key-verifier.vitest.ts index f2f84d9c..0628c401 100644 --- a/tests/unit/services/host-key/host-key-verifier.vitest.ts +++ b/tests/unit/services/host-key/host-key-verifier.vitest.ts @@ -100,6 +100,26 @@ describe('createHostKeyVerifier', () => { vi.useRealTimers() }) + async function runWithClientResponse(service: HostKeyService, action: string): Promise { + socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { + setTimeout(() => { + handler({ action }) + }, 10) + }) + + const verifier = createHostKeyVerifier({ + hostKeyService: service, + socket: socket as unknown as Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const promise = callVerifier(verifier, TEST_KEY_BUFFER) + await vi.advanceTimersByTimeAsync(10) + return promise + } + it('returns true without events when feature is disabled', async () => { const service = createMockHostKeyService({ isEnabled: false }) const verifier = createHostKeyVerifier({ @@ -174,27 +194,7 @@ describe('createHostKeyVerifier', () => { serverLookupResult: { status: 'unknown' }, }) - // Simulate client responding 'accept' - socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { - setTimeout(() => { - handler({ action: 'accept' }) - }, 10) - }) - - const verifier = createHostKeyVerifier({ - hostKeyService: service, - socket: socket as unknown as Socket, - host: TEST_HOST, - port: TEST_PORT, - log: mockLog, - }) - - const promise = callVerifier(verifier, TEST_KEY_BUFFER) - - // Advance timer to trigger the client response - await vi.advanceTimersByTimeAsync(10) - - const result = await promise + const result = await runWithClientResponse(service, 'accept') expect(result).toBe(true) expect(socket.emit).toHaveBeenCalledWith( @@ -220,23 +220,7 @@ describe('createHostKeyVerifier', () => { serverLookupResult: { status: 'unknown' }, }) - socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { - setTimeout(() => { - handler({ action: 'reject' }) - }, 10) - }) - - const verifier = createHostKeyVerifier({ - hostKeyService: service, - socket: socket as unknown as Socket, - host: TEST_HOST, - port: TEST_PORT, - log: mockLog, - }) - - const promise = callVerifier(verifier, TEST_KEY_BUFFER) - await vi.advanceTimersByTimeAsync(10) - const result = await promise + const result = await runWithClientResponse(service, 'reject') expect(result).toBe(false) }) @@ -341,23 +325,7 @@ describe('createHostKeyVerifier', () => { unknownKeyAction: 'prompt', }) - socket.once.mockImplementation((_event: string, handler: (response: { action: string }) => void) => { - setTimeout(() => { - handler({ action: 'accept' }) - }, 10) - }) - - const verifier = createHostKeyVerifier({ - hostKeyService: service, - socket: socket as unknown as Socket, - host: TEST_HOST, - port: TEST_PORT, - log: mockLog, - }) - - const promise = callVerifier(verifier, TEST_KEY_BUFFER) - await vi.advanceTimersByTimeAsync(10) - const result = await promise + const result = await runWithClientResponse(service, 'accept') expect(result).toBe(true) expect(socket.emit).toHaveBeenCalledWith( diff --git a/tests/unit/socket-v2-test-utils.ts b/tests/unit/socket-v2-test-utils.ts index 4c4bf638..d577c1df 100644 --- a/tests/unit/socket-v2-test-utils.ts +++ b/tests/unit/socket-v2-test-utils.ts @@ -157,7 +157,7 @@ export const createMockConfig = (): MockConfig => ({ enabled: false, mode: 'hybrid', unknownKeyAction: 'prompt', - serverStore: { enabled: false, dbPath: '/tmp/hostkeys.db' }, + serverStore: { enabled: false, dbPath: ':memory:' }, clientStore: { enabled: false }, } }, diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index c36e037f..120f9319 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -253,7 +253,7 @@ describe('ServiceSocketAdapter', () => { const config = createConfig() const services = {} as Services - new ServiceSocketAdapter(socket, config, services) + void new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { @@ -272,7 +272,7 @@ describe('ServiceSocketAdapter', () => { config.ssh.hostKeyVerification.enabled = true const services = {} as Services - new ServiceSocketAdapter(socket, config, services) + void new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { From ad14af90e5722872a79f8338aa32cfcd459785ea Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 06:28:19 -0800 Subject: [PATCH 23/31] fix: resolve lint errors in test files Remove unnecessary type assertion in socket-contracts and fix negated condition in host-key-seed tests. --- tests/contracts/socket-contracts.vitest.ts | 2 +- tests/unit/scripts/host-key-seed.vitest.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/contracts/socket-contracts.vitest.ts b/tests/contracts/socket-contracts.vitest.ts index 47676d42..11126aa1 100644 --- a/tests/contracts/socket-contracts.vitest.ts +++ b/tests/contracts/socket-contracts.vitest.ts @@ -108,7 +108,7 @@ describe('Socket.IO Contracts', () => { // and the post-auth one with allowReauth, allowReconnect, allowReplay, autoLog, hostKeyVerification. // Find the post-auth permissions event (the one with allowReplay). const permEvents = mockSocket.emit.mock.calls.filter((c) => c[0] === 'permissions') - const postAuthPerm = permEvents.find((c) => isRecord(c[1]) && 'allowReplay' in (c[1] as Record)) + const postAuthPerm = permEvents.find((c) => isRecord(c[1]) && 'allowReplay' in c[1]) expect(postAuthPerm).toBeDefined() if (postAuthPerm === undefined) { return diff --git a/tests/unit/scripts/host-key-seed.vitest.ts b/tests/unit/scripts/host-key-seed.vitest.ts index 03f9f3f4..4ade16b4 100644 --- a/tests/unit/scripts/host-key-seed.vitest.ts +++ b/tests/unit/scripts/host-key-seed.vitest.ts @@ -79,10 +79,10 @@ describe('resolveDbPath', () => { afterEach(() => { // Restore env - if (originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] !== undefined) { - process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] - } else { + if (originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] === undefined) { delete process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + } else { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = originalEnv['WEBSSH2_SSH_HOSTKEY_DB_PATH'] } }) From dbea2dff7c705286f56fd26d16dc0928c983cfdc Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 06:32:55 -0800 Subject: [PATCH 24/31] fix: update rollup to 4.59.0 for GHSA-mw96-cpmx-2vgc path traversal fix Update rollup from 4.57.1 to 4.59.0 to address high severity arbitrary file write vulnerability. Exception to 2-week age-out policy granted due to severity. Update SECURITY.md with assessment and review dates. --- SECURITY.md | 27 +++++- package-lock.json | 206 +++++++++++++++++++++++----------------------- 2 files changed, 128 insertions(+), 105 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index e207c83d..086deb06 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -121,6 +121,29 @@ For more information about detection logic or mitigations, contact the security --- -**Last updated:** 2026-01-27 +## Rollup path traversal vulnerability (GHSA-mw96-cpmx-2vgc) -**Next review:** 2026-02-27 +As of 2026-02-26, we evaluated the following vulnerability affecting our dev dependencies: + +### GHSA-mw96-cpmx-2vgc (Rollup Arbitrary File Write) + +| Aspect | Status | +| ----------------- | --------------------------------------------------- | +| Affected versions | rollup 4.0.0 - 4.58.0 | +| Severity | HIGH | +| Our version | rollup@4.59.0 (updated from 4.57.1) | +| Status | **Patched** - updated to fixed version | + +This vulnerability allows arbitrary file writes via path traversal in rollup's bundle output. + +**Action taken:** + +- Updated rollup from 4.57.1 to 4.59.0 which includes the fix +- rollup is a dev dependency only (used by Vitest) and does not ship in production builds +- Exception to the 2-week age-out policy was granted due to high severity + +--- + +**Last updated:** 2026-02-26 + +**Next review:** 2026-03-26 diff --git a/package-lock.json b/package-lock.json index f51ce4fb..2269b59f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -819,9 +819,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -833,9 +833,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -847,9 +847,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -861,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -875,9 +875,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -889,9 +889,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -903,9 +903,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -917,9 +917,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -931,9 +931,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -945,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -959,9 +959,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -973,9 +973,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -987,9 +987,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1001,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1015,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1029,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1043,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1057,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1070,9 +1070,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1084,9 +1084,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1098,9 +1098,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1112,9 +1112,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1126,9 +1126,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1140,9 +1140,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1154,9 +1154,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -4770,9 +4770,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4786,31 +4786,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, From 142db67ba699cb754e0ecba8d24ced642e6fdc72 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 06:49:19 -0800 Subject: [PATCH 25/31] fix: resolve markdownlint warnings in host-key-protocol.md --- DOCS/host-key-protocol.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/DOCS/host-key-protocol.md b/DOCS/host-key-protocol.md index 30568621..c891793d 100644 --- a/DOCS/host-key-protocol.md +++ b/DOCS/host-key-protocol.md @@ -15,7 +15,7 @@ All events use the `hostkey:` namespace prefix. Event names correspond to the co ### Server to Client Events | Event | Constant | Payload | Description | -|-------|----------|---------|-------------| +| ----- | -------- | ------- | ----------- | | `hostkey:verify` | `HOSTKEY_VERIFY` | `HostKeyVerifyPayload` | Server requests the client to verify an unknown host key | | `hostkey:verified` | `HOSTKEY_VERIFIED` | `HostKeyVerifiedPayload` | Key was verified successfully; connection proceeds | | `hostkey:mismatch` | `HOSTKEY_MISMATCH` | `HostKeyMismatchPayload` | Presented key does not match stored key; connection refused | @@ -25,7 +25,7 @@ All events use the `hostkey:` namespace prefix. Event names correspond to the co ### Client to Server Events | Event | Constant | Payload | Description | -|-------|----------|---------|-------------| +| ----- | -------- | ------- | ----------- | | `hostkey:verify-response` | `HOSTKEY_VERIFY_RESPONSE` | `HostKeyVerifyResponse` | Client's verification decision in response to `hostkey:verify` | ## Payload Schemas @@ -127,7 +127,7 @@ interface HostKeyVerifyResponse { **Action values:** | Value | Meaning | -|-------|---------| +| ----- | ------- | | `trusted` | The key was already in the client's local key store | | `accept` | The key was unknown but the user chose to trust it | | `reject` | The user declined to trust the key | @@ -349,7 +349,7 @@ flowchart TD When the server emits `hostkey:verify` and awaits a client response, a timeout governs how long the server will wait. | Parameter | Default | Description | -|-----------|---------|-------------| +| --------- | ------- | ----------- | | `timeout` | 30000 ms (30 seconds) | Maximum time to wait for `hostkey:verify-response` | **When the timeout fires:** @@ -386,7 +386,7 @@ The timeout is passed as the `timeout` option to `createHostKeyVerifier()`. It c ### Action Values Summary | Action | When to Send | Effect | -|--------|--------------|--------| +| ------ | ------------ | ------ | | `trusted` | Key is already in the client's local key store and matches | Connection proceeds | | `accept` | Key was unknown, user chose to trust it | Connection proceeds | | `reject` | Key was unknown or mismatched, user declined | Connection refused | @@ -399,7 +399,7 @@ All host key verification state is scoped to a single SSH connection attempt. On Fingerprints are SHA-256 hashes in `SHA256:` format, matching the convention used by OpenSSH. For example: -``` +```text SHA256:jMn3j6dsf7...base64... ``` @@ -422,13 +422,13 @@ If OpenSSH format is impractical, a JSON-based store keyed by `host:port:algorit ### Interactive vs Batch Mode | Mode | Behavior | -|------|----------| +| ---- | -------- | | **Interactive** | Prompt the user on `hostkey:verify`, display fingerprint, wait for yes/no | | **Batch** | Apply policy without user interaction; fail-closed by default | In interactive mode, display output similar to OpenSSH: -``` +```text The authenticity of host 'server1.example.com (192.168.1.10)' can't be established. ssh-ed25519 key fingerprint is SHA256:jMn3j6dsf7... Are you sure you want to continue connecting (yes/no)? @@ -437,7 +437,7 @@ Are you sure you want to continue connecting (yes/no)? ### Suggested CLI Flags | Flag | Description | Default | -|------|-------------|---------| +| ---- | ----------- | ------- | | `--known-hosts ` | Path to known_hosts file | `~/.ssh/known_hosts` | | `--accept-unknown` | Automatically accept unknown keys (respond `'accept'`); useful for scripting but insecure | Off | | `--fingerprint ` | Expect a specific fingerprint; respond `'trusted'` if it matches, `'reject'` otherwise | None | @@ -455,7 +455,7 @@ When no interactive terminal is available, the CLI should: ### Exit Codes | Code | Meaning | -|------|---------| +| ---- | ------- | | 0 | Connection succeeded | | 1 | General error | | 2 | Host key verification failed (mismatch or rejected) | @@ -464,7 +464,7 @@ When no interactive terminal is available, the CLI should: On `hostkey:mismatch`, the CLI should print a prominent warning to stderr and exit with code 2, similar to OpenSSH: -``` +```text @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ From 7ae7e6308ccd62045320b3653bc298e258ec71aa Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 07:34:27 -0800 Subject: [PATCH 26/31] feat: update webssh2_client dependency to version 3.4.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2269b59f..f0af3f07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "socket.io": "^4.8.1", "ssh2": "1.17", "validator": "^13.15.23", - "webssh2_client": "^3.3.0", + "webssh2_client": "^3.4.0", "zod": "^4.1.12" }, "bin": { @@ -5762,9 +5762,9 @@ } }, "node_modules/webssh2_client": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/webssh2_client/-/webssh2_client-3.3.0.tgz", - "integrity": "sha512-Fa6GA/f4shi/ykqiKHIi7W1thRXRfR1005oMho7o2OGWSdLcBMRjB5Ng5Q4CU/6d0AoJLKgA8PQoV5Txza8baw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webssh2_client/-/webssh2_client-3.4.0.tgz", + "integrity": "sha512-JPogMLdcO70vs464AbGSl4vmJaK0Y3avI4EW1LHrtM556e+Es1snYmAq2vQ2s61JL7OZ5mhSZNwd+jqQgNii4A==", "license": "MIT", "dependencies": { "@xterm/addon-search": "^0.16.0" diff --git a/package.json b/package.json index e57f23f9..d12caf4f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "socket.io": "^4.8.1", "ssh2": "1.17", "validator": "^13.15.23", - "webssh2_client": "^3.3.0", + "webssh2_client": "^3.4.0", "zod": "^4.1.12" }, "scripts": { From b33dc6dcc73aecdc9b5aa67561ae68fcd51185e0 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 07:44:44 -0800 Subject: [PATCH 27/31] fix: resolve SonarQube issues across codebase (S3735, S3776, S2871, S4623, S6557, S4043, S7763, S4325, S6594, S7781, S7755, S7784, S7924) Address 50 SonarQube issues spanning CRITICAL, MAJOR, and MINOR severities: - Remove unnecessary void operator from test describes (S3735, 12 issues) - Extract loadFileConfig helper to reduce cognitive complexity in config.ts (S3776) - Use String#endsWith instead of regex suffix checks in scripts (S6557) - Use toReversed() to avoid in-place array mutation (S4043) - Remove redundant undefined argument where default param exists (S4623) - Fix CSS contrast ratio for WCAG AA compliance (S7924) - Convert import-then-export to export...from syntax (S7763) - Remove unnecessary type assertions in test files (S4325) - Use RegExp.exec() instead of String.match() (S6594) - Use String#replaceAll() instead of replace with global regex (S7781) - Prefer .at(-1) over [.length - 1] indexing (S7755) - Use structuredClone() instead of JSON.parse(JSON.stringify()) (S7784) - Fix TypeScript index signature access and argument type safety (TS4111, TS2345) - Add structuredClone to ESLint globals for Node.js 22+ --- .gitignore | 4 +- app/auth/auth-pipeline.ts | 13 +--- app/config.ts | 72 ++++++++++--------- app/connection/ssh-validator.ts | 2 +- eslint.config.mjs | 1 + examples/sso-bigip-apm.html | 2 +- scripts/run-node-tests.mjs | 4 +- .../socket-negative-auth-exec.vitest.ts | 13 +--- .../e2e-term-size-replay-v2.spec.ts | 16 ++--- tests/playwright/v2-helpers.ts | 2 +- tests/test-utils.ts | 4 +- tests/types/index.ts | 2 +- tests/unit/config-enhanced.vitest.ts | 2 +- tests/unit/config/config-processor.vitest.ts | 10 +-- tests/unit/connection/ssh-validator.vitest.ts | 2 +- .../unit/routes/route-error-handler.vitest.ts | 6 +- tests/unit/socket/control-handler.vitest.ts | 2 +- .../socket/service-socket-adapter.vitest.ts | 2 +- tests/unit/types/result.vitest.ts | 2 +- tests/unit/utils-parse-env-vars.vitest.ts | 2 +- tests/unit/utils/object-merger.vitest.ts | 8 +-- 21 files changed, 83 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 3f13aea7..03a264b2 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ config.json.backup release-artifacts/ -data/ \ No newline at end of file +data/ + +DOCS/plans/* \ No newline at end of file diff --git a/app/auth/auth-pipeline.ts b/app/auth/auth-pipeline.ts index 5f23d817..27c55e73 100644 --- a/app/auth/auth-pipeline.ts +++ b/app/auth/auth-pipeline.ts @@ -10,21 +10,14 @@ import type { AuthSession } from './auth-utils.js' import { BasicAuthProvider, PostAuthProvider, - type AuthProvider as InternalAuthProvider, - type AuthMethod as InternalAuthMethod + type AuthProvider, + type AuthMethod, } from './providers/index.js' -type AuthProvider = InternalAuthProvider -type AuthMethod = InternalAuthMethod +export type { AuthProvider, AuthMethod } from './providers/index.js' const debug = createNamespacedDebug('auth-pipeline') -// Re-export types for backward compatibility -export type { - InternalAuthMethod as AuthMethod, - InternalAuthProvider as AuthProvider -} - type ExtendedRequest = IncomingMessage & { session?: AuthSession res?: unknown diff --git a/app/config.ts b/app/config.ts index afe8f76e..b8767167 100644 --- a/app/config.ts +++ b/app/config.ts @@ -2,7 +2,7 @@ // app/config.ts import { inspect } from 'node:util' -import { generateSecureSecret, enhanceConfig, err } from './utils/index.js' +import { generateSecureSecret, enhanceConfig, ok, err } from './utils/index.js' import { createNamespacedDebug } from './logger.js' import { ConfigError } from './errors.js' import type { Config, ConfigValidationError } from './types/config.js' @@ -28,6 +28,39 @@ const debug = createNamespacedDebug('config') // Session secret will be generated inside loadEnhancedConfig if needed +async function loadFileConfig( + resolution: ConfigFileResolution, + resolvedPath: string | undefined +): Promise | undefined, ConfigValidationError[]>> { + if (!resolution.exists) { + debug('No config file found at %s, using environment variables and defaults', resolvedPath) + return ok(undefined) + } + + const fileResult = await readConfigFile(resolution.location) + if (!fileResult.ok) { + const error = fileResult.error as { code?: string } + if (error.code === 'ENOENT') { + debug('Config file not found (expected):', resolvedPath) + return ok(undefined) + } + return err([{ + path: 'config.json', + message: `Failed to read config file: ${fileResult.error.message}`, + }]) + } + + const parseResult = parseConfigJson(fileResult.value) + if (!parseResult.ok) { + return err([{ + path: 'config.json', + message: `Failed to parse config JSON: ${parseResult.error.message}`, + }]) + } + + return ok(parseResult.value) +} + async function loadEnhancedConfig( resolution: ConfigFileResolution, sessionSecret?: string @@ -48,39 +81,12 @@ async function loadEnhancedConfig( const resolvedPath = configLocationToPath(resolution.location) // Load file config if a valid location exists - let fileConfig: Partial | undefined - if (resolution.exists) { - const fileResult = await readConfigFile(resolution.location) - if (fileResult.ok) { - const parseResult = parseConfigJson(fileResult.value) - if (parseResult.ok) { - fileConfig = parseResult.value - } else { - return err([{ - path: 'config.json', - message: `Failed to parse config JSON: ${parseResult.error.message}`, - }]) - } - } else { - // Check if it's just a missing file (ENOENT) - this is expected and not an error - const error = fileResult.error as { code?: string } - if (error.code === 'ENOENT') { - // Missing file is expected and not an error - debug('Config file not found (expected):', resolvedPath) - } else { - // Only treat non-ENOENT errors as actual errors - return err([{ - path: 'config.json', - message: `Failed to read config file: ${fileResult.error.message}`, - }]) - } - // File doesn't exist - this is fine, we'll use env vars and defaults - } - } else { - // No config file available, skip file loading - debug('No config file found at %s, using environment variables and defaults', resolvedPath) + const fileConfigResult = await loadFileConfig(resolution, resolvedPath) + if (!fileConfigResult.ok) { + return fileConfigResult } - + const fileConfig = fileConfigResult.value + // Load environment config const envConfig = mapEnvironmentVariables(process.env) diff --git a/app/connection/ssh-validator.ts b/app/connection/ssh-validator.ts index 36763961..66eb348f 100644 --- a/app/connection/ssh-validator.ts +++ b/app/connection/ssh-validator.ts @@ -122,7 +122,7 @@ const extractHostnameFromDnsError = (message: string): string => { } if (part.toUpperCase() !== 'ENOTFOUND') { // Sanitize hostname - only allow valid hostname characters - return part.replace(/[^a-zA-Z0-9.-]/g, '').slice(0, 253) + return part.replaceAll(/[^a-zA-Z0-9.-]/g, '').slice(0, 253) } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a3095d3..bfc4acef 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,6 +32,7 @@ export default [ }, globals: { ...nodePlugin.configs.recommended.globals, + structuredClone: 'readonly', }, }, plugins: { diff --git a/examples/sso-bigip-apm.html b/examples/sso-bigip-apm.html index 6f973b64..8499c8af 100644 --- a/examples/sso-bigip-apm.html +++ b/examples/sso-bigip-apm.html @@ -64,7 +64,7 @@ } button { - background: #007bff; + background: #0062cc; color: white; border: none; padding: 10px 20px; diff --git a/scripts/run-node-tests.mjs b/scripts/run-node-tests.mjs index a10e4d91..16c11d80 100644 --- a/scripts/run-node-tests.mjs +++ b/scripts/run-node-tests.mjs @@ -31,9 +31,9 @@ const skipNetwork = ['1', 'true', 'yes'].includes( String(process.env.WEBSSH2_SKIP_NETWORK || '').toLowerCase() ) if (skipNetwork) { - files = files.filter((f) => !/\/ssh\.test\.js$/.test(f)) + files = files.filter((f) => !f.endsWith('/ssh.test.js')) // Also skip HTTP route tests that bind/listen via supertest in restricted envs - files = files.filter((f) => !/\/post-auth\.test\.js$/.test(f)) + files = files.filter((f) => !f.endsWith('/post-auth.test.js')) } if (files.length === 0) { diff --git a/tests/contracts/socket-negative-auth-exec.vitest.ts b/tests/contracts/socket-negative-auth-exec.vitest.ts index 9923c2cc..1d796667 100644 --- a/tests/contracts/socket-negative-auth-exec.vitest.ts +++ b/tests/contracts/socket-negative-auth-exec.vitest.ts @@ -19,17 +19,10 @@ describe('Socket.IO Negative: authenticate + exec env', () => { let mockServices: unknown beforeEach(() => { - io = new EventEmitter() as EventEmitter & { on: ReturnType } - io.on = vi.fn(io.on.bind(io)) as ReturnType + io = new EventEmitter() + io.on = vi.fn(io.on.bind(io)) - mockSocket = new EventEmitter() as EventEmitter & { - id: string - request: { session: { save: ReturnType; sshCredentials: unknown; usedBasicAuth: boolean; envVars: unknown } } - emit: ReturnType - disconnect: ReturnType - onAny: ReturnType - offAny: ReturnType - } + mockSocket = new EventEmitter() mockSocket.id = 'neg-auth-exec' mockSocket.request = { session: { save: vi.fn((cb: () => void) => cb()), sshCredentials: null, usedBasicAuth: false, envVars: null }, diff --git a/tests/playwright/e2e-term-size-replay-v2.spec.ts b/tests/playwright/e2e-term-size-replay-v2.spec.ts index d87ed5bf..44f28fe8 100644 --- a/tests/playwright/e2e-term-size-replay-v2.spec.ts +++ b/tests/playwright/e2e-term-size-replay-v2.spec.ts @@ -5,7 +5,7 @@ import { test, expect, type Page, type Browser, type BrowserContext } from '@pla import { DEFAULTS } from '../../app/constants/index.js' import { SSH_PORT, USERNAME, PASSWORD, TIMEOUTS } from './constants.js' -const E2E_ENABLED = process.env.ENABLE_E2E_SSH === '1' +const E2E_ENABLED = process.env['ENABLE_E2E_SSH'] === '1' // V2-specific helpers async function waitForV2Terminal(page: Page, timeout = TIMEOUTS.CONNECTION): Promise { @@ -55,12 +55,12 @@ async function executeV2Command(page: Page, command: string): Promise { await page.waitForTimeout(TIMEOUTS.SHORT_WAIT) } -async function openV2WithBasicAuth(browser: Browser, baseURL: string, params: string): Promise<{ page: Page; context: BrowserContext }> { +async function openV2WithBasicAuth(browser: Browser, baseURL: string | undefined, params: string): Promise<{ page: Page; context: BrowserContext }> { const context = await browser.newContext({ httpCredentials: { username: USERNAME, password: PASSWORD }, }) const page = await context.newPage() - await page.goto(`${baseURL}/ssh/host/localhost?port=${SSH_PORT}&${params}`) + await page.goto(`${baseURL ?? ''}/ssh/host/localhost?port=${SSH_PORT}&${params}`) return { page, context } } @@ -116,8 +116,8 @@ test.describe('V2 E2E: TERM, size, and replay credentials', () => { // Find the match that looks like terminal dimensions (not timestamps or other numbers) const sttyMatch = matches.find((m) => { - const r = Number.parseInt(m[1]) - const c = Number.parseInt(m[2]) + const r = Number.parseInt(m[1] ?? '0') + const c = Number.parseInt(m[2] ?? '0') // Terminal dimensions should be reasonable return r > 0 && r < 500 && c > 0 && c < 500 }) @@ -126,7 +126,7 @@ test.describe('V2 E2E: TERM, size, and replay credentials', () => { if (sttyMatch === undefined) { // Fallback to any number pair - const match = out.match(/\b(\d+)\s+(\d+)\b/) + const match = /\b(\d+)\s+(\d+)\b/.exec(out) expect(match).toBeTruthy() if (match === null) { throw new Error(`Expected fallback dimensions in terminal output: ${out.slice(0, 500)}`) @@ -185,7 +185,7 @@ test.describe('V2 E2E: TERM, size, and replay credentials', () => { // Extract size from output const sizeMatches: RegExpMatchArray[] = [...initialOut.matchAll(/\b(\d+)\s+(\d+)\b/g)] - const initialSizeMatch: RegExpMatchArray | undefined = sizeMatches[sizeMatches.length - 1] + const initialSizeMatch: RegExpMatchArray | undefined = sizeMatches.at(-1) if (initialSizeMatch === undefined) { throw new Error(`No initial size found in terminal output: ${initialOut.slice(0, 500)}`) @@ -210,7 +210,7 @@ test.describe('V2 E2E: TERM, size, and replay credentials', () => { const newSizeMatches: RegExpMatchArray[] = [...newOut.matchAll(/\b(\d+)\s+(\d+)\b/g)] // The last match should be our new size - const lastSizeMatch: RegExpMatchArray | undefined = newSizeMatches[newSizeMatches.length - 1] + const lastSizeMatch: RegExpMatchArray | undefined = newSizeMatches.at(-1) if (lastSizeMatch === undefined) { throw new Error(`No size found after resize. Terminal output: ${newOut.slice(0, 500)}`) diff --git a/tests/playwright/v2-helpers.ts b/tests/playwright/v2-helpers.ts index 7420f075..62f03478 100644 --- a/tests/playwright/v2-helpers.ts +++ b/tests/playwright/v2-helpers.ts @@ -312,7 +312,7 @@ export async function executeCommandList(page: Page, commands: string[]): Promis // Wait for command to complete - check for echo output if (command.startsWith('echo ')) { - const expectedOutput = command.match(/"([^"]+)"/)?.[1] + const expectedOutput = /"([^"]+)"/.exec(command)?.[1] if (expectedOutput !== undefined && expectedOutput !== '') { await waitForCommandOutput(page, expectedOutput, TIMEOUTS.SHORT_WAIT * 2) } diff --git a/tests/test-utils.ts b/tests/test-utils.ts index cab1ef17..c91abacc 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -28,7 +28,7 @@ import { ok, err, isErr } from '../app/utils/result.js' import { createAuthMethod } from '../app/types/branded.js' // Re-export Result utility functions for test use -export { ok, err, isErr } +export { ok, err, isErr } from '../app/utils/result.js' export interface StructuredLoggerStub extends StructuredLogger { readonly entries: Array<{ level: LogLevel; entry: Omit }> @@ -342,7 +342,7 @@ export function setupMockStoreStates(mockStore: SessionStore, ...states: unknown mockObj.mockReturnValueOnce(state) } if (states.length > 0) { - mockObj.mockReturnValue(states[states.length - 1]) + mockObj.mockReturnValue(states.at(-1)) } return mockStore } diff --git a/tests/types/index.ts b/tests/types/index.ts index 401cc825..051a47cc 100644 --- a/tests/types/index.ts +++ b/tests/types/index.ts @@ -201,7 +201,7 @@ export class TestCleanup { } async runAll(): Promise { - for (const fn of this.cleanupFns.reverse()) { + for (const fn of this.cleanupFns.toReversed()) { await fn() } this.cleanupFns = [] diff --git a/tests/unit/config-enhanced.vitest.ts b/tests/unit/config-enhanced.vitest.ts index 9b4c68f3..31a39c90 100644 --- a/tests/unit/config-enhanced.vitest.ts +++ b/tests/unit/config-enhanced.vitest.ts @@ -356,7 +356,7 @@ describe('Enhanced Config - Validation Functions', () => { it('should validate SSH ports correctly', () => { expect(validateSshPort(22)).toBe(22) expect(validateSshPort(2222)).toBe(2222) - expect(validateSshPort(undefined)).toBe(22) // default + expect(validateSshPort()).toBe(22) // default expect(() => validateSshPort(0)).toThrow('Invalid SSH port') expect(() => validateSshPort(65536)).toThrow('Invalid SSH port') expect(() => validateSshPort(3.14)).toThrow('Invalid SSH port') diff --git a/tests/unit/config/config-processor.vitest.ts b/tests/unit/config/config-processor.vitest.ts index c8bfa7e5..3a103812 100644 --- a/tests/unit/config/config-processor.vitest.ts +++ b/tests/unit/config/config-processor.vitest.ts @@ -13,7 +13,7 @@ import type { Config } from '../../../app/types/config.js' import { AUTH_METHOD_TOKENS, DEFAULT_AUTH_METHODS } from '../../../app/constants/index.js' import { TEST_SECRET_123, TEST_PASSWORDS, TEST_IPS, TEST_SECRET } from '../../test-constants.js' -void describe('createDefaultConfig', () => { +describe('createDefaultConfig', () => { it('should create default config with auto-generated session secret', () => { const config = createDefaultConfig() @@ -52,7 +52,7 @@ void describe('createDefaultConfig', () => { }) }) -void describe('mergeConfigs', () => { +describe('mergeConfigs', () => { it('should return default config when no overrides provided', () => { const defaultConfig = createDefaultConfig(TEST_PASSWORDS.secret) @@ -105,7 +105,7 @@ void describe('mergeConfigs', () => { }) }) -void describe('processConfig', () => { +describe('processConfig', () => { it('should return ok result for valid configuration', () => { const defaultConfig = createDefaultConfig(TEST_SECRET) @@ -147,7 +147,7 @@ void describe('processConfig', () => { }) }) -void describe('parseConfigJson', () => { +describe('parseConfigJson', () => { it('should parse valid JSON', () => { const json = '{"listen": {"port": 3000}, "ssh": {"host": "example.com"}}' @@ -186,7 +186,7 @@ void describe('parseConfigJson', () => { }) }) -void describe('createCorsConfig', () => { +describe('createCorsConfig', () => { it('should create CORS config from application config', () => { const config = createDefaultConfig() config.http.origins = ['http://localhost:3000', 'https://example.com'] diff --git a/tests/unit/connection/ssh-validator.vitest.ts b/tests/unit/connection/ssh-validator.vitest.ts index 55046a05..163087a3 100644 --- a/tests/unit/connection/ssh-validator.vitest.ts +++ b/tests/unit/connection/ssh-validator.vitest.ts @@ -226,7 +226,7 @@ describe('enhanceErrorMessage', () => { // Should truncate to 253 characters (max DNS label length) expect(result).toContain('DNS resolution failed') - const match = result.match(/DNS resolution failed for '([^']+)'/) + const match = /DNS resolution failed for '([^']+)'/.exec(result) expect(match).not.toBe(null) if (match !== null) { expect(match[1]?.length).toBeLessThanOrEqual(253) diff --git a/tests/unit/routes/route-error-handler.vitest.ts b/tests/unit/routes/route-error-handler.vitest.ts index 5a53cf7b..db6b7097 100644 --- a/tests/unit/routes/route-error-handler.vitest.ts +++ b/tests/unit/routes/route-error-handler.vitest.ts @@ -10,7 +10,7 @@ import { } from '../../../app/routes/route-error-handler.js' import { HTTP } from '../../../app/constants/index.js' -void describe('createSshValidationErrorResponse', () => { +describe('createSshValidationErrorResponse', () => { it('returns 401 with auth header for auth errors', () => { const result: SshValidationResult = { errorType: 'auth', @@ -79,7 +79,7 @@ void describe('createSshValidationErrorResponse', () => { }) }) -void describe('createRouteErrorMessage', () => { +describe('createRouteErrorMessage', () => { it('formats error message correctly', () => { const error = new Error('Database connection failed') @@ -98,7 +98,7 @@ void describe('createRouteErrorMessage', () => { }) }) -void describe('getErrorStatusCode', () => { +describe('getErrorStatusCode', () => { it('returns 400 for required field errors', () => { expect(getErrorStatusCode(new Error('Field is required'))).toBe(400) expect(getErrorStatusCode(new Error('required parameter missing'))).toBe(400) diff --git a/tests/unit/socket/control-handler.vitest.ts b/tests/unit/socket/control-handler.vitest.ts index 99074b63..4948560a 100644 --- a/tests/unit/socket/control-handler.vitest.ts +++ b/tests/unit/socket/control-handler.vitest.ts @@ -87,7 +87,7 @@ function createTestConfig(options: Partial = {}): Config { } function createShellStream(): EventEmitter & { write: ReturnType } { - const stream = new EventEmitter() as EventEmitter & { write: ReturnType } + const stream = new EventEmitter() stream.write = vi.fn() return stream } diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index 120f9319..bd240b59 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -180,7 +180,7 @@ const createConfig = (): Config => ({ session: 'x-session' } } -} as Config) +}) type TestSocket = Socket diff --git a/tests/unit/types/result.vitest.ts b/tests/unit/types/result.vitest.ts index d49b25ef..ab98c262 100644 --- a/tests/unit/types/result.vitest.ts +++ b/tests/unit/types/result.vitest.ts @@ -21,7 +21,7 @@ import { } from '../../test-utils.js' import type { Result } from '../../../app/types/result.js' -void describe('Result type', () => { +describe('Result type', () => { describe('ok and err constructors', () => { it('creates success result', () => { const result = ok(42) diff --git a/tests/unit/utils-parse-env-vars.vitest.ts b/tests/unit/utils-parse-env-vars.vitest.ts index b1a8931b..973319f1 100644 --- a/tests/unit/utils-parse-env-vars.vitest.ts +++ b/tests/unit/utils-parse-env-vars.vitest.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { parseEnvVars } from '../../app/validation/index.js' -void describe('parseEnvVars', () => { +describe('parseEnvVars', () => { it('parses valid pairs', () => { const result: Record | null = parseEnvVars('FOO:bar,BAR:baz') expect(result).toEqual({ FOO: 'bar', BAR: 'baz' }) diff --git a/tests/unit/utils/object-merger.vitest.ts b/tests/unit/utils/object-merger.vitest.ts index 2ca6e975..b7ad4db2 100644 --- a/tests/unit/utils/object-merger.vitest.ts +++ b/tests/unit/utils/object-merger.vitest.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest' import { isPlainObject, deepMergePure } from '../../../app/utils/object-merger.js' -void describe('isPlainObject', () => { +describe('isPlainObject', () => { it('should return true for plain objects', () => { expect(isPlainObject({})).toBe(true) expect(isPlainObject({ a: 1 })).toBe(true) @@ -25,7 +25,7 @@ void describe('isPlainObject', () => { }) }) -void describe('deepMergePure', () => { +describe('deepMergePure', () => { it('should merge flat objects', () => { const target = { a: 1, b: 2 } const source = { b: 3, c: 4 } @@ -67,8 +67,8 @@ void describe('deepMergePure', () => { it('should not mutate the original objects', () => { const target = { a: 1, nested: { x: 10 } } const source = { nested: { y: 20 } } - const originalTarget = JSON.parse(JSON.stringify(target)) as typeof target - const originalSource = JSON.parse(JSON.stringify(source)) as typeof source + const originalTarget = structuredClone(target) + const originalSource = structuredClone(source) const result = deepMergePure(target, source) From 3bec992c4aec97afa0a9b2ba2d9d6ee36b748f52 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 09:31:13 -0800 Subject: [PATCH 28/31] fix: remove void operator from test files (S3735) --- tests/unit/config/host-key-config.vitest.ts | 2 +- .../unit/services/host-key/host-key-service.vitest.ts | 10 +++++----- tests/unit/services/host-key/host-key-store.vitest.ts | 10 +++++----- tests/unit/socket/service-socket-adapter.vitest.ts | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/unit/config/host-key-config.vitest.ts b/tests/unit/config/host-key-config.vitest.ts index 62846c64..717fbb5c 100644 --- a/tests/unit/config/host-key-config.vitest.ts +++ b/tests/unit/config/host-key-config.vitest.ts @@ -26,7 +26,7 @@ function buildHostKeyConfig( } } -void describe('resolveHostKeyMode', () => { +describe('resolveHostKeyMode', () => { it('should set serverStore=true, clientStore=false for mode "server"', () => { const config = buildHostKeyConfig({ mode: 'server' }) const result = resolveHostKeyMode(config) diff --git a/tests/unit/services/host-key/host-key-service.vitest.ts b/tests/unit/services/host-key/host-key-service.vitest.ts index b22e2b29..433484b5 100644 --- a/tests/unit/services/host-key/host-key-service.vitest.ts +++ b/tests/unit/services/host-key/host-key-service.vitest.ts @@ -41,7 +41,7 @@ function seedTestDb(dbPath: string): void { db.close() } -void describe('HostKeyService', () => { +describe('HostKeyService', () => { let ctx: TestContext beforeEach(() => { @@ -52,7 +52,7 @@ void describe('HostKeyService', () => { cleanupTempDbContext(ctx) }) - void describe('getters', () => { + describe('getters', () => { it('should expose isEnabled', () => { const svc = new HostKeyService(buildConfig({ enabled: true })) expect(svc.isEnabled).toBe(true) @@ -88,7 +88,7 @@ void describe('HostKeyService', () => { }) }) - void describe('serverLookup', () => { + describe('serverLookup', () => { it('should delegate to the underlying store', () => { seedTestDb(ctx.dbPath) const config = buildConfig({ @@ -115,7 +115,7 @@ void describe('HostKeyService', () => { }) }) - void describe('computeFingerprint', () => { + describe('computeFingerprint', () => { it('should produce a SHA256: prefixed fingerprint', () => { const fingerprint = HostKeyService.computeFingerprint(TEST_KEY_ED25519) @@ -147,7 +147,7 @@ void describe('HostKeyService', () => { }) }) - void describe('close', () => { + describe('close', () => { it('should close the underlying store', () => { seedTestDb(ctx.dbPath) const config = buildConfig({ diff --git a/tests/unit/services/host-key/host-key-store.vitest.ts b/tests/unit/services/host-key/host-key-store.vitest.ts index 8f59ddb0..5069fd7e 100644 --- a/tests/unit/services/host-key/host-key-store.vitest.ts +++ b/tests/unit/services/host-key/host-key-store.vitest.ts @@ -37,7 +37,7 @@ function seedTestDb(dbPath: string): void { db.close() } -void describe('HostKeyStore', () => { +describe('HostKeyStore', () => { let ctx: TestContext beforeEach(() => { @@ -48,7 +48,7 @@ void describe('HostKeyStore', () => { cleanupTempDbContext(ctx) }) - void describe('constructor', () => { + describe('constructor', () => { it('should open existing database file', () => { createTestDb(ctx.dbPath) const store = new HostKeyStore(ctx.dbPath) @@ -65,7 +65,7 @@ void describe('HostKeyStore', () => { }) }) - void describe('lookup', () => { + describe('lookup', () => { it('should return "trusted" when key matches stored key', () => { seedTestDb(ctx.dbPath) const store = new HostKeyStore(ctx.dbPath) @@ -155,7 +155,7 @@ void describe('HostKeyStore', () => { }) }) - void describe('getAll', () => { + describe('getAll', () => { it('should return all keys for a host/port', () => { seedTestDb(ctx.dbPath) const store = new HostKeyStore(ctx.dbPath) @@ -188,7 +188,7 @@ void describe('HostKeyStore', () => { }) }) - void describe('close', () => { + describe('close', () => { it('should close the database', () => { createTestDb(ctx.dbPath) const store = new HostKeyStore(ctx.dbPath) diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index bd240b59..22517746 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -253,7 +253,7 @@ describe('ServiceSocketAdapter', () => { const config = createConfig() const services = {} as Services - void new ServiceSocketAdapter(socket, config, services) + const _adapter = new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { @@ -272,7 +272,7 @@ describe('ServiceSocketAdapter', () => { config.ssh.hostKeyVerification.enabled = true const services = {} as Services - void new ServiceSocketAdapter(socket, config, services) + const _adapter = new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { From a6163d6163fed778dc736d07e04deaa1c5f73bcb Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 09:34:06 -0800 Subject: [PATCH 29/31] fix: reduce awaitClientVerification params to options object (S107) and use structuredClone (S7784) --- app/services/host-key/host-key-verifier.ts | 37 +++++++++++---------- tests/unit/config/host-key-config.vitest.ts | 2 +- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/app/services/host-key/host-key-verifier.ts b/app/services/host-key/host-key-verifier.ts index 2a496d7a..ade196bf 100644 --- a/app/services/host-key/host-key-verifier.ts +++ b/app/services/host-key/host-key-verifier.ts @@ -183,9 +183,9 @@ export function createHostKeyVerifier( // Step 3: Client store lookup if (hostKeyService.clientStoreEnabled) { - awaitClientVerification( - socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify - ) + awaitClientVerification({ + socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify, + }) return } @@ -220,27 +220,30 @@ export function createHostKeyVerifier( // action === 'prompt' log('Unknown key action: prompt') - awaitClientVerification( - socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify - ) + awaitClientVerification({ + socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify, + }) } } +interface ClientVerificationOptions { + socket: Socket + host: string + port: number + algorithm: string + base64Key: string + fingerprint: string + log: (...args: unknown[]) => void + timeout: number + verify: (valid: boolean) => void +} + /** * Emit a verify event to the client and wait for their response * with a configurable timeout. */ -function awaitClientVerification( - socket: Socket, - host: string, - port: number, - algorithm: string, - base64Key: string, - fingerprint: string, - log: (...args: unknown[]) => void, - timeout: number, - verify: (valid: boolean) => void -): void { +function awaitClientVerification(options: ClientVerificationOptions): void { + const { socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify } = options const verifyPayload: HostKeyVerifyPayload = { host, port, diff --git a/tests/unit/config/host-key-config.vitest.ts b/tests/unit/config/host-key-config.vitest.ts index 717fbb5c..3f7b6eb3 100644 --- a/tests/unit/config/host-key-config.vitest.ts +++ b/tests/unit/config/host-key-config.vitest.ts @@ -110,7 +110,7 @@ describe('resolveHostKeyMode', () => { it('should not mutate the input config', () => { const config = buildHostKeyConfig({ mode: 'server' }) - const original = JSON.parse(JSON.stringify(config)) as typeof config + const original = structuredClone(config) resolveHostKeyMode(config) expect(config).toEqual(original) From bdd7991aba42b8b3dd1cad4365b0b2dd578dd933 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 09:42:02 -0800 Subject: [PATCH 30/31] fix: use eslint-disable for no-new in side-effect constructors --- tests/unit/socket/service-socket-adapter.vitest.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index 22517746..b248b49d 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -253,7 +253,8 @@ describe('ServiceSocketAdapter', () => { const config = createConfig() const services = {} as Services - const _adapter = new ServiceSocketAdapter(socket, config, services) + // eslint-disable-next-line no-new -- constructor called for side effects (emits events) + new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { @@ -272,7 +273,8 @@ describe('ServiceSocketAdapter', () => { config.ssh.hostKeyVerification.enabled = true const services = {} as Services - const _adapter = new ServiceSocketAdapter(socket, config, services) + // eslint-disable-next-line no-new -- constructor called for side effects (emits events) + new ServiceSocketAdapter(socket, config, services) expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { From 226f18198714009e71b1797d864c6435411614af Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 09:51:06 -0800 Subject: [PATCH 31/31] chore: update emitSocketLog type usage and adjust mock parameters --- .../socket/service-socket-adapter.vitest.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/unit/socket/service-socket-adapter.vitest.ts b/tests/unit/socket/service-socket-adapter.vitest.ts index b248b49d..8d2bbfec 100644 --- a/tests/unit/socket/service-socket-adapter.vitest.ts +++ b/tests/unit/socket/service-socket-adapter.vitest.ts @@ -10,9 +10,7 @@ import type { InterServerEvents, SocketData } from '../../../app/types/contracts/v1/socket.js' -import type { LogLevel } from '../../../app/logging/levels.js' -import type { LogEventName } from '../../../app/logging/event-catalog.js' -import type { SocketLogOptions } from '../../../app/logging/socket-logger.js' +import type { emitSocketLog } from '../../../app/logging/socket-logger.js' import { TEST_NETWORK, TEST_SECRET, @@ -22,15 +20,9 @@ import { import { DEFAULT_AUTH_METHODS } from '../../../app/constants/index.js' import { createAuthMethod } from '../../../app/types/branded.js' -type EmitSocketLogArgs = [ - AdapterContext, - LogLevel, - LogEventName, - string, - SocketLogOptions | undefined -] +type EmitSocketLogFn = typeof emitSocketLog -const emitSocketLogMock = vi.fn() +const emitSocketLogMock = vi.fn() const { REMOTE_PASSWORD_HEADER, @@ -49,7 +41,7 @@ const ensureSocket = (context: AdapterContext): Socket< } vi.mock('../../../app/logging/socket-logger.js', () => ({ - emitSocketLog: (...args: EmitSocketLogArgs) => { + emitSocketLog: (...args: Parameters) => { emitSocketLogMock(...args) } })) @@ -161,6 +153,7 @@ const createConfig = (): Config => ({ background: '#000000' }, options: { + challengeButton: false, allowReplay: true, allowReauth: true, allowReconnect: true, @@ -197,11 +190,11 @@ const createSocket = (): TestSocket => { handshake: { headers, address: TEST_NETWORK.HANDSHAKE_IP - }, + } as unknown as TestSocket['handshake'], request: { headers, session: { [SESSION_CREDENTIALS_KEY]: { passwordSource: PASSWORD_SOURCE_NONE } } - }, + } as unknown as TestSocket['request'], on: vi.fn(), onAny: vi.fn(), emit: vi.fn() @@ -227,7 +220,7 @@ describe('ServiceSocketAdapter', () => { expect(adapter).toBeInstanceOf(ServiceSocketAdapter) expect(emitSocketLogMock).toHaveBeenCalled() - const callArgs: EmitSocketLogArgs = emitSocketLogMock.mock.calls[0] + const callArgs: Parameters = emitSocketLogMock.mock.calls[0] const [context, level, event, message, options] = callArgs expect(level).toBe('info') @@ -254,7 +247,7 @@ describe('ServiceSocketAdapter', () => { const services = {} as Services // eslint-disable-next-line no-new -- constructor called for side effects (emits events) - new ServiceSocketAdapter(socket, config, services) + new ServiceSocketAdapter(socket, config, services) //NOSONAR expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: { @@ -274,7 +267,7 @@ describe('ServiceSocketAdapter', () => { const services = {} as Services // eslint-disable-next-line no-new -- constructor called for side effects (emits events) - new ServiceSocketAdapter(socket, config, services) + new ServiceSocketAdapter(socket, config, services) //NOSONAR expect(socket.emit).toHaveBeenCalledWith('permissions', { hostKeyVerification: {