feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

@@ -102,6 +102,12 @@ export function listCredentialTypes(): string[] {
export function registerKeyboardInteractiveInterceptor(
interceptor: KeyboardInteractiveInterceptor,
): () => void {
const existing = interceptors.get(interceptor.id);
if (existing && existing.pluginId !== interceptor.pluginId) {
throw new Error(
`Keyboard-interactive handler "${interceptor.id}" is already provided by ${existing.pluginId}`,
);
}
interceptors.set(interceptor.id, interceptor);
return () => {
if (interceptors.get(interceptor.id) === interceptor) {
@@ -104,14 +104,17 @@ async function prepareKey(
const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) {
try {
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
await setupCACertAuth(
const { applyCertificateAuth } =
await import("@termix/plugin-sdk/ssh-certs");
await applyCertificateAuth(
config,
env.client,
config.privateKey as Buffer,
certPublicKey,
{
privateKey: config.privateKey as Buffer,
certificate: certPublicKey,
passphrase,
},
host.username,
passphrase,
);
env.log("info", "Using SSH key authentication with CA certificate");
return { status: "ready" };
@@ -154,13 +154,8 @@ export function createPromptKeyboardInteractiveHandler(
switch (decision.kind) {
case "auto":
return decision.responses;
case "warpgate": {
await channel.ask({
kind: "warpgate",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
});
case "browser": {
await channel.ask(decision);
return prompts.map(() => "");
}
case "totp": {
+23 -15
View File
@@ -35,7 +35,11 @@ export interface SshConnectHost {
keyPassword?: string | null;
keyType?: string | null;
certPublicKey?: string | null;
useWarpgate?: boolean | null;
/**
* Host-scope plugin settings by plugin id, filled in by the resolver so
* synchronous hooks (keyboard-interactive handlers) can read them.
*/
pluginSettings?: Record<string, Record<string, unknown>> | null;
forceKeyboardInteractive?: boolean | null;
vaultProfile?: { id?: number | null } | null;
terminalConfig?: Record<string, unknown> | null;
@@ -234,21 +238,30 @@ export interface KeyboardInteractivePrompt {
echo?: boolean;
}
/**
* A round finished in a browser: open the URL, compare the code, continue.
* `id` names the handler that claimed it; transports name their messages
* after it.
*/
export interface BrowserSignInRound {
kind: "browser";
id: string;
label: string;
url: string | null;
code: string;
instructions: string;
}
/** What to do with one keyboard-interactive round. */
export type KeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| BrowserSignInRound
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
/**
* Interceptors run before the built-in classifier. A provider that owns a
* prompt style (Warpgate today) returns a decision; everyone else returns null.
* Interceptors run before the built-in classifier. A plugin that owns a
* prompt style returns a decision; everyone else returns null.
*/
export interface KeyboardInteractiveInterceptor {
id: string;
@@ -281,9 +294,4 @@ export interface SshPromptChannel {
export type SshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
};
| BrowserSignInRound;
+3 -3
View File
@@ -1159,14 +1159,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return;
}
if (decision.kind === "warpgate") {
if (decision.kind === "browser") {
if (responseSent) return;
responseSent = true;
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Warpgate authentication required",
`${decision.label} sign-in required`,
{ url: decision.url },
),
);
@@ -1175,7 +1175,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
requires_warpgate: true,
sessionId,
url: decision.url,
securityKey: decision.securityKey,
securityKey: decision.code,
connectionLogs,
});
return;
+15
View File
@@ -252,6 +252,21 @@ export async function resolveHostById(
}
}
// Keyboard-interactive handlers run synchronously mid-handshake and can
// only read their host settings from here.
try {
const { loadHostPluginSettings } =
await import("../database/routes/host-plugin-settings.js");
const settings = (await loadHostPluginSettings([hostId])).get(hostId);
if (settings) host.pluginSettings = settings;
} catch (e) {
sshLogger.warn("Failed to load plugin settings for host", {
operation: "host_resolver_plugin_settings",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
return host as unknown as SSHHost;
}
-937
View File
@@ -1,937 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
import { spawn, ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import { WebSocket } from "ws";
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
import { sshLogger } from "../utils/logger.js";
import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { promises as fs } from "fs";
import path from "path";
import axios from "axios";
import { load as loadYaml } from "js-yaml";
const AUTH_TIMEOUT = 60 * 1000;
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
interface OPKSSHAuthSession {
requestId: string;
userId: string;
hostId: number;
hostname: string;
process: ChildProcess;
localPort: number;
callbackPort: number;
remoteRedirectUri: string;
providers: Array<{ alias: string; issuer: string }>;
status:
"starting" | "waiting_for_auth" | "authenticating" | "completed" | "error";
ws: WebSocket;
stdoutBuffer: string;
privateKeyBuffer: string;
sshCertBuffer: string;
identity: {
email?: string;
sub?: string;
issuer?: string;
audience?: string;
};
createdAt: Date;
approvalTimeout: NodeJS.Timeout;
cleanup: () => Promise<void>;
}
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
const oauthStateToRequestId = new Map<string, string>();
const cleanupInProgress = new Set<string>();
function getOPKConfigPath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, ".opk", "config.yml");
}
async function ensureOPKConfigDir(): Promise<void> {
const configPath = getOPKConfigPath();
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
}
async function createTemplateConfig(): Promise<void> {
const configPath = getOPKConfigPath();
const template = `
# OPKSSH Configuration
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
# Termix Documentation: https://docs.termix.site/features/authentication/opkssh
`;
try {
await ensureOPKConfigDir();
await fs.writeFile(configPath, template, "utf8");
sshLogger.info(`Created template OPKSSH config at ${configPath}`);
} catch (error) {
sshLogger.warn("Failed to create template OPKSSH config", error);
}
}
interface ProviderRedirectInfo {
alias: string;
issuer: string;
redirectUris: string[];
}
async function checkOPKConfigExists(): Promise<{
exists: boolean;
error?: string;
configPath?: string;
providers?: ProviderRedirectInfo[];
}> {
const configPath = getOPKConfigPath();
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
try {
const content = await fs.readFile(configPath, "utf8");
if (!content.includes("providers:")) {
return {
exists: false,
configPath,
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`,
};
}
const lines = content.split("\n");
const hasUncommentedProvider = lines.some((line) => {
const trimmed = line.trim();
return (
trimmed.startsWith("- alias:") ||
(trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#"))
);
});
if (!hasUncommentedProvider) {
return {
exists: false,
configPath,
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
let providers: ProviderRedirectInfo[] = [];
try {
const parsed = loadYaml(content) as {
providers?: Array<{
alias?: string;
issuer?: string;
redirect_uris?: string[];
}>;
};
if (parsed?.providers && Array.isArray(parsed.providers)) {
providers = parsed.providers
.filter(
(
p,
): p is {
alias: string;
issuer: string;
redirect_uris?: string[];
} => typeof p.alias === "string" && typeof p.issuer === "string",
)
.map((p) => ({
alias: p.alias,
issuer: p.issuer.replace(/^https?:\/\//, ""),
redirectUris: Array.isArray(p.redirect_uris)
? p.redirect_uris.filter(
(u): u is string => typeof u === "string",
)
: [],
}));
}
} catch (e) {
sshLogger.warn("Failed to parse OPKSSH config for providers", {
operation: "opkssh_config_parse_providers_error",
error: e,
});
}
return { exists: true, configPath, providers };
} catch {
await createTemplateConfig();
return {
exists: false,
configPath,
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
}
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
// (derived from request origin); users do NOT put that URL in this config field.
function validateRedirectUrisAreLocalhost(
providers: ProviderRedirectInfo[],
): { ok: true } | { ok: false; message: string } {
const isLocalHost = (host: string): boolean => {
const bare = host.replace(/^\[|\]$/g, "");
return (
bare === "localhost" ||
bare === "127.0.0.1" ||
bare === "::1" ||
bare === "0:0:0:0:0:0:0:1" ||
bare.startsWith("localhost:") ||
bare.startsWith("127.0.0.1:")
);
};
const issues: string[] = [];
for (const p of providers) {
const uris = p.redirectUris || [];
if (uris.length === 0) continue;
const nonLocal = uris.filter((u) => {
try {
return !isLocalHost(new URL(u).hostname);
} catch {
return true;
}
});
if (nonLocal.length > 0) {
issues.push(
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
);
}
}
if (issues.length > 0) {
return {
ok: false,
message:
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
`${issues.join("\n")}\n\n` +
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
`Docs: https://docs.termix.site/features/authentication/opkssh`,
};
}
return { ok: true };
}
export async function startOPKSSHAuth(
userId: string,
hostId: number,
hostname: string,
ws: WebSocket,
requestOrigin: string,
): Promise<string> {
try {
await ensureOPKConfigDir();
const configDir = path.dirname(getOPKConfigPath());
await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK);
} catch (error) {
sshLogger.error("OPKSSH directory not accessible", error);
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
ws.send(
JSON.stringify({
type: "opkssh_error",
error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`,
}),
);
return "";
}
const configCheck = await checkOPKConfigExists();
if (!configCheck.exists) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: configCheck.error,
instructions: configCheck.error,
}),
);
return "";
}
const redirectValidation = validateRedirectUrisAreLocalhost(
configCheck.providers || [],
);
if (redirectValidation.ok === false) {
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
operation: "opkssh_config_redirect_uris_not_localhost",
configPath: configCheck.configPath,
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: redirectValidation.message,
instructions: redirectValidation.message,
}),
);
return "";
}
const requestId = randomUUID();
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
sshLogger.info("Starting OPKSSH auth session", {
operation: "opkssh_start_auth_remote_redirect_uri",
requestId,
userId,
hostId,
requestOrigin,
remoteRedirectUri,
providerAliases: (configCheck.providers || []).map((p) => p.alias),
});
const session: Partial<OPKSSHAuthSession> = {
requestId,
userId,
hostId,
hostname,
localPort: 0,
callbackPort: 0,
remoteRedirectUri,
providers: configCheck.providers || [],
status: "starting",
ws,
stdoutBuffer: "",
privateKeyBuffer: "",
sshCertBuffer: "",
identity: {},
createdAt: new Date(),
};
try {
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
const configPath = getOPKConfigPath();
const args = [
"login",
"--print-key",
"--disable-browser-open",
`--config-path=${configPath}`,
`--remote-redirect-uri=${remoteRedirectUri}`,
];
const opksshProcess = spawn(binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
},
});
session.process = opksshProcess;
const cleanup = async () => {
await cleanupAuthSession(requestId);
};
session.cleanup = cleanup;
const timeout = setTimeout(async () => {
sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`);
ws.send(
JSON.stringify({
type: "opkssh_timeout",
requestId,
}),
);
await cleanup();
}, AUTH_TIMEOUT);
session.approvalTimeout = timeout;
ws.on("close", () => {
cleanup();
});
activeAuthSessions.set(requestId, session as OPKSSHAuthSession);
opksshProcess.stdout?.on("data", (data) => {
const output = data.toString();
handleOPKSSHOutput(requestId, output);
});
opksshProcess.stderr?.on("data", async (data) => {
const stderr = data.toString();
if (
stderr.includes("Opening browser to") ||
stderr.includes("Open your browser to:")
) {
handleOPKSSHOutput(requestId, stderr);
}
if (stderr.includes("listening on")) {
handleOPKSSHOutput(requestId, stderr);
}
const lowerStderr = stderr.toLowerCase();
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
// with the distinctive message "redirectURI must be localhost". Surface that
// directly with actionable guidance.
if (lowerStderr.includes("redirecturi must be localhost")) {
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
operation: "opkssh_stderr_redirect_uris_not_localhost",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
const genericRedirectIndicators = [
"redirect_uri",
"redirect uri",
"invalid redirect",
"no matching redirect",
"allowed redirect",
"mismatching redirection",
];
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
lowerStderr.includes(s),
);
if (hasGenericRedirectError) {
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
operation: "opkssh_stderr_redirect_uri_error",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
`(e.g. in Google Cloud Console → OAuth client). ` +
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
if (
stderr.includes("provider not found") ||
stderr.includes("config error") ||
stderr.includes("invalid config") ||
stderr.includes("config not found")
) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
instructions:
"See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md",
}),
);
cleanup();
}
if (
stderr.includes("level=error") ||
stderr.includes("Error:") ||
stderr.includes("failed")
) {
const isXdgOpenError = stderr.includes('exec: "xdg-open"');
if (!isXdgOpenError) {
if (
stderr.includes("bind: address already in use") ||
stderr.includes("error logging in") ||
stderr.includes("failed to start")
) {
await cleanup();
}
}
}
});
opksshProcess.on("error", (error) => {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process error: ${error.message}`,
}),
);
cleanup();
});
opksshProcess.on("exit", (code) => {
if (code !== 0 && session.status !== "completed") {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process exited with code ${code}`,
}),
);
}
cleanup();
});
return requestId;
} catch (error) {
sshLogger.error(`Failed to start OPKSSH auth session`, error);
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `Failed to start OPKSSH authentication: ${getErrorMessage(error)}`,
}),
);
return "";
}
}
function handleOPKSSHOutput(requestId: string, output: string): void {
const session = activeAuthSessions.get(requestId);
if (!session) {
return;
}
session.stdoutBuffer += output;
const chooserUrlMatch = session.stdoutBuffer.match(
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
);
if (chooserUrlMatch && session.status === "starting") {
const actualPort = parseInt(chooserUrlMatch[1], 10);
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
session.localPort = actualPort;
// No port rewrite any more: the terminal socket used to live on 30002,
// separate from the HTTP API, so a redirect URI derived from it pointed at
// the wrong server in direct dev mode. The terminal rides 30001 now.
const baseUrl = session.remoteRedirectUri.replace(
/\/host\/opkssh-callback$/,
"",
);
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "chooser",
url: proxiedChooserUrl,
providers: session.providers,
localUrl: localChooserUrl,
message: "Please authenticate in your browser",
}),
);
}
const callbackPortMatch = session.stdoutBuffer.match(
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
);
if (callbackPortMatch && !session.callbackPort) {
session.callbackPort = parseInt(callbackPortMatch[1], 10);
}
if (output.includes("BEGIN OPENSSH PRIVATE KEY")) {
session.status = "authenticating";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "authenticating",
message: "Processing authentication...",
}),
);
}
const privateKeyMatch = session.stdoutBuffer.match(
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/,
);
if (privateKeyMatch) {
session.privateKeyBuffer = privateKeyMatch[1].trim();
}
const certMatch = session.stdoutBuffer.match(
/(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/,
);
if (certMatch) {
session.sshCertBuffer = certMatch[1].trim();
}
const identityMatch = session.stdoutBuffer.match(
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/,
);
if (identityMatch) {
session.identity = {
email: identityMatch[1],
sub: identityMatch[2],
issuer: identityMatch[3],
audience: identityMatch[4],
};
}
if (session.privateKeyBuffer && session.sshCertBuffer) {
if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) {
sshLogger.error(`Invalid private key extracted [${requestId}]`, {
bufferPrefix: session.privateKeyBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid private key from OPKSSH output",
}),
);
return;
}
if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) {
sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, {
bufferPrefix: session.sshCertBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid SSH certificate from OPKSSH output",
}),
);
return;
}
storeOPKSSHToken(session);
}
}
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
const encryptedCert = FieldCrypto.encryptField(
session.sshCertBuffer,
userDataKey,
tokenId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
session.privateKeyBuffer,
userDataKey,
tokenId,
"private_key",
);
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: encryptedCert,
privateKey: encryptedKey,
email: session.identity.email,
sub: session.identity.sub,
issuer: session.identity.issuer,
audience: session.identity.audience,
expiresAt: expiresAt.toISOString(),
});
session.status = "completed";
session.ws.send(
JSON.stringify({
type: "opkssh_completed",
requestId: session.requestId,
expiresAt: expiresAt.toISOString(),
}),
);
await session.cleanup();
} catch (error) {
sshLogger.error(
`Failed to store OPKSSH token for session ${session.requestId}`,
error,
);
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to store authentication token",
}),
);
await session.cleanup();
}
}
export async function getOPKSSHToken(
userId: string,
hostId: number,
): Promise<{ sshCert: string; privateKey: string } | null> {
try {
const repository = createCurrentOpksshTokenRepository();
const tokenData = await repository.findByUserAndHost(userId, hostId);
if (!tokenData) {
return null;
}
const expiresAt = new Date(tokenData.expiresAt);
if (expiresAt < new Date()) {
await repository.deleteByUserAndHost(userId, hostId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${userId}-${hostId}`;
const decryptedCert = FieldCrypto.decryptField(
tokenData.sshCert,
userDataKey,
tokenId,
"ssh_cert",
);
const decryptedKey = FieldCrypto.decryptField(
tokenData.privateKey,
userDataKey,
tokenId,
"private_key",
);
await repository.updateLastUsed(userId, hostId);
return {
sshCert: decryptedCert,
privateKey: decryptedKey,
};
} catch (error) {
sshLogger.error(`Failed to retrieve OPKSSH token`, error);
return null;
}
}
export async function deleteOPKSSHToken(
userId: string,
hostId: number,
): Promise<void> {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
}
export async function invalidateOPKSSHToken(
userId: string,
hostId: number,
reason: string,
): Promise<void> {
try {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
} catch (error) {
sshLogger.error(`Failed to invalidate OPKSSH token`, {
userId,
hostId,
reason,
error,
});
}
}
export async function handleOAuthCallback(
requestId: string,
queryString: string,
): Promise<{ success: boolean; message?: string }> {
const session = activeAuthSessions.get(requestId);
if (!session) {
return { success: false, message: "Invalid authentication session" };
}
try {
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
await axios.get(callbackUrl, {
timeout: 10000,
validateStatus: () => true,
});
return { success: true };
} catch {
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to complete authentication",
}),
);
await session.cleanup();
return { success: false, message: "Authentication failed" };
}
}
async function cleanupAuthSession(requestId: string): Promise<void> {
if (cleanupInProgress.has(requestId)) {
return;
}
cleanupInProgress.add(requestId);
try {
const session = activeAuthSessions.get(requestId);
if (!session) {
cleanupInProgress.delete(requestId);
return;
}
if (session.approvalTimeout) {
clearTimeout(session.approvalTimeout);
}
if (session.process) {
try {
session.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const killTimeout = setTimeout(() => {
if (session.process && !session.process.killed) {
session.process.kill("SIGKILL");
}
resolve();
}, 3000);
session.process.once("exit", () => {
clearTimeout(killTimeout);
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (killError) {
sshLogger.warn(
`Failed to kill OPKSSH process for session ${requestId}`,
killError,
);
}
}
// Clean up any OAuth state mappings for this session
for (const [state, reqId] of oauthStateToRequestId.entries()) {
if (reqId === requestId) {
oauthStateToRequestId.delete(state);
}
}
activeAuthSessions.delete(requestId);
} finally {
cleanupInProgress.delete(requestId);
}
}
export function cancelAuthSession(requestId: string): void {
const session = activeAuthSessions.get(requestId);
if (session) {
session.cleanup();
}
}
export function getActiveAuthSession(
requestId: string,
): OPKSSHAuthSession | undefined {
return activeAuthSessions.get(requestId);
}
export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] {
const sessions: OPKSSHAuthSession[] = [];
for (const session of activeAuthSessions.values()) {
if (session.userId === userId) {
sessions.push(session);
}
}
return sessions;
}
export function getActiveSessionsAll(): OPKSSHAuthSession[] {
return Array.from(activeAuthSessions.values());
}
export function registerOAuthState(state: string, requestId: string): void {
oauthStateToRequestId.set(state, requestId);
}
export function getRequestIdByOAuthState(state: string): string | undefined {
return oauthStateToRequestId.get(state);
}
export function clearOAuthState(state: string): void {
oauthStateToRequestId.delete(state);
}
export async function getUserIdFromRequest(req: {
cookies?: Record<string, string>;
headers: Record<string, string | undefined>;
}): Promise<string | null> {
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return null;
}
const decoded = await authManager.verifyJWTToken(token);
if (!decoded?.userId || decoded.pendingTOTP) return null;
return decoded.userId;
} catch {
return null;
}
}
-369
View File
@@ -1,369 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
// SSH certificate authentication workarounds for ssh2.
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
// the certificate onto the parsed key, wraps ECDSA signing to convert
// DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
//
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase)
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported)
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
interface OPKSSHToken {
privateKey: string;
sshCert: string;
}
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface OPKSSHProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type OPKSSHClient = Client & {
_protocol?: OPKSSHProtocol;
};
type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
// ── Internal implementation ──────────────────────────────────────────────────
// Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds.
async function _applyCertToConnection(
config: ConnectConfig,
client: Client,
privKey: ParsedPrivateKey,
certStr: string,
): Promise<void> {
// Extract cert type and blob from the stored certificate
const certParts = certStr.trim().split(/\s+/);
if (certParts.length < 2) {
throw new Error(
"Invalid certificate format: expected '<type> <base64>' string",
);
}
const certType = certParts[0];
const certBlob = Buffer.from(certParts[1], "base64");
// Graft cert type and blob onto the parsed private key
privKey.type = certType;
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)",
);
if (!pubSSHSym) {
throw new Error(
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
);
}
privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => {
const sigAlgo = algo?.includes("-cert-")
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
: algo;
const sig = origSign(data, sigAlgo);
if (sig instanceof Error || sig[0] !== 0x30) return sig;
// Convert DER-encoded ECDSA signature to SSH wire format
try {
let pos = 2;
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
pos++;
const rLen = sig[pos++];
const r = sig.subarray(pos, pos + rLen);
pos += rLen + 1;
const sLen = sig[pos++];
const s = sig.subarray(pos, pos + sLen);
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
out.writeUInt32BE(r.length, 0);
r.copy(out, 4);
out.writeUInt32BE(s.length, 4 + r.length);
s.copy(out, 4 + r.length + 4);
return out;
} catch {
return sig;
}
};
}
// Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false;
const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[],
_partialSuccess: boolean,
callback,
) => {
const next = callback as OPKSSHNextAuthHandler;
if (
!certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey"))
) {
certAuthAttempted = true;
next({
type: "publickey",
username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
next(false);
}
};
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client);
const patchedClient = client as OPKSSHClient;
patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto);
proto.authPK = (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) {
return origAuthPK(user, pubKey, keyAlgo, cbSign);
}
// Signed auth with cert type: rebuild packet with base algo in
// the signature wrapper. keyAlgo may be undefined for ECDSA.
const certAlgo = keyAlgo || pubKey.type;
const pubSSH = pubKey.getPublicSSH();
const sessionID = proto._kex.sessionID;
const sesLen = sessionID.length;
const userLen = Buffer.byteLength(user);
const certAlgoLen = Buffer.byteLength(certAlgo);
const baseAlgoLen = Buffer.byteLength(baseAlgo);
const pubKeyLen = pubSSH.length;
// Build data to sign (uses cert algo — matches server verification)
const sigDataLen =
4 +
sesLen +
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen;
const sigData = Buffer.allocUnsafe(sigDataLen);
let sp = 0;
sigData.writeUInt32BE(sesLen, sp);
sp += 4;
sessionID.copy(sigData, sp);
sp += sesLen;
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
sigData.writeUInt32BE(userLen, sp);
sp += 4;
sigData.write(user, sp, userLen, "utf8");
sp += userLen;
sigData.writeUInt32BE(14, sp);
sp += 4;
sigData.write("ssh-connection", sp, 14, "utf8");
sp += 14;
sigData.writeUInt32BE(9, sp);
sp += 4;
sigData.write("publickey", sp, 9, "utf8");
sp += 9;
sigData[sp++] = 1; // TRUE
sigData.writeUInt32BE(certAlgoLen, sp);
sp += 4;
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
sp += certAlgoLen;
sigData.writeUInt32BE(pubKeyLen, sp);
sp += 4;
pubSSH.copy(sigData, sp);
cbSign(sigData, (signature: Buffer) => {
const sigLen = signature.length;
const payloadLen =
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen +
4 +
4 +
baseAlgoLen +
4 +
sigLen;
const packet = proto._packetRW.write.alloc(payloadLen);
let pp = proto._packetRW.write.allocStart;
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
packet.writeUInt32BE(userLen, ++pp);
pp += 4;
packet.write(user, pp, userLen, "utf8");
pp += userLen;
packet.writeUInt32BE(14, pp);
pp += 4;
packet.write("ssh-connection", pp, 14, "utf8");
pp += 14;
packet.writeUInt32BE(9, pp);
pp += 4;
packet.write("publickey", pp, 9, "utf8");
pp += 9;
packet[pp++] = 1; // TRUE
// Header: cert type
packet.writeUInt32BE(certAlgoLen, pp);
pp += 4;
packet.write(certAlgo, pp, certAlgoLen, "utf8");
pp += certAlgoLen;
// Public key blob
packet.writeUInt32BE(pubKeyLen, pp);
pp += 4;
pubSSH.copy(packet, pp);
pp += pubKeyLen;
// Signature wrapper: base algo (NOT cert type)
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
pp += 4;
packet.writeUInt32BE(baseAlgoLen, pp);
pp += 4;
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
pp += baseAlgoLen;
packet.writeUInt32BE(sigLen, pp);
pp += 4;
signature.copy(packet, pp);
proto._authsQueue.push("publickey");
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
const finalized = proto._packetRW.write.finalize(packet);
proto._cipher.encrypt(finalized);
});
};
return connectedClient;
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Set up OPKSSH certificate authentication on an ssh2 Client.
* The OPKSSH private key is assumed to be unencrypted (no passphrase).
*/
export async function setupOPKSSHCertAuth(
config: ConnectConfig,
client: Client,
token: OPKSSHToken,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const parsed = parseKey(Buffer.from(token.privateKey));
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, token.sshCert);
}
/**
* Set up CA-signed certificate authentication on an ssh2 Client.
* Supports passphrase-protected private keys.
* The cert content is the full text of the -cert.pub file
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
*/
export async function setupCACertAuth(
config: ConnectConfig,
client: Client,
privateKey: Buffer | string,
certPublicKey: string,
username: string,
passphrase?: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(privateKey)
? privateKey
: Buffer.from(privateKey, "utf8");
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const errMsg = getErrorMessage(parsed, "unknown error");
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, certPublicKey);
}
+48 -44
View File
@@ -2,12 +2,7 @@ import { randomBytes } from "crypto";
import type { WebSocket } from "ws";
import { sshLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import {
createCurrentOpksshTokenRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js";
import {
stepCaRuntime,
@@ -31,9 +26,8 @@ import {
/**
* Step CA (smallstep) SSH user certificates through its OIDC provisioner.
*
* Mirrors the OPKSSH flow and reuses its storage, WS messages and connect
* path; the only difference is how the certificate is obtained: no binary,
* just the CA's HTTP API plus one OIDC redirect back to Termix.
* The CA's HTTP API plus one OIDC redirect back to Termix. Issued
* certificates are kept in memory until they expire.
*/
export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback";
@@ -87,6 +81,37 @@ interface StepCaAuthSession {
const sessions = new Map<string, StepCaAuthSession>();
interface IssuedCertificate {
sshCert: string;
privateKey: string;
expiresAt: number;
}
const issued = new Map<string, IssuedCertificate>();
function certKey(userId: string, hostId: number): string {
return `${userId}:${hostId}`;
}
/** The cached certificate for this user and host, while it is valid. */
export function getStepCaCert(
userId: string,
hostId: number,
): { sshCert: string; privateKey: string } | null {
const key = certKey(userId, hostId);
const cert = issued.get(key);
if (!cert) return null;
if (cert.expiresAt <= Date.now()) {
issued.delete(key);
return null;
}
return { sshCert: cert.sshCert, privateKey: cert.privateKey };
}
export function invalidateStepCaCert(userId: string, hostId: number): void {
issued.delete(certKey(userId, hostId));
}
function send(ws: WebSocket, message: object): void {
try {
ws.send(JSON.stringify(message));
@@ -112,7 +137,7 @@ export async function startStepCaAuth(
const settings = await readStepCaSettings();
if (!settings) {
send(ws, {
type: "opkssh_config_error",
type: "stepca_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.",
@@ -162,7 +187,7 @@ export async function startStepCaAuth(
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(ws, { type: "opkssh_timeout", requestId: state });
send(ws, { type: "stepca_timeout", requestId: state });
endSession(current);
}, AUTH_TIMEOUT_MS),
};
@@ -184,7 +209,7 @@ export async function startStepCaAuth(
});
send(ws, {
type: "opkssh_status",
type: "stepca_status",
requestId: state,
stage: "chooser",
label: "Step CA",
@@ -205,7 +230,7 @@ export async function startStepCaAuth(
hostId,
});
send(ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: state,
error: `Step CA: ${getErrorMessage(error)}`,
});
@@ -221,9 +246,8 @@ export function cancelStepCaAuth(requestId: string): boolean {
/**
* Finishes the flow once the identity provider redirects back: exchanges
* the code, has the CA sign the key, stores the certificate the way OPKSSH
* does (same table, same encryption, same token id) and tells the terminal
* to reconnect.
* the code, has the CA sign the key, caches the certificate and tells the
* terminal to reconnect.
*/
export async function completeStepCaAuth(
query: StepCaCallbackQuery,
@@ -262,7 +286,7 @@ async function finishStepCaAuth(
if (query.error || !query.code) {
const message = query.error_description || query.error || "Sign-in failed";
send(session.ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
@@ -272,7 +296,7 @@ async function finishStepCaAuth(
try {
send(session.ws, {
type: "opkssh_status",
type: "stepca_status",
requestId: session.state,
stage: "authenticating",
});
@@ -320,35 +344,15 @@ async function finishStepCaAuth(
}
const expiresAt = certificateInfo.validBefore;
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) throw new Error("User data key not found");
// Same token id as OPKSSH: getOPKSSHToken decrypts with it.
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: FieldCrypto.encryptField(
certificate,
userDataKey,
tokenId,
"ssh_cert",
),
privateKey: FieldCrypto.encryptField(
session.keyPair.privateKeyPem,
userDataKey,
tokenId,
"private_key",
),
email,
sub: typeof claims.sub === "string" ? claims.sub : undefined,
issuer: typeof claims.iss === "string" ? claims.iss : undefined,
audience: typeof claims.aud === "string" ? claims.aud : undefined,
expiresAt: expiresAt.toISOString(),
issued.set(certKey(session.userId, session.hostId), {
sshCert: certificate,
privateKey: session.keyPair.privateKeyPem,
expiresAt: expiresAt.getTime(),
});
session.completed = true;
send(session.ws, {
type: "opkssh_completed",
type: "stepca_completed",
requestId: session.state,
expiresAt: expiresAt.toISOString(),
});
@@ -362,7 +366,7 @@ async function finishStepCaAuth(
});
const message = getErrorMessage(error);
send(session.ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
+2 -2
View File
@@ -1,6 +1,6 @@
// HashiCorp Vault SSH signer authentication.
//
// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP):
// 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),
@@ -8,7 +8,7 @@
// 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 setupOPKSSHCertAuth.
// 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.