diff --git a/nodejs/services/bridge.js b/nodejs/services/bridge.js index b045a27..eec9f4f 100644 --- a/nodejs/services/bridge.js +++ b/nodejs/services/bridge.js @@ -23,7 +23,7 @@ function counter(onBytes) { // Connect the upstream ssh2.Client, retrying once after a short pause if the // first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a // first-time key injection). -function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) { +function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected }) { return new Promise((resolve, reject) => { let attempted = false; const dial = (allowRetry) => { @@ -42,6 +42,7 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus }) .connect({ host, port, username, privateKey, + certificates: cert ? [cert] : undefined, readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000, keepaliveInterval: 15000, hostVerifier: (key) => { diff --git a/nodejs/services/ssh_server.js b/nodejs/services/ssh_server.js index 9f89c7a..6a47694 100644 --- a/nodejs/services/ssh_server.js +++ b/nodejs/services/ssh_server.js @@ -129,14 +129,26 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port }); let justInjected = false; - try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); } + let cert; + const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled; + + try { + if (usePki) { + const { getSignedCert } = require('../utils/vault_cert'); + cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid); + } else { + justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); + } + } catch (err) { + const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed'; + throw fail(failType, err.message, host ? host.slug : undefined); + } let upstream; try { upstream = await connectUpstream({ host: endpoint.address, port: endpoint.port, - username: state.uid, privateKey: JUMP_KEYS.clientKey, + username: state.uid, privateKey: JUMP_KEYS.clientKey, cert, uid: state.uid, justInjected, onHostKey, }); } catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); } @@ -228,14 +240,26 @@ async function runTuiSession(session, client, state) { await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port }); let justInjected = false; - try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); } + let cert; + const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled; + + try { + if (usePki) { + const { getSignedCert } = require('../utils/vault_cert'); + cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid); + } else { + justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); + } + } catch (err) { + const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed'; + return finishFail(failType, err.message, tui.host.slug); + } let upstream; try { upstream = await connectUpstream({ host: endpoint.address, port: endpoint.port, - username: state.uid, privateKey: JUMP_KEYS.clientKey, + username: state.uid, privateKey: JUMP_KEYS.clientKey, cert, uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }), }); } catch (err) { diff --git a/nodejs/utils/vault_cert.js b/nodejs/utils/vault_cert.js new file mode 100644 index 0000000..4231ea7 --- /dev/null +++ b/nodejs/utils/vault_cert.js @@ -0,0 +1,44 @@ +'use strict'; + +const conf = require('@simpleworkjs/conf'); + +/** + * Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy. + * + * @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...') + * @param {string} targetUid - The username the cert should be valid for + * @returns {Promise} - The signed SSH certificate + */ +async function getSignedCert(publicKey, targetUid) { + const sso = conf.sso || {}; + const pkiConfig = conf.ssh?.pki || {}; + + const vaultRole = pkiConfig.role || 'jump-host-role'; + const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${sso.apiToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + public_key: publicKey, + valid_principals: targetUid + }) + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`); + } + + const data = await response.json(); + if (!data.data || !data.data.signed_key) { + throw new Error('Vault response missing signed_key'); + } + + return data.data.signed_key; +} + +module.exports = { getSignedCert };