feat: move vault ssh signing into a plugin (C5)

This commit is contained in:
LukeGus committed 2026-09-24 20:24:09 -05:00
1 parent b027717c46
commit 6e56c90aac
198 files changed
+19717 -3445

No files matched your search

@@ -2,8 +2,7 @@
* Every SSH auth type the server knows how to connect with.
*
* Core registers password, key, credential, agent and none. The rest come from
* plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from
* src/backend/auth/legacy-providers.ts. A type with no provider fails the
* plugins through ctx.auth.registerSshAuthProvider. A type with no provider fails the
* connect with a message naming the plugin that would provide it.
*/
@@ -1,4 +1,3 @@
import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js";
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
let registered = false;
@@ -11,7 +10,6 @@ export function ensureCoreSshAuthProviders(): void {
if (registered) return;
registered = true;
registerBuiltinSshAuthProviders();
registerLegacySshAuthProviders();
}
/** Test helper, pairs with resetSshAuthRegistryForTests. */
-1
View File
@@ -41,7 +41,6 @@ export interface SshConnectHost {
*/
pluginSettings?: Record<string, Record<string, unknown>> | null;
forceKeyboardInteractive?: boolean | null;
vaultProfile?: { id?: number | null } | null;
terminalConfig?: Record<string, unknown> | null;
jumpHosts?: Array<{ hostId: number }> | null;
useSocks5?: boolean | null;
-10
View File
@@ -600,7 +600,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
let resolvedVaultProfileId: number | null = null;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById, resolveHostBySyncId } =
@@ -625,9 +624,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedVaultProfileId =
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
null;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -695,9 +691,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedVaultProfileId =
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
null;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -787,9 +780,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keyPassword: resolvedCredentials.keyPassword,
certPublicKey: resolvedCredentials.certPublicKey,
terminalConfig: resolvedTerminalConfig ?? null,
vaultProfile: resolvedVaultProfileId
? { id: resolvedVaultProfileId }
: null,
jumpHosts: resolvedJumpHosts,
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
-21
View File
@@ -3,7 +3,6 @@ import { findUsableCredential } from "./usable-credential.js";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
@@ -232,26 +231,6 @@ export async function resolveHostById(
sharedAuthResolution === "recipient-override" ? userId : ownerId,
);
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
// certificate itself is obtained per-user at connect time via Vault OIDC.
if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") {
try {
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (profile) {
(host as Record<string, unknown>).vaultProfile = profile;
host.authType = "vault";
}
} catch (e) {
sshLogger.warn("Failed to resolve vault profile for host", {
operation: "host_resolver_vault_profile",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
// Keyboard-interactive handlers run synchronously mid-handshake and can
// only read their host settings from here.
try {
-214
View File
@@ -1,214 +0,0 @@
// Orchestrates the interactive Vault OIDC login that yields a signed,
// short-lived SSH certificate. Mirrors the OPKSSH session manager but is
// driven entirely over Vault's HTTP API (no external binary).
//
// Lifecycle:
// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC
// auth_url, stash a pending session keyed by the Vault-issued `state`,
// and send the auth_url to the browser over the terminal WebSocket.
// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH.
// - completeVaultAuth(): exchange the code for a Vault token, sign the
// ephemeral key, cache the cert, and notify the browser to reconnect.
import { WebSocket } from "ws";
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
} from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
import {
type VaultProfileConfig,
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
storeVaultCert,
} from "./vault-signer-auth.js";
export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback";
const AUTH_TIMEOUT = 5 * 60 * 1000;
interface VaultAuthSession {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
ephemeralPrivateKey: string;
ephemeralPublicKey: string;
ws: WebSocket;
timeout: NodeJS.Timeout;
}
// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback
// can correlate back to the originating session.
const sessions = new Map<string, VaultAuthSession>();
function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
return {
id: row.id as number,
vaultAddr: row.vaultAddr as string,
vaultNamespace: (row.vaultNamespace as string | null) ?? null,
oidcMount: (row.oidcMount as string | null) ?? null,
oidcRole: (row.oidcRole as string | null) ?? null,
sshMount: (row.sshMount as string | null) ?? null,
sshRole: row.sshRole as string,
validPrincipals: (row.validPrincipals as string | null) ?? null,
keyType: (row.keyType as string | null) ?? null,
};
}
/** Load the Vault profile referenced by a host, or null if not configured. */
export async function loadVaultProfileForHost(
hostId: number,
userId: string,
): Promise<VaultProfileConfig | null> {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host || host.vaultProfileId == null) return null;
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (!profile) return null;
return rowToProfileConfig(profile as Record<string, unknown>);
}
/**
* Begin an interactive Vault OIDC login and send the auth URL to the browser.
*/
export async function startVaultAuth(
userId: string,
hostId: number,
profile: VaultProfileConfig,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const ephemeral = generateEphemeralKeyPair(profile.keyType);
const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) clearTimeout(existing.timeout);
const timeout = setTimeout(() => {
sessions.delete(state);
}, AUTH_TIMEOUT);
sessions.set(state, {
state,
userId,
hostId,
profile,
clientNonce,
ephemeralPrivateKey: ephemeral.privateKey,
ephemeralPublicKey: ephemeral.publicKey,
ws,
timeout,
});
sshLogger.info("Started Vault OIDC auth session", {
operation: "vault_oidc_start",
userId,
hostId,
profileId: profile.id,
});
ws.send(
JSON.stringify({
type: "vault_auth_url",
hostId,
url: authUrl,
}),
);
}
/**
* Complete a Vault OIDC login from the browser callback: exchange the code for a
* Vault token, sign the ephemeral key, cache the certificate, and notify the
* browser to resume the SSH connection.
*/
export async function completeVaultAuth(
state: string,
code: string,
): Promise<{ ok: boolean; error?: string }> {
const session = sessions.get(state);
if (!session) {
return { ok: false, error: "No matching Vault authentication session" };
}
try {
const token = await completeVaultOidc(session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signedCert = await signWithVault(
session.profile,
token,
session.ephemeralPublicKey,
);
const expiresAt = await storeVaultCert(
session.userId,
session.profile.id,
session.ephemeralPrivateKey,
signedCert,
);
sshLogger.success("Completed Vault OIDC auth and cached certificate", {
operation: "vault_oidc_complete",
userId: session.userId,
hostId: session.hostId,
profileId: session.profile.id,
});
session.ws.send(
JSON.stringify({
type: "vault_completed",
hostId: session.hostId,
expiresAt,
}),
);
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
sshLogger.error("Vault OIDC completion failed", e, {
operation: "vault_oidc_complete_error",
userId: session.userId,
hostId: session.hostId,
});
try {
session.ws.send(
JSON.stringify({
type: "vault_error",
hostId: session.hostId,
error: msg,
}),
);
} catch {
// ws may be closed
}
return { ok: false, error: msg };
} finally {
clearTimeout(session.timeout);
sessions.delete(state);
}
}
/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */
export function cancelVaultAuthByHost(userId: string, hostId: number): void {
for (const [state, s] of sessions) {
if (s.userId === userId && s.hostId === hostId) {
clearTimeout(s.timeout);
sessions.delete(state);
}
}
}
-139
View File
@@ -1,139 +0,0 @@
// HashiCorp Vault SSH signer authentication.
//
// Flow (Vault-driven over HTTP):
// 1. Generate an ephemeral SSH keypair (never persisted long-term).
// 2. The user authenticates to Vault via an interactive OIDC flow
// (auth/<mount>/oidc/auth_url -> browser -> auth/<mount>/oidc/callback),
// yielding a short-lived Vault token.
// 3. Vault's SSH secrets engine signs the ephemeral public key
// (<sshMount>/sign/<role>) -> short-lived OpenSSH certificate.
// 4. The ephemeral private key + certificate are cached per-user (encrypted)
// until the certificate expires, then used to connect via applyCertificateAuth.
//
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
// Vault connection SETTINGS live in shareable vault_profiles rows.
//
// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and
// is re-exported here so callers have a single import surface.
import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { parseCertValidBefore } from "./vault-signer-core.js";
export type {
VaultProfileConfig,
EphemeralKeyPair,
} from "./vault-signer-core.js";
export {
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
parseCertValidBefore,
} from "./vault-signer-core.js";
// Re-sign once we're within this many seconds of expiry.
const EXPIRY_SKEW_SECONDS = 60;
function cacheRecordId(userId: string, profileId: number): string {
return `vault-${userId}-${profileId}`;
}
/**
* Store an ephemeral private key + signed certificate for a user/profile.
* Returns the expiry as an ISO string.
*/
export async function storeVaultCert(
userId: string,
profileId: number,
privateKey: string,
signedCert: string,
): Promise<string> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const validBefore = parseCertValidBefore(signedCert);
const expiresMs =
validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000;
const expiresAt = new Date(expiresMs).toISOString();
const recordId = cacheRecordId(userId, profileId);
const encryptedCert = FieldCrypto.encryptField(
signedCert,
userDataKey,
recordId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
privateKey,
userDataKey,
recordId,
"private_key",
);
await createCurrentVaultTokenRepository().upsert({
userId,
profileId,
sshCert: encryptedCert,
privateKey: encryptedKey,
expiresAt,
});
return expiresAt;
}
/**
* Return a cached, still-valid ephemeral key + certificate for a user/profile,
* or null if none exists or it has (nearly) expired.
*/
export async function getVaultCert(
userId: string,
profileId: number,
): Promise<{ privateKey: string; sshCert: string } | null> {
const repository = createCurrentVaultTokenRepository();
const row = await repository.findByUserAndProfile(userId, profileId);
if (!row) return null;
const expiresMs = new Date(row.expiresAt).getTime();
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
await deleteVaultCert(userId, profileId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const recordId = cacheRecordId(userId, profileId);
const sshCert = FieldCrypto.decryptField(
row.sshCert,
userDataKey,
recordId,
"ssh_cert",
);
const privateKey = FieldCrypto.decryptField(
row.privateKey,
userDataKey,
recordId,
"private_key",
);
await repository.updateLastUsed(userId, profileId);
return { privateKey, sshCert };
}
export async function deleteVaultCert(
userId: string,
profileId: number,
): Promise<void> {
await createCurrentVaultTokenRepository().deleteByUserAndProfile(
userId,
profileId,
);
}
-265
View File
@@ -1,265 +0,0 @@
// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation,
// the Vault OIDC HTTP flow, certificate signing, and certificate parsing.
//
// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user
// certificate cache) so this layer can be unit-tested without the native SQLite
// dependency.
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { sshLogger } from "../utils/logger.js";
const { utils: ssh2Utils } = ssh2Pkg;
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
async function vaultRequest(
url: string,
method: "GET" | "POST" | "PUT",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<any> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: any;
if (text) {
try {
json = JSON.parse(text);
} catch {
// leave undefined for non-JSON bodies
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** Generate an ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
ssh2Type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
ssh2Type = "ecdsa";
options.bits = 256;
break;
case "ssh-ed25519":
default:
ssh2Type = "ed25519";
break;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce
* needed to complete the callback. `redirectUri` must be allowed both by the
* Vault role's allowed_redirect_uris and by the IdP.
*/
export async function startVaultOidc(
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault embeds the state it generated in the auth_url; we need it to correlate
// the browser callback back to this login attempt.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Complete the Vault OIDC callback and return a short-lived Vault token. */
export async function completeVaultOidc(
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile));
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken };
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
headers,
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* Parse the "valid before" Unix timestamp out of an OpenSSH certificate.
* Returns 0 if it can't be parsed (caller falls back to a conservative TTL).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const readString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
readString(); // format id
readString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) readString();
readUint64(); // serial
pos += 4; // type (uint32)
readString(); // key id
readString(); // valid principals
readUint64(); // valid after
return readUint64(); // valid before
} catch (e) {
sshLogger.warn("Failed to parse Vault-signed certificate expiry", {
operation: "vault_cert_parse",
error: e instanceof Error ? e.message : String(e),
});
return 0;
}
}