diff --git a/.gitignore b/.gitignore index 1eaa662f1..03a264b24 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ config.json.backup release-artifacts/ +data/ + +DOCS/plans/* \ No newline at end of file diff --git a/DOCS/configuration/CONFIG-JSON.md b/DOCS/configuration/CONFIG-JSON.md index 648acaf0b..55d9497c7 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 0c0ed0824..4d8c232e1 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 b2ef3dcf2..0fe5a7f31 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 | diff --git a/DOCS/host-key-protocol.md b/DOCS/host-key-protocol.md new file mode 100644 index 000000000..c891793d2 --- /dev/null +++ b/DOCS/host-key-protocol.md @@ -0,0 +1,475 @@ +# 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) + +```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 + +```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') + +```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') + +```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') + +```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. + +```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 + +Server store is checked first. If the key is unknown on the server, the client is consulted. + +#### Server Found (Trusted) + +```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 + +```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 + +```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()`. + +```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:** + +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: + +```text +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: + +```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)? +``` + +### 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: + +```text +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +@ 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. +``` diff --git a/README.md b/README.md index 6c484b81a..7b88e8ad4 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. diff --git a/SECURITY.md b/SECURITY.md index e207c83d1..086deb067 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/app/auth/auth-pipeline.ts b/app/auth/auth-pipeline.ts index 5f23d8175..27c55e736 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 afe8f76ee..b8767167a 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/config/config-processor.ts b/app/config/config-processor.ts index 72c1b60c0..8ceeeb588 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 exhaustiveCheck: never = config.mode + throw new Error(`Unknown host key verification mode: ${String(exhaustiveCheck)}`) + } + } + + // 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/app/config/default-config.ts b/app/config/default-config.ts index 96f8910fc..f13dfd2e2 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' }, diff --git a/app/connection/ssh-validator.ts b/app/connection/ssh-validator.ts index 367639612..66eb348fd 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/app/constants/socket-events.ts b/app/constants/socket-events.ts index b8c3bdebd..bcbcca919 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 diff --git a/app/routes/handlers/ssh-config-handler.ts b/app/routes/handlers/ssh-config-handler.ts index ca4549267..d4ae90613 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/schemas/config-schema.ts b/app/schemas/config-schema.ts index 0d34b5b67..c21105b6f 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 0fcf7e972..bf8516978 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') @@ -92,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) @@ -118,7 +124,11 @@ export function createServices( ssh, terminal, session, - sftp + sftp, + } + + if (hostKey !== undefined) { + services.hostKey = hostKey } factoryLogger('Services created successfully') 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 000000000..6acb9d4f3 --- /dev/null +++ b/app/services/host-key/host-key-service.ts @@ -0,0 +1,88 @@ +// 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, 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/app/services/host-key/host-key-store.ts b/app/services/host-key/host-key-store.ts new file mode 100644 index 000000000..1e96a7461 --- /dev/null +++ b/app/services/host-key/host-key-store.ts @@ -0,0 +1,128 @@ +// app/services/host-key/host-key-store.ts +// SQLite-backed read-only host key store + +import Database, { type Database as BetterSqlite3Database } 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: BetterSqlite3Database | 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/app/services/host-key/host-key-verifier.ts b/app/services/host-key/host-key-verifier.ts new file mode 100644 index 000000000..ade196bff --- /dev/null +++ b/app/services/host-key/host-key-verifier.ts @@ -0,0 +1,313 @@ +// app/services/host-key/host-key-verifier.ts +// 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' + +/** + * 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 + +/** + * 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 -> verify(true) + * 2. Server store lookup: + * - 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, verify(true) + * - reject -> verify(false) + * - timeout -> verify(false) + * 4. Neither store has key -> apply unknownKeyAction: + * - alert -> emit alert, verify(true) + * - reject -> emit rejected, verify(false) + * - prompt -> emit verify, await client response + */ +export function createHostKeyVerifier( + options: CreateHostKeyVerifierOptions +): HostVerifier { + const { + hostKeyService, + socket, + host, + port, + log, + timeout = DEFAULT_TIMEOUT, + } = options + + return (key: Buffer, verify: (valid: boolean) => void): void => { + // Step 1: Feature disabled + if (!hostKeyService.isEnabled) { + verify(true) + return + } + + const algorithm = extractAlgorithm(key) + 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) + verify(true) + return + } + + if (lookupResult.status === 'mismatch') { + log('Host key MISMATCH detected by server store') + const storedFingerprint = lookupResult.storedKey === undefined + ? 'unknown' + : HostKeyService.computeFingerprint(lookupResult.storedKey) + const payload: HostKeyMismatchPayload = { + host, + port, + algorithm, + presentedFingerprint: fingerprint, + storedFingerprint, + source: 'server', + } + socket.emit(SOCKET_EVENTS.HOSTKEY_MISMATCH, payload) + verify(false) + return + } + + // status === 'unknown', fall through + log('Host key unknown in server store, checking client store') + } + + // Step 3: Client store lookup + if (hostKeyService.clientStoreEnabled) { + awaitClientVerification({ + socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify, + }) + return + } + + // 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) + verify(true) + return + } + + if (action === 'reject') { + log('Unknown key action: reject') + const payload: HostKeyRejectedPayload = { + host, + port, + algorithm, + fingerprint, + } + socket.emit(SOCKET_EVENTS.HOSTKEY_REJECTED, payload) + verify(false) + return + } + + // action === 'prompt' + log('Unknown key action: prompt') + 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(options: ClientVerificationOptions): void { + const { socket, host, port, algorithm, base64Key, fingerprint, log, timeout, verify } = options + const verifyPayload: HostKeyVerifyPayload = { + host, + port, + algorithm, + fingerprint, + key: base64Key, + } + + const cleanup = (): void => { + clearTimeout(timer) + socket.removeListener(SOCKET_EVENTS.HOSTKEY_VERIFY_RESPONSE, handler) + socket.removeListener('disconnect', onDisconnect) + } + + const handler = (response: unknown): void => { + cleanup() + + // 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, + port, + algorithm, + fingerprint, + source: 'client', + } + socket.emit(SOCKET_EVENTS.HOSTKEY_VERIFIED, verifiedPayload) + verify(true) + return + } + + // 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 dc29a7e48..3f527e179 100644 --- a/app/services/interfaces.ts +++ b/app/services/interfaces.ts @@ -8,8 +8,10 @@ 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' /** * Credentials for authentication @@ -95,6 +97,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?: SocketIoSocket } /** @@ -325,6 +333,7 @@ export interface Services { terminal: TerminalService session: SessionService sftp?: FileService + hostKey?: HostKeyService } /** diff --git a/app/services/ssh/ssh-service.ts b/app/services/ssh/ssh-service.ts index 881478f64..54f448337 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' @@ -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?: 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: 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/app/socket/adapters/service-socket-adapter.ts b/app/socket/adapters/service-socket-adapter.ts index abc7ea923..942b26868 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/socket/adapters/service-socket-authentication.ts b/app/socket/adapters/service-socket-authentication.ts index d042ed830..3686625c0 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/socket/adapters/socket-adapter.ts b/app/socket/adapters/socket-adapter.ts index aae4ff7a6..ef49919b0 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, + }, }) } diff --git a/app/types/config.ts b/app/types/config.ts index 143ec631f..5413be63c 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 } /** diff --git a/app/types/contracts/v1/http.ts b/app/types/contracts/v1/http.ts index ce41c1e3d..a0f9be66e 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 9dd90872d..1c38e22c5 100644 --- a/app/types/contracts/v1/socket.ts +++ b/app/types/contracts/v1/socket.ts @@ -244,12 +244,17 @@ 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 + unknownKeyAction: 'prompt' | 'alert' | 'reject' + } }) => void // UI updates (element + value) updateUI: (payload: { element: string; value: unknown }) => void diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a3095d3b..bfc4acefe 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 6f973b64d..8499c8af6 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/package-lock.json b/package-lock.json index 3413629be..f0af3f075 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", @@ -19,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": { @@ -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", @@ -817,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" ], @@ -831,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" ], @@ -845,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" ], @@ -859,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" ], @@ -873,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" ], @@ -887,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" ], @@ -901,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" ], @@ -915,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" ], @@ -929,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" ], @@ -943,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" ], @@ -957,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" ], @@ -971,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" ], @@ -985,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" ], @@ -999,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" ], @@ -1013,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" ], @@ -1027,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" ], @@ -1041,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" ], @@ -1055,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" ], @@ -1068,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" ], @@ -1082,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" ], @@ -1096,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" ], @@ -1110,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" ], @@ -1124,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" ], @@ -1138,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" ], @@ -1152,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" ], @@ -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", @@ -4459,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": { @@ -4475,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" } }, @@ -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", @@ -5332,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 461098896..d12caf4fc 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", @@ -47,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": { @@ -72,12 +73,14 @@ "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", "@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", diff --git a/scripts/host-key-seed.ts b/scripts/host-key-seed.ts new file mode 100644 index 000000000..7804619b3 --- /dev/null +++ b/scripts/host-key-seed.ts @@ -0,0 +1,607 @@ +// 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 + 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 + 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. + */ +export 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 +} + +export function resolveDbPath(explicitPath: string | undefined): string { + if (explicitPath !== undefined) { + 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)) { + 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 +// --------------------------------------------------------------------------- + +export 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 +} + +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 + 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 +} + +// 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/scripts/run-node-tests.mjs b/scripts/run-node-tests.mjs index a10e4d91b..16c11d80a 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/routes-stack.vitest.ts b/tests/contracts/routes-stack.vitest.ts index 1eb84f250..c67b83986 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 6fbb1adc1..11126aa18 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]) + 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'].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/contracts/socket-negative-auth-exec.vitest.ts b/tests/contracts/socket-negative-auth-exec.vitest.ts index 9923c2cca..1d7966674 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/integration/host-key-verification.vitest.ts b/tests/integration/host-key-verification.vitest.ts new file mode 100644 index 000000000..b0a9399fc --- /dev/null +++ b/tests/integration/host-key-verification.vitest.ts @@ -0,0 +1,296 @@ +// 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, 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, + 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) + }) + }) +} + +/** + * 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) + 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 | undefined + clientStoreEnabled?: boolean | undefined + dbPath?: string | undefined +}): 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 }) + }) + + 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) + + 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 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) + + 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 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 { result, algorithm, service } = await runUnknownKeyScenario('reject') + + 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 { result, algorithm, service } = await runUnknownKeyScenario('alert') + + 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') + + const config = buildConfig({ + enabled: false, + serverStoreEnabled: false, + clientStoreEnabled: false, + }) + 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) + + expect(result).toBe(true) + expect(socket.emit).not.toHaveBeenCalled() + + service.close() + }) +}) diff --git a/tests/playwright/e2e-term-size-replay-v2.spec.ts b/tests/playwright/e2e-term-size-replay-v2.spec.ts index d87ed5bff..44f28fe89 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 7420f075b..62f034782 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 b5e39e3b1..c91abacc7 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 } @@ -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: ':memory:' }, + clientStore: { enabled: false }, + }, ...overrides.ssh, }, options: { diff --git a/tests/types/index.ts b/tests/types/index.ts index 401cc825e..051a47cc8 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 9b4c68f39..31a39c908 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 c8bfa7e5b..3a1038122 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/config/host-key-config.vitest.ts b/tests/unit/config/host-key-config.vitest.ts new file mode 100644 index 000000000..3f7b6eb39 --- /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, + } +} + +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) + }) +}) diff --git a/tests/unit/connection/ssh-validator.vitest.ts b/tests/unit/connection/ssh-validator.vitest.ts index 55046a055..163087a32 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 5a53cf7bb..db6b7097e 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/routes/ssh-config-handler.vitest.ts b/tests/unit/routes/ssh-config-handler.vitest.ts index 80583e8a1..00dfa5c3a 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 000000000..4ade16b4c --- /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) { + delete process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] + } else { + process.env['WEBSSH2_SSH_HOSTKEY_DB_PATH'] = originalEnv['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/services/host-key/host-key-service.vitest.ts b/tests/unit/services/host-key/host-key-service.vitest.ts new file mode 100644 index 000000000..433484b52 --- /dev/null +++ b/tests/unit/services/host-key/host-key-service.vitest.ts @@ -0,0 +1,171 @@ +// 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 { + HOST_KEY_SCHEMA, + createTempDbContext, + cleanupTempDbContext, + type TestContext, +} from './host-key-test-fixtures.js' + +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, + } +} + +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() +} + +describe('HostKeyService', () => { + let ctx: TestContext + + beforeEach(() => { + ctx = createTempDbContext('hostkey-svc-') + }) + + afterEach(() => { + cleanupTempDbContext(ctx) + }) + + 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() + }) + }) + + 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() + }) + }) + + 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+/]+=*$/) + }) + }) + + 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 + }) + }) +}) 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 000000000..5069fd7ee --- /dev/null +++ b/tests/unit/services/host-key/host-key-store.vitest.ts @@ -0,0 +1,217 @@ +// 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 { + 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' + +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() +} + +describe('HostKeyStore', () => { + let ctx: TestContext + + beforeEach(() => { + ctx = createTempDbContext('hostkey-test-') + }) + + afterEach(() => { + cleanupTempDbContext(ctx) + }) + + 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() + }) + }) + + 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() + }) + }) + + 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((a, b) => a.localeCompare(b)) + 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() + }) + }) + + 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) + }) + }) +}) 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 000000000..1f8588bc3 --- /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 new file mode 100644 index 000000000..0628c401a --- /dev/null +++ b/tests/unit/services/host-key/host-key-verifier.vitest.ts @@ -0,0 +1,339 @@ +// tests/unit/services/host-key/host-key-verifier.vitest.ts +// Tests for createHostKeyVerifier factory + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Socket } from 'socket.io' +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' + +// --- 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 +} + +/** + * 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_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('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 + + beforeEach(() => { + socket = createMockSocket() + vi.useFakeTimers() + }) + + afterEach(() => { + 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({ + hostKeyService: service, + socket: socket as unknown as Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, TEST_KEY_BUFFER) + + 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 Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, TEST_KEY_BUFFER) + + 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 Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, TEST_KEY_BUFFER) + + 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' }, + }) + + const result = await runWithClientResponse(service, 'accept') + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFY, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + algorithm: 'ssh-ed25519', + 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' }, + }) + + const result = await runWithClientResponse(service, 'reject') + + 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 Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + timeout: 5000, + }) + + const promise = callVerifier(verifier, TEST_KEY_BUFFER) + + // 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 Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, TEST_KEY_BUFFER) + + 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 Socket, + host: TEST_HOST, + port: TEST_PORT, + log: mockLog, + }) + + const result = await callVerifier(verifier, TEST_KEY_BUFFER) + + 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', + }) + + const result = await runWithClientResponse(service, 'accept') + + expect(result).toBe(true) + expect(socket.emit).toHaveBeenCalledWith( + SOCKET_EVENTS.HOSTKEY_VERIFY, + expect.objectContaining({ + host: TEST_HOST, + port: TEST_PORT, + }) + ) + }) +}) diff --git a/tests/unit/socket-v2-test-utils.ts b/tests/unit/socket-v2-test-utils.ts index 68bacd653..d577c1df8 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: ':memory:' }, + clientStore: { enabled: false }, } }, options: { diff --git a/tests/unit/socket/control-handler.vitest.ts b/tests/unit/socket/control-handler.vitest.ts index 99074b63a..4948560ab 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 c788a89fd..8d2bbfec1 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) } })) @@ -142,13 +134,26 @@ 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, background: '#000000' }, options: { + challengeButton: false, allowReplay: true, allowReauth: true, allowReconnect: true, @@ -168,7 +173,7 @@ const createConfig = (): Config => ({ session: 'x-session' } } -} as Config) +}) type TestSocket = Socket @@ -185,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() @@ -215,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') @@ -233,4 +238,43 @@ 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 + + // eslint-disable-next-line no-new -- constructor called for side effects (emits events) + new ServiceSocketAdapter(socket, config, services) //NOSONAR + + 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 + + // eslint-disable-next-line no-new -- constructor called for side effects (emits events) + new ServiceSocketAdapter(socket, config, services) //NOSONAR + + expect(socket.emit).toHaveBeenCalledWith('permissions', { + hostKeyVerification: { + enabled: true, + clientStoreEnabled: true, + unknownKeyAction: 'prompt', + }, + }) + }) }) diff --git a/tests/unit/types/result.vitest.ts b/tests/unit/types/result.vitest.ts index d49b25ef2..ab98c262f 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 b1a8931bf..973319f17 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 2ca6e975d..b7ad4db2f 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)