feat: implement OpenBao PKI certificate authentication

This commit is contained in:
2026-08-02 01:54:00 -04:00
parent a57d579b0e
commit 256476268f
3 changed files with 76 additions and 7 deletions
+2 -1
View File
@@ -23,7 +23,7 @@ function counter(onBytes) {
// Connect the upstream ssh2.Client, retrying once after a short pause if the // 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 attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
// first-time key injection). // 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) => { return new Promise((resolve, reject) => {
let attempted = false; let attempted = false;
const dial = (allowRetry) => { const dial = (allowRetry) => {
@@ -42,6 +42,7 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
}) })
.connect({ .connect({
host, port, username, privateKey, host, port, username, privateKey,
certificates: cert ? [cert] : undefined,
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000, readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
keepaliveInterval: 15000, keepaliveInterval: 15000,
hostVerifier: (key) => { hostVerifier: (key) => {
+30 -6
View File
@@ -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 }); await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false; let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } let cert;
catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); } 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; let upstream;
try { try {
upstream = await connectUpstream({ upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port, 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, uid: state.uid, justInjected, onHostKey,
}); });
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); } } 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 }); await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false; let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } let cert;
catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); } 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; let upstream;
try { try {
upstream = await connectUpstream({ upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port, 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 }), uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
}); });
} catch (err) { } catch (err) {
+44
View File
@@ -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<string>} - 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 };