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 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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue