diff --git a/docker/terminal/server.js b/docker/terminal/server.js index 4ac83c1..12c3d18 100644 --- a/docker/terminal/server.js +++ b/docker/terminal/server.js @@ -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 " " 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; diff --git a/routes/web.php b/routes/web.php index 590c77e..cca9bcd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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, " "); 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'); diff --git a/tests/Feature/TerminalTest.php b/tests/Feature/TerminalTest.php index f3ada32..ac367d5 100644 --- a/tests/Feature/TerminalTest.php +++ b/tests/Feature/TerminalTest.php @@ -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());