From 32d77fc6d0c4ba25d110daaf1ee01864e4f69a30 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 25 Aug 2026 02:56:44 +0800 Subject: [PATCH] feat: Step CA SSH certificates as a host authentication type (#1340) * feat: Step CA SSH certificates as a host authentication type Issue short-lived SSH user certificates from a smallstep CA through its OIDC provisioner, over the CA's HTTP API rather than the step binary. Everything after issuance reuses the OPKSSH plumbing: the same encrypted per-user/host token store, WebSocket dialog and ssh2 certificate injection, with the connect paths branching on a shared usesIssuedCertificate() predicate. Instance-wide CA settings live in the admin panel, with a private-host allowlist for the SSRF guard. * fix: harden Step CA callback flow * style: format Step CA changes --- README.md | 9 +- docker/compose-dev.yml | 1 + docker/docker-compose.yml | 1 + docs/readme/README-CN.md | 8 +- src/backend/ai/tools/catalog.ts | 2 + .../database/routes/host-bulk-routes.ts | 3 +- .../database/routes/host-step-ca-routes.ts | 34 ++ src/backend/database/routes/host.ts | 2 + src/backend/database/routes/opkssh-html.ts | 2 +- .../database/routes/user-settings-routes.ts | 187 ++++++++- src/backend/hosts/docker/routes.ts | 3 +- src/backend/hosts/file-manager/index.ts | 5 +- src/backend/hosts/issued-certificate-auth.ts | 12 + src/backend/hosts/metrics/helpers.ts | 4 +- src/backend/hosts/metrics/index.ts | 7 +- src/backend/hosts/step-ca-auth.ts | 372 +++++++++++++++++ src/backend/hosts/step-ca-runtime.ts | 172 ++++++++ src/backend/hosts/terminal/index.ts | 28 +- src/backend/tests/hosts/step-ca-auth.test.ts | 188 +++++++++ .../tests/utils/step-ca-client.test.ts | 103 +++++ src/backend/utils/safe-outbound-fetch.ts | 15 + src/backend/utils/step-ca-client.ts | 394 ++++++++++++++++++ src/backend/utils/step-ca-egress.ts | 13 + src/types/host-sidebar-preferences.ts | 5 +- src/types/index.ts | 18 +- src/types/ui-types.ts | 1 + src/ui/api/ai-api.ts | 19 + src/ui/api/settings-api.ts | 33 ++ .../components/proxmox/proxmox-import-auth.ts | 1 + src/ui/dashboard/DashboardTab.tsx | 7 +- src/ui/features/terminal/Terminal.tsx | 4 + src/ui/locales/en.json | 14 + src/ui/sidebar/AdminSettingsPanel.tsx | 55 +++ src/ui/sidebar/AdminSettingsSections.tsx | 79 ++++ src/ui/sidebar/HostEditor.tsx | 21 + src/ui/sidebar/HostEditorFeatureTabs.tsx | 1 + src/ui/sidebar/HostsPanel.tsx | 9 +- src/ui/ssh/dialogs/OPKSSHDialog.tsx | 7 +- 38 files changed, 1791 insertions(+), 48 deletions(-) create mode 100644 src/backend/database/routes/host-step-ca-routes.ts create mode 100644 src/backend/hosts/issued-certificate-auth.ts create mode 100644 src/backend/hosts/step-ca-auth.ts create mode 100644 src/backend/hosts/step-ca-runtime.ts create mode 100644 src/backend/tests/hosts/step-ca-auth.test.ts create mode 100644 src/backend/tests/utils/step-ca-client.test.ts create mode 100644 src/backend/utils/step-ca-client.ts create mode 100644 src/backend/utils/step-ca-egress.ts diff --git a/README.md b/README.md index a854608a..e218cc26 100644 --- a/README.md +++ b/README.md @@ -356,9 +356,12 @@ networks: For multiple Termix backend instances, set the same `REDIS_URL` and optional `TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room -presence, control requests, controller state, and events; keep WebSocket session -affinity enabled because live SSH and remote desktop transports remain attached -to the backend instance that opened them. A single instance needs no Redis. +presence, control requests, controller state, and events. It also routes Step CA +OAuth callbacks back to the instance holding the user's terminal; the optional +`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages. +Keep WebSocket session affinity enabled because live SSH and remote desktop +transports remain attached to the backend instance that opened them. A single +instance needs no Redis. ### Command Line Interface diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 7ad7a69b..3c181e5a 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -18,6 +18,7 @@ services: GUACD_DRIVE_PATH: "/termix-data/rdp-drive" # REDIS_URL: "redis://redis:6379" # TERMIX_REDIS_PREFIX: "termix:collab" + # TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca" depends_on: - guacd-dev networks: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 24ee6e95..dd671bcd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,6 +20,7 @@ services: # in-memory fallback. # REDIS_URL: "redis://redis:6379" # TERMIX_REDIS_PREFIX: "termix:collab" + # TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca" # Hardened deployments can require keys from environment variables or # Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE, # ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE. diff --git a/docs/readme/README-CN.md b/docs/readme/README-CN.md index e54d025a..6f7db684 100644 --- a/docs/readme/README-CN.md +++ b/docs/readme/README-CN.md @@ -351,9 +351,11 @@ networks: ``` 部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置 -`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件; -实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持 -WebSocket 会话亲和性。单实例部署无需 Redis。 +`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件, +也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过 +`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍 +依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。 +单实例部署无需 Redis。 ### 命令行工具 diff --git a/src/backend/ai/tools/catalog.ts b/src/backend/ai/tools/catalog.ts index 7952ae88..5a13061d 100644 --- a/src/backend/ai/tools/catalog.ts +++ b/src/backend/ai/tools/catalog.ts @@ -49,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [ "identity", "certificate", "opkssh", + "stepca", + "step_ca", "acme", "ssl", "audit", diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 8a96fe46..b00f6c19 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -600,13 +600,14 @@ export function registerHostBulkRoutes( "credential", "none", "opkssh", + "stepca", "tailscale", "vault", ].includes(hostData.authType) ) { results.failed++; results.errors.push( - `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`, + `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`, ); continue; } diff --git a/src/backend/database/routes/host-step-ca-routes.ts b/src/backend/database/routes/host-step-ca-routes.ts new file mode 100644 index 00000000..16e9cf8d --- /dev/null +++ b/src/backend/database/routes/host-step-ca-routes.ts @@ -0,0 +1,34 @@ +import type { Request, Response, Router } from "express"; +import { escapeHtml } from "./opkssh-html.js"; + +function resultPage(ok: boolean, message: string): string { + return `Termix + +

${ok ? "Signed in" : "Sign-in failed"}

${escapeHtml(message)}

`; +} + +/** + * The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose: + * the browser that finishes the sign-in may not be the one running Termix, + * so the request is matched to its session by the OAuth state. + */ +export function registerHostStepCaRoutes(router: Router): void { + router.get("/step-ca-callback", async (req: Request, res: Response) => { + const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js"); + const stringQuery = (name: string): string | undefined => { + const value = req.query[name]; + return typeof value === "string" ? value : undefined; + }; + const result = await completeStepCaAuth({ + state: stringQuery("state"), + code: stringQuery("code"), + error: stringQuery("error"), + error_description: stringQuery("error_description"), + }); + res + .status(result.ok ? 200 : 400) + .type("html") + .send(resultPage(result.ok, result.message)); + }); +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 211834a0..5a211794 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -43,6 +43,7 @@ import { } from "./host-normalizers.js"; import { validateParentHostId } from "./host-parent-validation.js"; import { registerHostOpksshRoutes } from "./host-opkssh-routes.js"; +import { registerHostStepCaRoutes } from "./host-step-ca-routes.js"; import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js"; import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js"; @@ -2899,6 +2900,7 @@ router.delete( ); registerHostOpksshRoutes(router); +registerHostStepCaRoutes(router); registerHostNetworkRoutes(router, { authenticateJWT, diff --git a/src/backend/database/routes/opkssh-html.ts b/src/backend/database/routes/opkssh-html.ts index fa94ac3a..f865edfd 100644 --- a/src/backend/database/routes/opkssh-html.ts +++ b/src/backend/database/routes/opkssh-html.ts @@ -1,6 +1,6 @@ import { sshLogger } from "../../utils/logger.js"; -function escapeHtml(value: string): string { +export function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(/ { + /** + * GET/PATCH a comma-list of private hosts an outbound feature may reach. + * Shared by notifications and Step CA; each keeps its own setting key. + */ + const registerPrivateEndpointAllowlist = ( + path: string, + settingKey: string, + auditAction: string, + label: string, + ) => { + router.get(path, authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { if (!(await getAdminActor(userId))) { return res.status(403).json({ error: "Not authorized" }); } - const raw = await createCurrentSettingsRepository().get( - NOTIFICATION_PRIVATE_ALLOWLIST_KEY, - ); + const raw = await createCurrentSettingsRepository().get(settingKey); res.json({ hosts: parseNotificationAllowlist(raw) }); } catch (err) { - authLogger.error("Failed to get notification endpoint allowlist", err); + authLogger.error(`Failed to get ${label} allowlist`, err); res.status(500).json({ error: "Failed to get the allowlist" }); } - }, - ); + }); - router.patch( - "/notification-private-endpoints", - authenticateJWT, - async (req, res) => { + router.patch(path, authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); @@ -1123,7 +1126,6 @@ export function registerUserSettingsRoutes( .status(400) .json({ error: "At most 50 hosts are allowed" }); } - const cleaned: string[] = []; for (const entry of hosts) { if (typeof entry !== "string") { @@ -1142,14 +1144,14 @@ export function registerUserSettingsRoutes( } await createCurrentSettingsRepository().set( - NOTIFICATION_PRIVATE_ALLOWLIST_KEY, + settingKey, JSON.stringify(cleaned), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, - action: "update_notification_private_endpoints", + action: auditAction, resourceType: "setting", details: JSON.stringify({ hosts: cleaned }), ipAddress, @@ -1158,15 +1160,156 @@ export function registerUserSettingsRoutes( }); res.json({ hosts: cleaned }); } catch (err) { - authLogger.error( - "Failed to update notification endpoint allowlist", - err, - ); + authLogger.error(`Failed to update ${label} allowlist`, err); res.status(500).json({ error: "Failed to update the allowlist" }); } - }, + }); + }; + + /** + * @openapi + * /users/notification-private-endpoints: + * get: + * summary: Get the private hosts notification channels may contact (admin only) + * tags: + * - Users + * patch: + * summary: Replace that allowlist (admin only) + * tags: + * - Users + */ + registerPrivateEndpointAllowlist( + "/notification-private-endpoints", + NOTIFICATION_PRIVATE_ALLOWLIST_KEY, + "update_notification_private_endpoints", + "notification endpoint", ); + /** + * @openapi + * /users/step-ca-private-endpoints: + * get: + * summary: Get the private hosts the Step CA certificate flow may contact (admin only) + * tags: + * - Users + * patch: + * summary: Replace that allowlist (admin only) + * tags: + * - Users + */ + registerPrivateEndpointAllowlist( + "/step-ca-private-endpoints", + STEP_CA_PRIVATE_ALLOWLIST_KEY, + "update_step_ca_private_endpoints", + "Step CA endpoint", + ); + + /** + * @openapi + * /users/step-ca-settings: + * get: + * summary: Step CA settings. Admins get the values; everyone else only whether it is configured. + * tags: + * - Users + * patch: + * summary: Set the Step CA URL, root fingerprint and OIDC provisioner (admin only). Empty values clear the configuration. + * tags: + * - Users + */ + router.get("/step-ca-settings", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const { readStepCaSettings } = + await import("../../hosts/step-ca-auth.js"); + const settings = await readStepCaSettings(); + if (!(await getAdminActor(userId))) { + return res.json({ configured: settings !== null }); + } + res.json({ + configured: settings !== null, + caUrl: settings?.caUrl ?? "", + fingerprint: settings?.fingerprint ?? "", + provisioner: settings?.provisioner ?? "", + }); + } catch (err) { + authLogger.error("Failed to get Step CA settings", err); + res.status(500).json({ error: "Failed to get Step CA settings" }); + } + }); + + router.patch("/step-ca-settings", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + const { caUrl, fingerprint, provisioner } = req.body ?? {}; + if ( + [caUrl, fingerprint, provisioner].some((v) => typeof v !== "string") + ) { + return res.status(400).json({ + error: "caUrl, fingerprint and provisioner must be strings", + }); + } + const values = { + caUrl: caUrl.trim(), + fingerprint: fingerprint.trim(), + provisioner: provisioner.trim(), + }; + const clearing = + !values.caUrl && !values.fingerprint && !values.provisioner; + if (!clearing) { + const { normalizeCaUrl, normalizeFingerprint } = + await import("../../utils/step-ca-client.js"); + try { + values.caUrl = normalizeCaUrl(values.caUrl); + values.fingerprint = normalizeFingerprint(values.fingerprint); + } catch (err) { + return res.status(400).json({ error: getErrorMessage(err) }); + } + if (!values.provisioner) { + return res.status(400).json({ error: "provisioner is required" }); + } + } + + const { STEP_CA_SETTING_KEYS } = + await import("../../hosts/step-ca-auth.js"); + const settings = createCurrentSettingsRepository(); + if (clearing) { + await settings.delete(STEP_CA_SETTING_KEYS.url); + await settings.delete(STEP_CA_SETTING_KEYS.fingerprint); + await settings.delete(STEP_CA_SETTING_KEYS.provisioner); + } else { + await settings.set(STEP_CA_SETTING_KEYS.url, values.caUrl); + await settings.set( + STEP_CA_SETTING_KEYS.fingerprint, + values.fingerprint, + ); + await settings.set( + STEP_CA_SETTING_KEYS.provisioner, + values.provisioner, + ); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "update_step_ca_settings", + resourceType: "setting", + details: JSON.stringify({ configured: !clearing, caUrl: values.caUrl }), + ipAddress, + userAgent, + success: true, + }); + res.json({ configured: !clearing, ...values }); + } catch (err) { + authLogger.error("Failed to update Step CA settings", err); + res.status(500).json({ error: "Failed to update Step CA settings" }); + } + }); + /** * @openapi * /users/host-defaults: diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts index 78149f84..3c36ed6c 100644 --- a/src/backend/hosts/docker/routes.ts +++ b/src/backend/hosts/docker/routes.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from "../../utils/error-message.js"; +import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import express from "express"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; @@ -300,7 +301,7 @@ export function registerDockerSshRoutes(app: express.Express): void { if (resolvedCredentials.password) { config.password = resolvedCredentials.password; } - } else if (resolvedCredentials.authType === "opkssh") { + } else if (usesIssuedCertificate(resolvedCredentials.authType)) { try { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index f38c011f..dcce4418 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from "../../utils/error-message.js"; +import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import express from "express"; import { logAudit, @@ -289,7 +290,7 @@ async function buildDedicatedTransferConnectConfig( throw new Error("Password required for transfer connection"); } config.password = host.password; - } else if (authType === "opkssh") { + } else if (usesIssuedCertificate(authType)) { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, host.id); if (!token) { @@ -1120,7 +1121,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { connectionLogs.push( createConnectionLog("info", "sftp_auth", "Using password authentication"), ); - } else if (resolvedCredentials.authType === "opkssh") { + } else if (usesIssuedCertificate(resolvedCredentials.authType)) { try { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); diff --git a/src/backend/hosts/issued-certificate-auth.ts b/src/backend/hosts/issued-certificate-auth.ts new file mode 100644 index 00000000..b5dccdc5 --- /dev/null +++ b/src/backend/hosts/issued-certificate-auth.ts @@ -0,0 +1,12 @@ +/** + * Auth types whose SSH certificate is issued on demand through a browser + * sign-in and cached per user and host (opkssh_tokens). They share the + * whole connect path; only the issuing flow differs. + */ +export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const; + +export function usesIssuedCertificate(authType: string | null | undefined) { + return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes( + authType ?? "", + ); +} diff --git a/src/backend/hosts/metrics/helpers.ts b/src/backend/hosts/metrics/helpers.ts index bd9eb4cb..d10b6282 100644 --- a/src/backend/hosts/metrics/helpers.ts +++ b/src/backend/hosts/metrics/helpers.ts @@ -1,4 +1,5 @@ import type { Client } from "ssh2"; +import { usesIssuedCertificate } from "../issued-certificate-auth.js"; export type StatsCapableHost = { connectionType?: string; @@ -13,7 +14,8 @@ export type TcpPingStatsConfig = { export function supportsMetrics(host: StatsCapableHost): boolean { const connectionType = host.connectionType || "ssh"; if (connectionType !== "ssh") return false; - if (host.authType === "none" || host.authType === "opkssh") return false; + if (host.authType === "none" || usesIssuedCertificate(host.authType)) + return false; return true; } diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index be431cf1..9368c9ca 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from "../../utils/error-message.js"; +import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import express from "express"; import net from "net"; import { createCorsMiddleware } from "../../utils/cors-config.js"; @@ -1396,7 +1397,7 @@ async function buildSshConfig( host.authType === "warpgate" ) { // no credentials needed - } else if (host.authType === "opkssh") { + } else if (usesIssuedCertificate(host.authType)) { // cert auth setup happens in createSshFactory (needs client instance) } else if (host.authType === "vault") { // cert auth setup happens in createSshFactory (needs client instance) @@ -1440,7 +1441,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { const client = new Client(); // Set up OPKSSH cert auth if needed (requires client instance) - if (host.authType === "opkssh" && host.userId) { + if (usesIssuedCertificate(host.authType) && host.userId) { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { @@ -2359,7 +2360,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { const config = await buildSshConfig(host); const client = new Client(); - if (host.authType === "opkssh" && host.userId) { + if (usesIssuedCertificate(host.authType) && host.userId) { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { diff --git a/src/backend/hosts/step-ca-auth.ts b/src/backend/hosts/step-ca-auth.ts new file mode 100644 index 00000000..b3ce0bc8 --- /dev/null +++ b/src/backend/hosts/step-ca-auth.ts @@ -0,0 +1,372 @@ +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 { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js"; +import { + stepCaRuntime, + type StepCaCallbackQuery, + type StepCaCallbackResult, +} from "./step-ca-runtime.js"; +import { + buildAuthorizationUrl, + createPkce, + decodeJwtClaims, + discoverOidcEndpoints, + exchangeCodeForIdToken, + fetchRootCertificate, + findOidcProvisioner, + generateSshKeyPair, + parseSshCertificate, + signSshCertificate, + type StepCaTarget, +} from "../utils/step-ca-client.js"; + +/** + * 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. + */ + +export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback"; +export const STEP_CA_SETTING_KEYS = { + url: "step_ca_url", + fingerprint: "step_ca_fingerprint", + provisioner: "step_ca_provisioner", +} as const; + +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; +const REMOTE_CALLBACK_WAIT_MS = 30_000; +const REMOTE_POLL_MS = 250; + +export interface StepCaSettings { + caUrl: string; + fingerprint: string; + provisioner: string; +} + +export async function readStepCaSettings(): Promise { + const settings = createCurrentSettingsRepository(); + const [caUrl, fingerprint, provisioner] = await Promise.all([ + settings.get(STEP_CA_SETTING_KEYS.url), + settings.get(STEP_CA_SETTING_KEYS.fingerprint), + settings.get(STEP_CA_SETTING_KEYS.provisioner), + ]); + if (!caUrl || !fingerprint || !provisioner) return null; + return { caUrl, fingerprint, provisioner }; +} + +interface StepCaAuthSession { + state: string; + userId: string; + hostId: number; + username: string; + ws: WebSocket; + target: StepCaTarget; + rootPem: string; + clientId: string; + clientSecret?: string; + tokenEndpoint: string; + redirectUri: string; + codeVerifier: string; + nonce: string; + keyPair: { publicKeyLine: string; privateKeyPem: string }; + timeout: NodeJS.Timeout; + commandPoll: NodeJS.Timeout | null; + processing: boolean; + completed: boolean; +} + +const sessions = new Map(); + +function send(ws: WebSocket, message: object): void { + try { + ws.send(JSON.stringify(message)); + } catch { + /* socket already gone */ + } +} + +function endSession(session: StepCaAuthSession, removeRuntime = true): void { + clearTimeout(session.timeout); + if (session.commandPoll) clearInterval(session.commandPoll); + sessions.delete(session.state); + if (removeRuntime) void stepCaRuntime.remove(session.state); +} + +export async function startStepCaAuth( + userId: string, + hostId: number, + username: string, + ws: WebSocket, + requestOrigin: string, +): Promise { + const settings = await readStepCaSettings(); + if (!settings) { + send(ws, { + type: "opkssh_config_error", + requestId: "", + error: + "Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.", + }); + return; + } + + const state = randomBytes(24).toString("base64url"); + try { + const target: StepCaTarget = { + caUrl: settings.caUrl, + fingerprint: settings.fingerprint, + allowedPrivateHosts: await readStepCaPrivateAllowlist(), + }; + const rootPem = await fetchRootCertificate(target); + const provisioner = await findOidcProvisioner( + target, + rootPem, + settings.provisioner, + ); + const endpoints = await discoverOidcEndpoints( + provisioner.configurationEndpoint, + target.allowedPrivateHosts, + ); + const pkce = createPkce(); + const nonce = randomBytes(16).toString("base64url"); + const redirectUri = `${requestOrigin}${STEP_CA_CALLBACK_PATH}`; + + const session: StepCaAuthSession = { + state, + userId, + hostId, + username, + ws, + target, + rootPem, + clientId: provisioner.clientID, + clientSecret: provisioner.clientSecret, + tokenEndpoint: endpoints.tokenEndpoint, + redirectUri, + codeVerifier: pkce.verifier, + nonce, + keyPair: generateSshKeyPair(), + processing: false, + completed: false, + commandPoll: null, + timeout: setTimeout(() => { + const current = sessions.get(state); + if (!current || current.completed || current.processing) return; + send(ws, { type: "opkssh_timeout", requestId: state }); + endSession(current); + }, AUTH_TIMEOUT_MS), + }; + sessions.set(state, session); + await stepCaRuntime.register(state); + session.commandPoll = setInterval(() => { + if (session.processing || session.completed) return; + void stepCaRuntime.takeCommand(state).then(async (query) => { + if (!query || session.processing || session.completed) return; + session.processing = true; + const result = await finishStepCaAuth(session, query, false); + await stepCaRuntime.complete(state, result); + }); + }, REMOTE_POLL_MS); + session.commandPoll.unref(); + ws.once("close", () => { + const current = sessions.get(state); + if (current && !current.completed) endSession(current); + }); + + send(ws, { + type: "opkssh_status", + requestId: state, + stage: "chooser", + label: "Step CA", + url: buildAuthorizationUrl({ + authorizationEndpoint: endpoints.authorizationEndpoint, + clientId: provisioner.clientID, + redirectUri, + state, + nonce, + codeChallenge: pkce.challenge, + }), + providers: [], + }); + } catch (error) { + sshLogger.error("Failed to start Step CA authentication", error, { + operation: "step_ca_start_error", + userId, + hostId, + }); + send(ws, { + type: "opkssh_error", + requestId: state, + error: `Step CA: ${getErrorMessage(error)}`, + }); + } +} + +export function cancelStepCaAuth(requestId: string): boolean { + const session = sessions.get(requestId); + if (!session) return false; + endSession(session); + return true; +} + +/** + * 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. + */ +export async function completeStepCaAuth( + query: StepCaCallbackQuery, +): Promise { + const session = query.state ? sessions.get(query.state) : undefined; + if (!session) { + if (!query.state || !(await stepCaRuntime.submit(query.state, query))) { + return { + ok: false, + message: "This sign-in request is no longer active.", + }; + } + const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS; + while (Date.now() < deadline) { + const result = await stepCaRuntime.takeResult(query.state); + if (result) return result; + await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS)); + } + return { + ok: false, + message: "The Termix instance handling this sign-in did not respond.", + }; + } + if (session.processing || session.completed) { + return { ok: false, message: "This sign-in request was already used." }; + } + session.processing = true; + return finishStepCaAuth(session, query); +} + +async function finishStepCaAuth( + session: StepCaAuthSession, + query: StepCaCallbackQuery, + removeRuntime = true, +): Promise { + if (query.error || !query.code) { + const message = query.error_description || query.error || "Sign-in failed"; + send(session.ws, { + type: "opkssh_error", + requestId: session.state, + error: `Step CA: ${message}`, + }); + endSession(session, removeRuntime); + return { ok: false, message }; + } + + try { + send(session.ws, { + type: "opkssh_status", + requestId: session.state, + stage: "authenticating", + }); + const idToken = await exchangeCodeForIdToken({ + tokenEndpoint: session.tokenEndpoint, + clientId: session.clientId, + clientSecret: session.clientSecret, + code: query.code, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + allowedPrivateHosts: session.target.allowedPrivateHosts, + }); + const claims = decodeJwtClaims(idToken); + if (claims.nonce !== session.nonce) { + throw new Error("The identity token does not match this sign-in"); + } + const email = typeof claims.email === "string" ? claims.email : undefined; + + const certificate = await signSshCertificate( + session.target, + session.rootPem, + { + publicKeyLine: session.keyPair.publicKeyLine, + ott: idToken, + principals: [session.username], + keyId: email ?? session.username, + }, + ); + + const certificateInfo = parseSshCertificate(certificate); + if (certificateInfo.publicKeyLine !== session.keyPair.publicKeyLine) { + throw new Error("The CA returned a certificate for a different key"); + } + if (!certificateInfo.principals.includes(session.username)) { + throw new Error("The CA certificate does not include the host username"); + } + const now = Date.now(); + if ( + certificateInfo.validBefore.getTime() <= now || + certificateInfo.validAfter.getTime() > now + 60_000 + ) { + throw new Error( + "The CA returned a certificate outside its validity window", + ); + } + 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(), + }); + + session.completed = true; + send(session.ws, { + type: "opkssh_completed", + requestId: session.state, + expiresAt: expiresAt.toISOString(), + }); + endSession(session, removeRuntime); + return { ok: true, message: "Signed in. You can close this window." }; + } catch (error) { + sshLogger.error("Step CA certificate issuance failed", error, { + operation: "step_ca_complete_error", + userId: session.userId, + hostId: session.hostId, + }); + const message = getErrorMessage(error); + send(session.ws, { + type: "opkssh_error", + requestId: session.state, + error: `Step CA: ${message}`, + }); + endSession(session, removeRuntime); + return { ok: false, message }; + } +} diff --git a/src/backend/hosts/step-ca-runtime.ts b/src/backend/hosts/step-ca-runtime.ts new file mode 100644 index 00000000..a5edf57f --- /dev/null +++ b/src/backend/hosts/step-ca-runtime.ts @@ -0,0 +1,172 @@ +import { createClient } from "redis"; +import { sshLogger } from "../utils/logger.js"; +import { + decryptSystemSecret, + encryptSystemSecret, +} from "../utils/system-secret-crypto.js"; + +export interface StepCaCallbackQuery { + state?: string; + code?: string; + error?: string; + error_description?: string; +} + +export interface StepCaCallbackResult { + ok: boolean; + message: string; +} + +const PREFIX = + process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca"; +const SESSION_TTL_SECONDS = 5 * 60; +const RESULT_TTL_SECONDS = 60; +const CONNECT_RETRY_MS = 15_000; + +export class StepCaRuntime { + private client: ReturnType | null = null; + private connecting: Promise | null = null; + private nextConnectAttempt = 0; + + async register(state: string): Promise { + if (!(await this.ensureConnected()) || !this.client) return; + await this.client + .set(this.routeKey(state), "active", { EX: SESSION_TTL_SECONDS }) + .catch((error) => this.logFailure("register", error)); + } + + async submit(state: string, query: StepCaCallbackQuery): Promise { + if (!(await this.ensureConnected()) || !this.client) return false; + try { + if (!(await this.client.exists(this.routeKey(state)))) return false; + const encrypted = await encryptSystemSecret(JSON.stringify(query)); + const stored = await this.client.set(this.commandKey(state), encrypted, { + EX: SESSION_TTL_SECONDS, + NX: true, + }); + return stored === "OK"; + } catch (error) { + this.logFailure("submit", error); + return false; + } + } + + async takeCommand(state: string): Promise { + if (!(await this.ensureConnected()) || !this.client) return null; + try { + const encrypted = await this.client.getDel(this.commandKey(state)); + if (!encrypted) return null; + return this.decode( + await decryptSystemSecret(encrypted.toString()), + ); + } catch (error) { + this.logFailure("take_command", error); + return null; + } + } + + async complete(state: string, result: StepCaCallbackResult): Promise { + if (!(await this.ensureConnected()) || !this.client) return; + try { + const encrypted = await encryptSystemSecret(JSON.stringify(result)); + await this.client + .multi() + .set(this.resultKey(state), encrypted, { EX: RESULT_TTL_SECONDS }) + .del(this.routeKey(state)) + .del(this.commandKey(state)) + .exec(); + } catch (error) { + this.logFailure("complete", error); + } + } + + async takeResult(state: string): Promise { + if (!(await this.ensureConnected()) || !this.client) return null; + try { + const encrypted = await this.client.getDel(this.resultKey(state)); + if (!encrypted) return null; + return this.decode( + await decryptSystemSecret(encrypted.toString()), + ); + } catch (error) { + this.logFailure("take_result", error); + return null; + } + } + + async remove(state: string): Promise { + if (!(await this.ensureConnected()) || !this.client) return; + await this.client + .del([ + this.routeKey(state), + this.commandKey(state), + this.resultKey(state), + ]) + .catch((error) => this.logFailure("remove", error)); + } + + async close(): Promise { + if (this.client?.isOpen) await this.client.quit(); + this.client = null; + } + + private async ensureConnected(): Promise { + const url = process.env.REDIS_URL?.trim(); + if (!url) return false; + if (this.client?.isReady) return true; + if (Date.now() < this.nextConnectAttempt) return false; + if (this.connecting) return this.connecting; + this.connecting = this.connect(url).finally(() => { + this.connecting = null; + }); + return this.connecting; + } + + private async connect(url: string): Promise { + try { + this.client = createClient({ + url, + socket: { connectTimeout: 1500, reconnectStrategy: false }, + }); + this.client.on("error", (error) => this.logFailure("client", error)); + await this.client.connect(); + this.nextConnectAttempt = 0; + return true; + } catch (error) { + this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS; + this.logFailure("connect", error); + await this.client?.disconnect().catch(() => {}); + this.client = null; + return false; + } + } + + private routeKey(state: string): string { + return `${PREFIX}:route:${state}`; + } + + private commandKey(state: string): string { + return `${PREFIX}:command:${state}`; + } + + private resultKey(state: string): string { + return `${PREFIX}:result:${state}`; + } + + private decode(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + } + + private logFailure(operation: string, error: unknown): void { + sshLogger.warn("Step CA Redis runtime unavailable", { + operation: `step_ca_redis_${operation}`, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export const stepCaRuntime = new StepCaRuntime(); diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 94a8a152..1292f6a4 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -1,5 +1,6 @@ import { getErrorMessage } from "../../utils/error-message.js"; import { getAuditUsername } from "../../utils/audit-logger.js"; +import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import { collabRoomHub } from "../collab/room-hub.js"; import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js"; import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js"; @@ -1088,6 +1089,17 @@ wss.on("connection", async (ws: WebSocket, req) => { } const hostname = host.name || host.ip; const requestOrigin = getRequestOrigin(req); + if (host.authType === "stepca") { + const { startStepCaAuth } = await import("../step-ca-auth.js"); + await startStepCaAuth( + userId, + opksshData.hostId, + host.username, + ws, + requestOrigin, + ); + break; + } await startOPKSSHAuth( userId, opksshData.hostId, @@ -1113,6 +1125,12 @@ wss.on("connection", async (ws: WebSocket, req) => { } case "opkssh_cancel": { + { + const { cancelStepCaAuth } = await import("../step-ca-auth.js"); + cancelStepCaAuth( + String((data as { requestId?: string })?.requestId ?? ""), + ); + } const cancelData = data as { requestId: string }; try { const { cancelAuthSession } = await import("../opkssh-auth.js"); @@ -2457,7 +2475,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }); if ( - resolvedCredentials.authType === "opkssh" && + usesIssuedCertificate(resolvedCredentials.authType) && err.message.includes("All configured authentication methods failed") ) { sshLogger.warn("OPKSSH authentication failed - invalidating token", { @@ -3064,8 +3082,8 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); return; - } else if (resolvedCredentials.authType === "opkssh") { - sendLog("auth", "info", "Using OPKSSH certificate authentication"); + } else if (usesIssuedCertificate(resolvedCredentials.authType)) { + sendLog("auth", "info", "Using issued SSH certificate authentication"); try { const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, id); @@ -3074,7 +3092,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog( "auth", "info", - "No valid OPKSSH token found, requesting authentication", + "No valid certificate found, requesting sign-in", ); ws.send( JSON.stringify({ @@ -3085,7 +3103,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sendLog("auth", "info", "Using cached OPKSSH certificate"); + sendLog("auth", "info", "Using cached SSH certificate"); const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth(connectConfig, sshConn, token, username); diff --git a/src/backend/tests/hosts/step-ca-auth.test.ts b/src/backend/tests/hosts/step-ca-auth.test.ts new file mode 100644 index 00000000..d3ffc4fe --- /dev/null +++ b/src/backend/tests/hosts/step-ca-auth.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WebSocket } from "ws"; + +const state = vi.hoisted(() => ({ + sent: [] as Array>, + upsert: vi.fn(async () => undefined), + parseInfo: { + publicKeyLine: "ssh-ed25519 TESTKEY", + principals: ["alice"], + validAfter: new Date(Date.now() - 60_000), + validBefore: new Date(Date.now() + 60 * 60_000), + }, + idToken: "", + command: null as Record | null, + remoteResult: null as { ok: boolean; message: string } | null, + runtimeComplete: vi.fn(async () => undefined), +})); + +vi.mock("../../utils/logger.js", () => ({ + sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) }, +})); +vi.mock("../../utils/field-crypto.js", () => ({ + FieldCrypto: { + encryptField: (value: string, _key: Buffer, _id: string, field: string) => + `${field}:${value}`, + }, +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => + ({ + step_ca_url: "https://ca.example", + step_ca_fingerprint: "a".repeat(64), + step_ca_provisioner: "oidc", + })[key], + }), + createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }), +})); +vi.mock("../../utils/step-ca-egress.js", () => ({ + readStepCaPrivateAllowlist: async () => [], +})); +vi.mock("../../utils/step-ca-client.js", () => ({ + fetchRootCertificate: async () => "ROOT", + findOidcProvisioner: async () => ({ + clientID: "client", + configurationEndpoint: "https://idp.example/.well-known/openid", + }), + discoverOidcEndpoints: async () => ({ + authorizationEndpoint: "https://idp.example/auth", + tokenEndpoint: "https://idp.example/token", + }), + createPkce: () => ({ verifier: "verifier", challenge: "challenge" }), + generateSshKeyPair: () => ({ + publicKeyLine: "ssh-ed25519 TESTKEY", + privateKeyPem: "PRIVATE", + }), + buildAuthorizationUrl: (input: Record) => { + const url = new URL("https://idp.example/auth"); + url.searchParams.set("state", input.state); + url.searchParams.set("nonce", input.nonce); + return url.toString(); + }, + exchangeCodeForIdToken: async () => state.idToken, + decodeJwtClaims: (token: string) => { + const payload = token.split(".")[1]; + return payload + ? JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) + : {}; + }, + signSshCertificate: async () => "CERT", + parseSshCertificate: () => state.parseInfo, +})); +vi.mock("../../hosts/step-ca-runtime.js", () => ({ + stepCaRuntime: { + register: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + submit: vi.fn(async () => true), + takeCommand: vi.fn(async () => { + const command = state.command; + state.command = null; + return command; + }), + complete: state.runtimeComplete, + takeResult: vi.fn(async () => { + const result = state.remoteResult; + state.remoteResult = null; + return result; + }), + }, +})); + +const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } = + await import("../../hosts/step-ca-auth.js"); + +function jwt(payload: object): string { + return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`; +} + +function fakeWs(): WebSocket { + return { + send: (raw: string) => state.sent.push(JSON.parse(raw)), + once: vi.fn(), + } as unknown as WebSocket; +} + +async function start(): Promise<{ requestId: string; nonce: string }> { + await startStepCaAuth( + "user-1", + 7, + "alice", + fakeWs(), + "https://termix.example", + ); + const chooser = state.sent.at(-1)!; + const url = new URL(String(chooser.url)); + return { + requestId: String(chooser.requestId), + nonce: url.searchParams.get("nonce")!, + }; +} + +beforeEach(() => { + state.sent.length = 0; + state.upsert.mockClear(); + state.runtimeComplete.mockClear(); + state.command = null; + state.remoteResult = null; + state.parseInfo = { + publicKeyLine: "ssh-ed25519 TESTKEY", + principals: ["alice"], + validAfter: new Date(Date.now() - 60_000), + validBefore: new Date(Date.now() + 60 * 60_000), + }; +}); + +describe("Step CA authentication", () => { + it("stores only a certificate bound to the nonce, key, principal and validity window", async () => { + const { requestId, nonce } = await start(); + state.idToken = jwt({ nonce, email: "alice@example.com" }); + + await expect( + completeStepCaAuth({ state: requestId, code: "code" }), + ).resolves.toEqual({ + ok: true, + message: "Signed in. You can close this window.", + }); + expect(state.upsert).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", hostId: 7 }), + ); + }); + + it("rejects a token without the requested nonce", async () => { + const { requestId } = await start(); + state.idToken = jwt({ email: "alice@example.com" }); + + const result = await completeStepCaAuth({ state: requestId, code: "code" }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/does not match/); + expect(state.upsert).not.toHaveBeenCalled(); + }); + + it("routes a callback through Redis to the instance holding the WebSocket", async () => { + const { requestId, nonce } = await start(); + state.idToken = jwt({ nonce }); + state.command = { state: requestId, code: "remote-code" }; + + await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), { + timeout: 2000, + }); + expect(state.upsert).toHaveBeenCalled(); + }); + + it("returns a result produced by another instance", async () => { + state.remoteResult = { ok: true, message: "remote success" }; + await expect( + completeStepCaAuth({ state: "remote-state", code: "code" }), + ).resolves.toEqual({ ok: true, message: "remote success" }); + }); + + it("cancels local sessions", async () => { + const { requestId } = await start(); + expect(cancelStepCaAuth(requestId)).toBe(true); + expect(cancelStepCaAuth(requestId)).toBe(false); + }); +}); diff --git a/src/backend/tests/utils/step-ca-client.test.ts b/src/backend/tests/utils/step-ca-client.test.ts new file mode 100644 index 00000000..ff4147de --- /dev/null +++ b/src/backend/tests/utils/step-ca-client.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import crypto from "crypto"; +import { + buildAuthorizationUrl, + certificateFingerprint, + createPkce, + decodeJwtClaims, + generateSshKeyPair, + normalizeCaUrl, + normalizeFingerprint, + parseSshCertificate, +} from "../../utils/step-ca-client.js"; +import { + generateCa, + signUserCertificate, +} from "../../database/routes/ssh-certificate.js"; + +describe("step-ca client helpers", () => { + it("normalizes the CA url and fingerprint the way step does", () => { + expect(normalizeCaUrl(" https://ca.internal:9000/ ")).toBe( + "https://ca.internal:9000", + ); + expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/); + const fp = "AB:cd".repeat(16).replace(/:/g, "") + ""; + expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase()); + expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/); + }); + + it("fingerprints a PEM certificate by the sha256 of its DER", () => { + const der = crypto.randomBytes(64); + const pem = `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`; + expect(certificateFingerprint(pem)).toBe( + crypto.createHash("sha256").update(der).digest("hex"), + ); + }); + + it("builds a PKCE authorization request", () => { + const { verifier, challenge } = createPkce(); + expect(challenge).toBe( + crypto.createHash("sha256").update(verifier).digest("base64url"), + ); + const url = new URL( + buildAuthorizationUrl({ + authorizationEndpoint: "https://idp.example/auth?tenant=x", + clientId: "cid", + redirectUri: "https://termix.example/callback", + state: "s", + nonce: "n", + codeChallenge: challenge, + }), + ); + expect(url.searchParams.get("tenant")).toBe("x"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("scope")).toContain("openid"); + }); + + it("generates an ed25519 key whose public line a CA can certify, and reads the cert back", () => { + const { publicKeyLine, privateKeyPem } = generateSshKeyPair(); + expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/); + expect(privateKeyPem).toContain("BEGIN PRIVATE KEY"); + + const ca = generateCa(); + const cert = signUserCertificate({ + userPublicKeyLine: publicKeyLine, + caPrivateKeyPem: ca.privateKeyPem, + caPublicKeyLine: ca.publicKeyLine, + keyId: "alice@example", + principals: ["alice", "ops"], + validAfter: 1_700_000_000, + validBefore: 1_700_057_600, + }); + expect(cert).not.toBeNull(); + const info = parseSshCertificate(cert!); + expect(info).toMatchObject({ + keyType: "ssh-ed25519-cert-v01@openssh.com", + publicKeyLine, + keyId: "alice@example", + principals: ["alice", "ops"], + }); + expect(info.validAfter.toISOString()).toBe("2023-11-14T22:13:20.000Z"); + expect(info.validBefore.getTime() - info.validAfter.getTime()).toBe( + 16 * 3600 * 1000, + ); + expect(() => parseSshCertificate(publicKeyLine)).toThrow(/certificate/); + expect(() => + parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"), + ).toThrow(/certificate/); + }); +}); + +describe("decodeJwtClaims", () => { + it("reads the payload without verifying and tolerates junk", () => { + const payload = Buffer.from( + JSON.stringify({ email: "a@b.c", nonce: "n1" }), + ).toString("base64url"); + expect(decodeJwtClaims(`x.${payload}.y`)).toEqual({ + email: "a@b.c", + nonce: "n1", + }); + expect(decodeJwtClaims("not-a-jwt")).toEqual({}); + }); +}); diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts index 0ebf4262..23d7cc38 100644 --- a/src/backend/utils/safe-outbound-fetch.ts +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -147,10 +147,21 @@ export function createDnsLookupHook( }; } +export interface OutboundTlsOptions { + /** PEM bundle to trust instead of the system store (private CAs). */ + ca?: string; + /** + * Skip certificate verification. Only for the one request that fetches a + * private CA's root by fingerprint, where the caller verifies the result. + */ + rejectUnauthorized?: boolean; +} + export async function safeOutboundFetch( rawUrl: string, options: RequestInit, allowedPrivateHosts: readonly string[] = [], + tls: OutboundTlsOptions = {}, ): Promise { const url = new URL(rawUrl); if ( @@ -172,6 +183,10 @@ export async function safeOutboundFetch( const dispatcher = new Agent({ connect: { lookup: createDnsLookupHook(lookup, allowPrivate), + ...(tls.ca ? { ca: tls.ca } : {}), + ...(tls.rejectUnauthorized === false + ? { rejectUnauthorized: false } + : {}), }, }); diff --git a/src/backend/utils/step-ca-client.ts b/src/backend/utils/step-ca-client.ts new file mode 100644 index 00000000..ce54d720 --- /dev/null +++ b/src/backend/utils/step-ca-client.ts @@ -0,0 +1,394 @@ +import crypto from "crypto"; +import { safeOutboundFetch } from "./safe-outbound-fetch.js"; + +/** + * A minimal client for smallstep's step-ca SSH user-certificate flow, done + * over its HTTP API rather than the `step` binary: + * + * 1. bootstrap the CA's root certificate by fingerprint (GET /root/{fp}) + * 2. read the OIDC provisioner's client settings (GET /provisioners) + * 3. run the OIDC authorization-code flow against the provider + * 4. POST the id_token as the one-time token to /1.0/ssh/sign + */ + +export interface StepCaTarget { + caUrl: string; + fingerprint: string; + /** Hosts the SSRF guard may reach even when they resolve to private ranges. */ + allowedPrivateHosts: readonly string[]; +} + +export interface StepCaOidcProvisioner { + name: string; + clientID: string; + clientSecret?: string; + configurationEndpoint: string; +} + +export interface OidcEndpoints { + authorizationEndpoint: string; + tokenEndpoint: string; +} + +const FETCH_TIMEOUT_MS = 15_000; + +/** Display-only claims; the CA is the component that verifies the token. */ +export function decodeJwtClaims(token: string): Record { + const payload = token.split(".")[1]; + if (!payload) return {}; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return {}; + } +} + +export function normalizeCaUrl(raw: string): string { + const url = new URL(raw.trim()); + if (url.protocol !== "https:") { + throw new Error("Step CA URL must use https"); + } + return url.toString().replace(/\/+$/, ""); +} + +export function normalizeFingerprint(raw: string): string { + const hex = raw.replace(/[^0-9a-fA-F]/g, "").toLowerCase(); + if (hex.length !== 64) { + throw new Error("CA fingerprint must be a SHA-256 hex digest"); + } + return hex; +} + +export function pemToDer(pem: string): Buffer { + const body = pem + .replace(/-----BEGIN [^-]+-----/g, "") + .replace(/-----END [^-]+-----/g, "") + .replace(/\s+/g, ""); + return Buffer.from(body, "base64"); +} + +export function certificateFingerprint(pem: string): string { + return crypto.createHash("sha256").update(pemToDer(pem)).digest("hex"); +} + +async function readJson(response: Response, what: string): Promise { + if (!response.ok) { + throw new Error(`${what} failed: HTTP ${response.status}`); + } + return (await response.json()) as T; +} + +/** + * The root endpoint is served under the CA's own TLS certificate, which + * nothing trusts yet - so this one request skips verification and trusts + * the fingerprint instead, exactly like `step ca bootstrap`. + */ +export async function fetchRootCertificate( + target: StepCaTarget, +): Promise { + const fingerprint = normalizeFingerprint(target.fingerprint); + const response = await safeOutboundFetch( + `${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`, + { method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, + target.allowedPrivateHosts, + { rejectUnauthorized: false }, + ); + const { ca } = await readJson<{ ca?: string }>( + response, + "Fetching the CA root", + ); + if (!ca || certificateFingerprint(ca) !== fingerprint) { + throw new Error("CA root certificate does not match the fingerprint"); + } + return ca; +} + +export async function findOidcProvisioner( + target: StepCaTarget, + rootPem: string, + name: string, +): Promise { + const base = normalizeCaUrl(target.caUrl); + let cursor = ""; + for (let page = 0; page < 20; page++) { + const response = await safeOutboundFetch( + `${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + { method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, + target.allowedPrivateHosts, + { ca: rootPem }, + ); + const body = await readJson<{ + provisioners?: Array>; + nextCursor?: string; + }>(response, "Listing CA provisioners"); + const match = (body.provisioners ?? []).find( + (p) => p.name === name && p.type === "OIDC", + ); + if (match) { + if ( + typeof match.clientID !== "string" || + typeof match.configurationEndpoint !== "string" + ) { + throw new Error("The OIDC provisioner is missing its client settings"); + } + return { + name, + clientID: match.clientID, + clientSecret: + typeof match.clientSecret === "string" + ? match.clientSecret + : undefined, + configurationEndpoint: match.configurationEndpoint, + }; + } + if (!body.nextCursor) break; + cursor = body.nextCursor; + } + throw new Error(`OIDC provisioner "${name}" not found on the CA`); +} + +export async function discoverOidcEndpoints( + configurationEndpoint: string, + allowedPrivateHosts: readonly string[], +): Promise { + const response = await safeOutboundFetch( + configurationEndpoint, + { method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, + allowedPrivateHosts, + ); + const doc = await readJson<{ + authorization_endpoint?: string; + token_endpoint?: string; + }>(response, "OIDC discovery"); + if (!doc.authorization_endpoint || !doc.token_endpoint) { + throw new Error("OIDC discovery document is incomplete"); + } + return { + authorizationEndpoint: doc.authorization_endpoint, + tokenEndpoint: doc.token_endpoint, + }; +} + +export function createPkce(): { verifier: string; challenge: string } { + const verifier = crypto.randomBytes(48).toString("base64url"); + const challenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; +} + +export function buildAuthorizationUrl(input: { + authorizationEndpoint: string; + clientId: string; + redirectUri: string; + state: string; + nonce: string; + codeChallenge: string; +}): string { + const url = new URL(input.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("state", input.state); + url.searchParams.set("nonce", input.nonce); + url.searchParams.set("code_challenge", input.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export async function exchangeCodeForIdToken(input: { + tokenEndpoint: string; + clientId: string; + clientSecret?: string; + code: string; + redirectUri: string; + codeVerifier: string; + allowedPrivateHosts: readonly string[]; +}): Promise { + const form = new URLSearchParams({ + grant_type: "authorization_code", + code: input.code, + redirect_uri: input.redirectUri, + client_id: input.clientId, + code_verifier: input.codeVerifier, + }); + if (input.clientSecret) form.set("client_secret", input.clientSecret); + const response = await safeOutboundFetch( + input.tokenEndpoint, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }, + input.allowedPrivateHosts, + ); + const body = await readJson<{ id_token?: string }>( + response, + "OIDC token exchange", + ); + if (!body.id_token) { + throw new Error("The identity provider returned no id_token"); + } + return body.id_token; +} + +// --- SSH keys and certificates --------------------------------------------- + +function sshString(value: Buffer | string): Buffer { + const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8"); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + return Buffer.concat([len, data]); +} + +export function generateSshKeyPair(): { + publicKeyLine: string; + privateKeyPem: string; +} { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const jwk = publicKey.export({ format: "jwk" }) as { x: string }; + const raw = Buffer.from(jwk.x, "base64url"); + const blob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]); + return { + publicKeyLine: `ssh-ed25519 ${blob.toString("base64")}`, + privateKeyPem: privateKey + .export({ format: "pem", type: "pkcs8" }) + .toString(), + }; +} + +export async function signSshCertificate( + target: StepCaTarget, + rootPem: string, + input: { + publicKeyLine: string; + ott: string; + principals: string[]; + keyId: string; + }, +): Promise { + const blob = input.publicKeyLine.trim().split(/\s+/)[1]; + if (!blob) throw new Error("Invalid public key line"); + const response = await safeOutboundFetch( + `${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + publicKey: blob, + ott: input.ott, + certType: "user", + principals: input.principals, + keyID: input.keyId, + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }, + target.allowedPrivateHosts, + { ca: rootPem }, + ); + if (!response.ok) { + let detail = ""; + try { + const err = (await response.json()) as { message?: string }; + detail = err.message ? `: ${err.message}` : ""; + } catch { + /* no body */ + } + throw new Error( + `The CA refused to sign the key (HTTP ${response.status})${detail}`, + ); + } + const body = (await response.json()) as { crt?: string }; + if (!body.crt) throw new Error("The CA returned no certificate"); + return body.crt.trim(); +} + +export interface SshCertificateInfo { + keyType: string; + publicKeyLine: string; + keyId: string; + principals: string[]; + validAfter: Date; + validBefore: Date; +} + +/** Reads the identity and validity window out of an OpenSSH certificate line. */ +export function parseSshCertificate(line: string): SshCertificateInfo { + const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64"); + if (blob.length === 0) throw new Error("Invalid SSH certificate"); + let offset = 0; + const readString = (): Buffer => { + if (offset + 4 > blob.length) throw new Error("Truncated SSH certificate"); + const len = blob.readUInt32BE(offset); + offset += 4; + if (offset + len > blob.length) { + throw new Error("Truncated SSH certificate"); + } + const value = blob.subarray(offset, offset + len); + offset += len; + return value; + }; + const readUint64 = (): bigint => { + if (offset + 8 > blob.length) throw new Error("Truncated SSH certificate"); + const value = blob.readBigUInt64BE(offset); + offset += 8; + return value; + }; + + const keyType = readString().toString(); + if (!keyType.endsWith("-cert-v01@openssh.com")) { + throw new Error("The CA returned a public key instead of a certificate"); + } + readString(); // nonce + // Public key fields differ by algorithm; consume them by shape. + const publicKeyParts: Buffer[] = []; + let plainKeyType: string; + if (keyType.startsWith("ssh-rsa")) { + plainKeyType = "ssh-rsa"; + publicKeyParts.push(readString(), readString()); // e, n + } else if (keyType.startsWith("ecdsa-")) { + plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, ""); + publicKeyParts.push(readString(), readString()); // curve, Q + } else { + plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, ""); + publicKeyParts.push(readString()); // ed25519 pk + } + const publicKeyBlob = Buffer.concat([ + sshString(plainKeyType), + ...publicKeyParts.map(sshString), + ]); + readUint64(); // serial + offset += 4; // type + const keyId = readString().toString(); + const principalsBlob = readString(); + const principals: string[] = []; + for (let p = 0; p < principalsBlob.length;) { + if (p + 4 > principalsBlob.length) { + throw new Error("Invalid SSH certificate principals"); + } + const len = principalsBlob.readUInt32BE(p); + if (p + 4 + len > principalsBlob.length) { + throw new Error("Invalid SSH certificate principals"); + } + principals.push(principalsBlob.subarray(p + 4, p + 4 + len).toString()); + p += 4 + len; + } + const validAfter = readUint64(); + const validBefore = readUint64(); + const toDate = (seconds: bigint) => + new Date( + Number(seconds > 8_640_000_000_000n ? 8_640_000_000_000n : seconds) * + 1000, + ); + return { + keyType, + publicKeyLine: `${plainKeyType} ${publicKeyBlob.toString("base64")}`, + keyId, + principals, + validAfter: toDate(validAfter), + validBefore: toDate(validBefore), + }; +} diff --git a/src/backend/utils/step-ca-egress.ts b/src/backend/utils/step-ca-egress.ts new file mode 100644 index 00000000..69e0bad5 --- /dev/null +++ b/src/backend/utils/step-ca-egress.ts @@ -0,0 +1,13 @@ +import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; +import { parseNotificationAllowlist } from "./notification-egress.js"; + +/** Private hosts (a CA on the LAN, an internal IdP) the Step CA flow may reach. */ +export const STEP_CA_PRIVATE_ALLOWLIST_KEY = + "step_ca_private_endpoint_allowlist"; + +export async function readStepCaPrivateAllowlist(): Promise { + const raw = await createCurrentSettingsRepository().get( + STEP_CA_PRIVATE_ALLOWLIST_KEY, + ); + return parseNotificationAllowlist(raw); +} diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index b9601e75..3013559b 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -34,7 +34,9 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; export interface HostSidebarFilterState { status: ("online" | "offline" | "pinned")[]; - authType: ("password" | "key" | "credential" | "none" | "opkssh")[]; + authType: ( + "password" | "key" | "credential" | "none" | "opkssh" | "stepca" + )[]; protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; tags: string[]; @@ -94,6 +96,7 @@ const FILTER_AUTH_TYPE: HostSidebarFilterState["authType"] = [ "credential", "none", "opkssh", + "stepca", ]; const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [ "ssh", diff --git a/src/types/index.ts b/src/types/index.ts index 0ffa2b68..e40b5484 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,7 +68,13 @@ export interface LDAPProviderConfig { export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; export type SSHAuthType = - "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "stepca" + | "tailscale"; export type GuacamoleAuthType = "password" | "credential"; @@ -133,6 +139,7 @@ export type Host = { | "credential" | "none" | "opkssh" + | "stepca" | "tailscale" | "agent" | "vault"; @@ -298,6 +305,7 @@ export interface HostData { | "credential" | "none" | "opkssh" + | "stepca" | "tailscale" | "agent" | "vault"; @@ -832,7 +840,13 @@ export type ErrorType = // ============================================================================ export type AuthType = - "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "stepca" + | "tailscale"; export type KeyType = "rsa" | "ecdsa" | "ed25519"; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index f5e86ae2..ae568324 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -31,6 +31,7 @@ export type Host = { | "credential" | "none" | "opkssh" + | "stepca" | "tailscale" | "vault" | "agent"; diff --git a/src/ui/api/ai-api.ts b/src/ui/api/ai-api.ts index d28609fd..61629cbe 100644 --- a/src/ui/api/ai-api.ts +++ b/src/ui/api/ai-api.ts @@ -226,6 +226,25 @@ export async function getNotificationPrivateEndpoints(): Promise { } } +export async function getStepCaPrivateEndpoints(): Promise { + try { + return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts; + } catch (error) { + throw handleApiError(error, "get Step CA endpoint allowlist"); + } +} + +export async function setStepCaPrivateEndpoints( + hosts: string[], +): Promise { + try { + return (await authApi.patch("/users/step-ca-private-endpoints", { hosts })) + .data.hosts; + } catch (error) { + throw handleApiError(error, "update Step CA endpoint allowlist"); + } +} + export async function setNotificationPrivateEndpoints( hosts: string[], ): Promise { diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index a11824c2..8c9659d8 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -85,6 +85,39 @@ export async function updateTerminalSessionSettings(input: { } } +export interface StepCaSettings { + configured: boolean; + caUrl: string; + fingerprint: string; + provisioner: string; +} + +export async function getStepCaSettings(): Promise { + try { + const response = await authApi.get("/users/step-ca-settings"); + return { + configured: !!response.data.configured, + caUrl: response.data.caUrl ?? "", + fingerprint: response.data.fingerprint ?? "", + provisioner: response.data.provisioner ?? "", + }; + } catch (error) { + handleApiError(error, "fetch Step CA settings"); + } +} + +export async function updateStepCaSettings(input: { + caUrl: string; + fingerprint: string; + provisioner: string; +}): Promise { + try { + await authApi.patch("/users/step-ca-settings", input); + } catch (error) { + handleApiError(error, "update Step CA settings"); + } +} + export async function updateSessionTimeout( timeoutHours: number, ): Promise { diff --git a/src/ui/components/proxmox/proxmox-import-auth.ts b/src/ui/components/proxmox/proxmox-import-auth.ts index 4aaed4ec..77293ca1 100644 --- a/src/ui/components/proxmox/proxmox-import-auth.ts +++ b/src/ui/components/proxmox/proxmox-import-auth.ts @@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([ "none", "agent", "opkssh", + "stepca", "tailscale", "vault", ]); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 047de057..f321fd0a 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -1357,7 +1357,12 @@ export function DashboardTab({ const hostId = Number(host.id); const knownStatus = statuses?.[hostId]?.status; if (knownStatus === "offline") return null; - if (host.authType === "none" || host.authType === "opkssh") return null; + if ( + host.authType === "none" || + host.authType === "opkssh" || + host.authType === "stepca" + ) + return null; try { const existing = newSessions.get(hostId); diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 6a62f2e7..90e0e08f 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -270,6 +270,8 @@ const TerminalInner = forwardRef( stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; error?: string; providers?: Array<{ alias: string; issuer: string }>; + /** Which issuer is asking (OPKSSH by default, "Step CA", ...). */ + label?: string; } | null>(null); const opksshTimeoutRef = useRef(null); @@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef( requestId: msg.requestId || "", stage: "chooser", providers: msg.providers, + label: typeof msg.label === "string" ? msg.label : undefined, }); if (opksshTimeoutRef.current) { clearTimeout(opksshTimeoutRef.current); @@ -3598,6 +3601,7 @@ const TerminalInner = forwardRef( stage={opksshDialog.stage} error={opksshDialog.error} providers={opksshDialog.providers} + label={opksshDialog.label} onCancel={() => { if (webSocketRef.current) { webSocketRef.current.send( diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 2a2f7bcc..17392441 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -920,6 +920,9 @@ "keyPassphraseSaved": "Passphrase saved, type to change", "replaceKey": "Replace key", "docsLink": "View docs", + "stepcaLabel": "Step CA", + "stepcaDesc": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator under Admin Settings.", + "authTypeStepca": "Step CA", "opksshLabel": "OPKSSH", "opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.", "warpgateLabel": "Warpgate Gateway", @@ -1503,6 +1506,7 @@ "filterAuthCredential": "Credential", "filterAuthNone": "None", "filterAuthOpkssh": "OPKSSH", + "filterAuthStepca": "Step CA", "filterProtocolGroup": "Protocol", "filterProtocolSsh": "SSH", "filterProtocolRdp": "RDP", @@ -2068,6 +2072,7 @@ "warpgateAuthUrl": "Authentication URL", "warpgateOpenBrowser": "Open in Browser", "warpgateContinue": "I've Completed Authentication", + "certAuthRequired": "{{provider}} Sign-in Required", "opksshAuthRequired": "OPKSSH Authentication Required", "opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.", "opksshOpenBrowser": "Open Browser to Authenticate", @@ -3689,6 +3694,15 @@ "aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.", "aiPrivateEndpoints": "Allowed private AI hosts", "aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.", + "stepCa": "Step CA", + "stepCaDesc": "Issue short-lived SSH certificates from a smallstep CA. Enter the CA URL, its root fingerprint (as shown by step ca bootstrap) and the name of the OIDC provisioner; then choose \"Step CA\" as a host's authentication type. Leave all three empty to disable.", + "stepCaFingerprint": "Root fingerprint (SHA-256)", + "stepCaProvisioner": "OIDC provisioner name", + "stepCaSaved": "Step CA settings saved", + "stepCaSaveFailed": "Failed to save Step CA settings", + "stepCaPrivateEndpoints": "Allowed private Step CA hosts", + "stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.", + "updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist", "notificationPrivateEndpoints": "Allowed private notification hosts", "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 9ece3214..2ae81e28 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -5,6 +5,8 @@ import { getAiGloballyEnabled, getAiPrivateEndpoints, getNotificationPrivateEndpoints, + getStepCaPrivateEndpoints, + setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi, setAiGloballyEnabled as setAiGloballyEnabledApi, setAiPrivateEndpoints as setAiPrivateEndpointsApi, setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi, @@ -97,6 +99,8 @@ import { toast } from "sonner"; import { getTerminalSessionSettings, updateTerminalSessionSettings, + getStepCaSettings, + updateStepCaSettings, } from "@/api/settings-api"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { @@ -181,6 +185,25 @@ export function AdminSettingsPanel({ useState(true); const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false); const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState([]); + const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState< + string[] + >([]); + const [stepCaSettings, setStepCaSettings] = useState({ + caUrl: "", + fingerprint: "", + provisioner: "", + }); + useEffect(() => { + getStepCaSettings() + .then((s) => + setStepCaSettings({ + caUrl: s.caUrl, + fingerprint: s.fingerprint, + provisioner: s.provisioner, + }), + ) + .catch(() => {}); + }, []); const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] = useState([]); const [hostDefaults, setHostDefaults] = useState({}); @@ -380,6 +403,7 @@ export function AdminSettingsPanel({ aiEnabled, aiEndpoints, notificationEndpoints, + stepCaEndpoints, imageStorage, ] = await Promise.allSettled([ getRegistrationAllowed(), @@ -399,6 +423,7 @@ export function AdminSettingsPanel({ getAiGloballyEnabled(), getAiPrivateEndpoints(), getNotificationPrivateEndpoints(), + getStepCaPrivateEndpoints(), getTerminalImageStorageSettings(), ]); @@ -451,6 +476,9 @@ export function AdminSettingsPanel({ if (aiEndpoints.status === "fulfilled") { setAiPrivateEndpoints(aiEndpoints.value); } + if (stepCaEndpoints.status === "fulfilled") { + setStepCaPrivateEndpoints(stepCaEndpoints.value); + } if (notificationEndpoints.status === "fulfilled") { setNotificationPrivateEndpoints(notificationEndpoints.value); } @@ -613,6 +641,28 @@ export function AdminSettingsPanel({ } } + async function handleSaveStepCaSettings() { + try { + await updateStepCaSettings(stepCaSettings); + toast.success(t("admin.stepCaSaved")); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("admin.stepCaSaveFailed"), + ); + } + } + + async function handleSaveStepCaPrivateEndpoints(hosts: string[]) { + const previous = stepCaPrivateEndpoints; + setStepCaPrivateEndpoints(hosts); + try { + setStepCaPrivateEndpoints(await setStepCaPrivateEndpointsApi(hosts)); + } catch { + setStepCaPrivateEndpoints(previous); + toast.error(t("admin.updateStepCaEndpointsFailed")); + } + } + async function handleSaveNotificationPrivateEndpoints(hosts: string[]) { const previous = notificationPrivateEndpoints; setNotificationPrivateEndpoints(hosts); @@ -1166,6 +1216,11 @@ export function AdminSettingsPanel({ aiPrivateEndpoints={aiPrivateEndpoints} onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints} notificationPrivateEndpoints={notificationPrivateEndpoints} + stepCaPrivateEndpoints={stepCaPrivateEndpoints} + onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints} + stepCaSettings={stepCaSettings} + setStepCaSettings={setStepCaSettings} + handleSaveStepCaSettings={handleSaveStepCaSettings} onSaveNotificationPrivateEndpoints={ handleSaveNotificationPrivateEndpoints } diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 38dab945..5de229cb 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -33,6 +33,13 @@ type GeneralSettingsSectionProps = { onSaveAiPrivateEndpoints: (hosts: string[]) => void; notificationPrivateEndpoints: string[]; onSaveNotificationPrivateEndpoints: (hosts: string[]) => void; + stepCaPrivateEndpoints: string[]; + onSaveStepCaPrivateEndpoints: (hosts: string[]) => void; + stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string }; + setStepCaSettings: Dispatch< + SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }> + >; + handleSaveStepCaSettings: () => void; handleToggleSessionSharingGloballyEnabled: () => void; allowRegistration: boolean; handleToggleRegistration: () => void; @@ -87,6 +94,11 @@ export function AdminGeneralSettingsSection({ onSaveAiPrivateEndpoints, notificationPrivateEndpoints, onSaveNotificationPrivateEndpoints, + stepCaPrivateEndpoints, + onSaveStepCaPrivateEndpoints, + stepCaSettings, + setStepCaSettings, + handleSaveStepCaSettings, handleToggleSessionSharingGloballyEnabled, allowRegistration, handleToggleRegistration, @@ -215,6 +227,73 @@ export function AdminGeneralSettingsSection({ } /> +
+ + {t("admin.stepCaPrivateEndpoints")} + + + {t("admin.stepCaPrivateEndpointsDesc")} + + + onSaveStepCaPrivateEndpoints( + event.target.value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ) + } + /> +
+ +
+ + {t("admin.stepCa")} + + + {t("admin.stepCaDesc")} + + + setStepCaSettings((p) => ({ ...p, caUrl: e.target.value })) + } + /> + + setStepCaSettings((p) => ({ ...p, fingerprint: e.target.value })) + } + /> +
+ + setStepCaSettings((p) => ({ + ...p, + provisioner: e.target.value, + })) + } + /> + +
+
( @@ -647,6 +648,26 @@ export function HostEditor({ {t("hosts.oidcUsernameHint")}

)} + {authMethod === "stepca" && ( +
+
+ + {t("hosts.stepcaLabel")} + + + {t("hosts.docsLink")} + +
+

+ {t("hosts.stepcaDesc")} +

+
+ )} {authMethod === "tailscale" && (

{t("hosts.tailscaleUsernameHint")} diff --git a/src/ui/sidebar/HostEditorFeatureTabs.tsx b/src/ui/sidebar/HostEditorFeatureTabs.tsx index 98bd27e8..094a2a37 100644 --- a/src/ui/sidebar/HostEditorFeatureTabs.tsx +++ b/src/ui/sidebar/HostEditorFeatureTabs.tsx @@ -171,6 +171,7 @@ export function HostProxmoxTab({ {t("hosts.authTypeCredential")} + diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 2aaa5957..4ee1c175 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -766,7 +766,14 @@ export function HostsPanel({ {t("hosts.filterAuthGroup")} {( - ["password", "key", "credential", "none", "opkssh"] as const + [ + "password", + "key", + "credential", + "none", + "opkssh", + "stepca", + ] as const ).map((val) => ( ; + /** Issuer name shown in the title; defaults to OPKSSH. */ + label?: string; onCancel: () => void; onOpenUrl: () => void; onSelectProvider?: (alias: string) => void; @@ -26,6 +28,7 @@ export function OPKSSHDialog({ onOpenUrl, onSelectProvider, backgroundColor, + label, }: OPKSSHDialogProps) { const { t } = useTranslation(); @@ -42,7 +45,9 @@ export function OPKSSHDialog({

- {t("terminal.opksshAuthRequired")} + {label + ? t("terminal.certAuthRequired", { provider: label }) + : t("terminal.opksshAuthRequired")}

{stage === "chooser" && (