mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user