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
parent
c3788f5851
commit
cc4cc3db4c
|
|
@ -14,6 +14,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const { Client } = require('ssh2');
|
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) {
|
function startSsh(ws, spec, size) {
|
||||||
const conn = new Client();
|
const conn = new Client();
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
const pin = parseHostKeyPin(spec.host_key); // null | false | Buffer
|
||||||
|
|
||||||
conn.on('ready', () => {
|
conn.on('ready', () => {
|
||||||
conn.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (err, s) => {
|
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(); });
|
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 };
|
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') {
|
if (spec.auth_type === 'key') {
|
||||||
cfg.privateKey = spec.secret;
|
cfg.privateKey = spec.secret;
|
||||||
if (spec.passphrase) cfg.passphrase = spec.passphrase;
|
if (spec.passphrase) cfg.passphrase = spec.passphrase;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
|
||||||
'auth_type' => $hc->auth_type,
|
'auth_type' => $hc->auth_type,
|
||||||
'secret' => $hc->secret, // decrypted by the model cast; internal net only
|
'secret' => $hc->secret, // decrypted by the model cast; internal net only
|
||||||
'passphrase' => $hc->passphrase,
|
'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,
|
'auth_type' => $cred->auth_type,
|
||||||
'secret' => $cred->secret, // 'encrypted' cast → decrypted here; sent only over the internal net
|
'secret' => $cred->secret, // 'encrypted' cast → decrypted here; sent only over the internal net
|
||||||
'passphrase' => $cred->passphrase, // decrypted or null
|
'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');
|
})->middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ class TerminalTest extends TestCase
|
||||||
'auth_type' => 'password',
|
'auth_type' => 'password',
|
||||||
'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net
|
'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net
|
||||||
'passphrase' => null,
|
'passphrase' => null,
|
||||||
|
'host_key' => null, // the host has no fleet TOFU pin
|
||||||
]);
|
]);
|
||||||
// single use → second resolve fails
|
// single use → second resolve fails
|
||||||
$this->resolve($token)->assertNotFound();
|
$this->resolve($token)->assertNotFound();
|
||||||
|
|
@ -147,9 +148,19 @@ class TerminalTest extends TestCase
|
||||||
'auth_type' => 'password',
|
'auth_type' => 'password',
|
||||||
'secret' => 's3cret-pw', // decrypted by the model cast, only over the internal net
|
'secret' => 's3cret-pw', // decrypted by the model cast, only over the internal net
|
||||||
'passphrase' => null,
|
'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
|
public function test_resolve_rejects_an_expired_token(): void
|
||||||
{
|
{
|
||||||
$token = $this->token('host', null, now()->subMinute());
|
$token = $this->token('host', null, now()->subMinute());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue