security: pin the SSH host key in the terminal sidecar (fail-closed)

The ssh2 sidecar connected with no host-key verification, so a MITM could present its own key
and capture the (decrypted) SSH credential — the phpseclib fleet path pins via TOFU but the
sidecar did not. The resolve endpoint now returns the server's TOFU-pinned host key; the sidecar
verifies the presented key against it (crypto.timingSafeEqual) during the handshake, before any
credential is sent.

parseHostKeyPin distinguishes three states so a corrupted record can't silently unpin:
  null   → no pin (Clusev host / never-contacted server) → accept, TOFU first use
  false  → a pin is present but malformed/undecodable    → FAIL CLOSED (reject)
  Buffer → enforce the pin (reject a mismatch as MITM)

Verified in-browser against a real server: valid key connects + runs; a swapped key is rejected
as MITM; a garbage non-empty key fails closed — all before any credential leaves the sidecar.
Full suite 469 pass; Codex re-review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-02 19:33:53 +02:00
parent c3788f5851
commit cc4cc3db4c
3 changed files with 51 additions and 0 deletions

View File

@ -14,6 +14,7 @@
*/
const http = require('http');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { Client } = require('ssh2');
@ -63,9 +64,27 @@ function bindInput(ws, io) {
});
}
// Parse the TOFU-pinned host key the app sends (phpseclib's "<algo> <base64>" string) into the raw
// wire-format key for comparison with what ssh2 presents. Returns:
// null → no pin configured (empty/absent) → accept, TOFU first-use
// false → a pin IS present but is malformed/undecodable → FAIL CLOSED (never silently unpin)
// Buffer → the pinned key to enforce
function parseHostKeyPin(hostKey) {
if (typeof hostKey !== 'string' || hostKey.trim() === '') return null; // no pin
const parts = hostKey.trim().split(/\s+/);
const b64 = (parts.length >= 2 ? parts[1] : parts[0]) || '';
if (b64 === '') return false;
let buf;
try { buf = Buffer.from(b64, 'base64'); } catch (_) { return false; }
// Buffer.from is lenient (skips invalid chars); re-encode and compare to reject non-canonical/garbage.
if (buf.length === 0 || buf.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) return false;
return buf;
}
function startSsh(ws, spec, size) {
const conn = new Client();
let stream = null;
const pin = parseHostKeyPin(spec.host_key); // null | false | Buffer
conn.on('ready', () => {
conn.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (err, s) => {
@ -88,6 +107,23 @@ function startSsh(ws, spec, size) {
ws.on('close', () => { try { if (stream) stream.close(); } catch (_) {} conn.end(); });
const cfg = { host: spec.host, port: spec.port || 22, username: spec.username, readyTimeout: 15000, keepaliveInterval: 20000 };
// Pin the host key against the app's TOFU record — refuse a mismatch (MITM) before sending the
// credential. null = no pin (Clusev host / never-contacted server) → accept like TOFU first use;
// false = a stored-but-malformed pin → FAIL CLOSED (a corrupted record must not silently unpin).
cfg.hostVerifier = (key, cb) => {
if (pin === null) { if (cb) cb(true); return true; }
if (pin === false) {
send(ws, { type: 'error', data: 'Host-Key-Pin ungültig — Verbindung abgebrochen.' });
if (cb) cb(false);
return false;
}
const buf = Buffer.isBuffer(key) ? key : Buffer.from(key || '');
const ok = buf.length === pin.length && crypto.timingSafeEqual(buf, pin);
if (!ok) send(ws, { type: 'error', data: 'Host-Key stimmt nicht — möglicher MITM. Verbindung abgebrochen.' });
if (cb) cb(ok);
return ok;
};
if (spec.auth_type === 'key') {
cfg.privateKey = spec.secret;
if (spec.passphrase) cfg.passphrase = spec.passphrase;

View File

@ -97,6 +97,7 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
'auth_type' => $hc->auth_type,
'secret' => $hc->secret, // decrypted by the model cast; internal net only
'passphrase' => $hc->passphrase,
'host_key' => null, // the host has no fleet TOFU pin (local machine)
]);
}
@ -112,6 +113,9 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
'auth_type' => $cred->auth_type,
'secret' => $cred->secret, // 'encrypted' cast → decrypted here; sent only over the internal net
'passphrase' => $cred->passphrase, // decrypted or null
// The TOFU-pinned host key (phpseclib getServerPublicHostKey, "<algo> <base64>"); the sidecar
// pins the SSH connection against it. Null when the server was never contacted → unpinned.
'host_key' => $server->ssh_host_key,
]);
})->middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve');

View File

@ -122,6 +122,7 @@ class TerminalTest extends TestCase
'auth_type' => 'password',
'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net
'passphrase' => null,
'host_key' => null, // the host has no fleet TOFU pin
]);
// single use → second resolve fails
$this->resolve($token)->assertNotFound();
@ -147,9 +148,19 @@ class TerminalTest extends TestCase
'auth_type' => 'password',
'secret' => 's3cret-pw', // decrypted by the model cast, only over the internal net
'passphrase' => null,
'host_key' => null, // no TOFU pin stored for this test server
]);
}
public function test_resolve_includes_the_pinned_host_key_when_the_server_has_one(): void
{
$server = $this->server();
$server->update(['ssh_host_key' => 'ssh-ed25519 AAAAC3NzaPINNED']);
$token = $this->token('server', $server->id);
$this->resolve($token)->assertOk()->assertJsonPath('host_key', 'ssh-ed25519 AAAAC3NzaPINNED');
}
public function test_resolve_rejects_an_expired_token(): void
{
$token = $this->token('host', null, now()->subMinute());