feat: move step-ca into a plugin (C4)

This commit is contained in:
LukeGus committed 2026-09-24 19:55:28 -05:00
1 parent 98d367d7e2
commit b027717c46
128 files changed
+3805 -1781

No files matched your search

-70
View File
@@ -15,80 +15,11 @@ import type {
SshConnectHost,
} from "../hosts/connect/types.js";
const STEPCA_REQUIRED_MESSAGE =
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
const VAULT_REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
const stepCaProvider: SshAuthProvider = {
type: "stepca",
pluginId: "core",
labelKey: "hosts.filterAuthStepca",
needsUserInteraction: true,
supportsBackground: false,
interaction: "stepca",
prepare: async (config, host, env) => {
const { getStepCaCert } = await import("../hosts/step-ca-auth.js");
const cert = getStepCaCert(env.userId, env.hostId);
if (!cert) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: "stepca",
message: STEPCA_REQUIRED_MESSAGE,
};
}
try {
await applyCertificateAuth(
config,
env.client,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message: "Step CA authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
void import("../hosts/step-ca-auth.js")
.then(({ invalidateStepCaCert }) =>
invalidateStepCaCert(env.userId, env.hostId),
)
.catch(() => {});
return {
status: "interaction-required",
interaction: "stepca",
message:
"Step CA authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
await startStepCaAuth(
request.userId,
request.hostId,
request.host.username,
request.socket,
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (!request.requestId) return;
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
cancelStepCaAuth(request.requestId);
},
};
function vaultProfileId(host: SshConnectHost): number | undefined {
return host.vaultProfile?.id ?? undefined;
}
@@ -192,7 +123,6 @@ export function registerLegacySshAuthProviders(): void {
if (sshRegistered) return;
sshRegistered = true;
registerSshAuthProvider(stepCaProvider);
registerSshAuthProvider(vaultProvider);
}
@@ -1,8 +1,8 @@
/**
* The 2.8 URLs under /host that something outside Termix still depends on:
* the OPKSSH redirect URI registered with identity providers. It forwards to
* the plugin route that replaced it, so with the plugin off it signs nobody
* in.
* the OPKSSH and Step CA redirect URIs registered with identity providers.
* Each forwards to the plugin route that replaced it, so with the plugin off
* it signs nobody in.
*/
import type { Request, Router } from "express";
@@ -33,4 +33,20 @@ export function registerHostAuthCompatRoutes(router: Router): void {
const rest = req.path.slice("/opkssh-callback".length);
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
});
/**
* @openapi
* /host/step-ca-callback:
* get:
* summary: Step CA callback (2.8 URL)
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the step-ca plugin's callback.
*/
router.get("/step-ca-callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
});
}
@@ -1,42 +0,0 @@
import type { Request, Response, Router } from "express";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
/**
* 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));
});
}
-2
View File
@@ -46,7 +46,6 @@ import {
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
@@ -2661,7 +2660,6 @@ registerHostAutostartRoutes(router, {
});
registerHostAuthCompatRoutes(router);
registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
@@ -1,5 +1,4 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { RequestHandler, Router } from "express";
import {
authLogger,
@@ -13,7 +12,6 @@ import {
AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
@@ -464,7 +462,7 @@ export function registerUserSettingsRoutes(
/**
* 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.
* One route pair per private endpoint allowlist, each with its own setting key.
*/
const registerPrivateEndpointAllowlist = (
path: string,
@@ -562,131 +560,6 @@ export function registerUserSettingsRoutes(
"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:
-376
View File
@@ -1,376 +0,0 @@
import { randomBytes } from "crypto";
import type { WebSocket } from "ws";
import { sshLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { 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.
*
* 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";
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<StepCaSettings | null> {
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<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));
} 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<void> {
const settings = await readStepCaSettings();
if (!settings) {
send(ws, {
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.",
});
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: "stepca_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: "stepca_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: "stepca_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, caches the certificate and tells the
* terminal to reconnect.
*/
export async function completeStepCaAuth(
query: StepCaCallbackQuery,
): Promise<StepCaCallbackResult> {
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<StepCaCallbackResult> {
if (query.error || !query.code) {
const message = query.error_description || query.error || "Sign-in failed";
send(session.ws, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
try {
send(session.ws, {
type: "stepca_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;
issued.set(certKey(session.userId, session.hostId), {
sshCert: certificate,
privateKey: session.keyPair.privateKeyPem,
expiresAt: expiresAt.getTime(),
});
session.completed = true;
send(session.ws, {
type: "stepca_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: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
}
-172
View File
@@ -1,172 +0,0 @@
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<typeof createClient> | null = null;
private connecting: Promise<boolean> | null = null;
private nextConnectAttempt = 0;
async register(state: string): Promise<void> {
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<boolean> {
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<StepCaCallbackQuery | null> {
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<StepCaCallbackQuery>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_command", error);
return null;
}
}
async complete(state: string, result: StepCaCallbackResult): Promise<void> {
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<StepCaCallbackResult | null> {
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<StepCaCallbackResult>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_result", error);
return null;
}
}
async remove(state: string): Promise<void> {
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<void> {
if (this.client?.isOpen) await this.client.quit();
this.client = null;
}
private async ensureConnected(): Promise<boolean> {
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<boolean> {
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<T>(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();
+6
View File
@@ -147,6 +147,12 @@ export function createPluginFetch(deps: Deps): PluginFetch {
signal: controller.signal,
},
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
{
...(init.tls?.ca ? { ca: init.tls.ca } : {}),
...(init.tls?.rejectUnauthorized === false
? { rejectUnauthorized: false }
: {}),
},
);
} finally {
clearTimeout(timer);
+4
View File
@@ -377,6 +377,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/warpgate-settings-migration.js");
await runWarpgateSettingsMigration();
const { runStepCaSettingsMigration } =
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
@@ -1,5 +1,5 @@
/**
* The OPKSSH redirect URI identity providers were registered with before
* The OPKSSH and Step CA redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact.
*/
@@ -49,3 +49,16 @@ describe("the old OPKSSH callback", () => {
);
});
});
describe("the old Step CA callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/step-ca-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/step-ca/callback?code=abc&state=xyz",
);
});
});
@@ -18,8 +18,6 @@ vi.mock("ssh2", async (importOriginal) => {
const mocks = vi.hoisted(() => ({
verifier: () => {},
createHostVerifier: vi.fn(),
getStepCaCert: vi.fn(),
invalidateStepCaCert: vi.fn(),
applyCertificateAuth: vi.fn(),
getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(),
@@ -40,10 +38,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
createHostVerifier: mocks.createHostVerifier,
},
}));
vi.mock("../../../hosts/step-ca-auth.js", () => ({
getStepCaCert: mocks.getStepCaCert,
invalidateStepCaCert: mocks.invalidateStepCaCert,
}));
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
applyCertificateAuth: mocks.applyCertificateAuth,
}));
@@ -276,25 +270,20 @@ describe("buildConnectConfig per auth type", () => {
// OPKSSH is a plugin-registered SSH auth provider now: see
// plugins/opkssh/tests/backend/provider.test.ts.
it("stepca without a cached certificate asks for a sign-in", async () => {
mocks.getStepCaCert.mockReturnValueOnce(null);
const { outcome } = await build(host({ authType: "stepca" }));
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "stepca",
});
expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7);
});
// Step CA is the step-ca plugin's provider: see
// plugins/step-ca/tests/backend/step-ca.test.ts.
it("stepca uses the cached certificate", async () => {
mocks.getStepCaCert.mockReturnValueOnce({
privateKey: "k",
sshCert: "c",
it("a stepca host without the step-ca plugin names it", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
]);
const { outcome } = await build(host({ authType: "stepca" }));
expect(outcome).toEqual({
status: "error",
code: "provider-missing",
message: "This host uses stepca, which needs the Step CA plugin",
});
const { config, outcome } = await build(host({ authType: "stepca" }));
expect(outcome.status).toBe("ready");
expect(config.authHandler).toBe("cert-handler");
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
setSshAuthTypeOwnerSource(() => []);
});
it("vault needs a profile, then a cached certificate", async () => {
@@ -465,18 +454,20 @@ describe("connectHost", () => {
});
it("throws the auth outcome instead of connecting", async () => {
mocks.getStepCaCert.mockReturnValue(null);
const attempt = connectHost(host({ authType: "stepca" }), {
mocks.getVaultCert.mockResolvedValue(null);
const vaultHost = () =>
host({ authType: "vault", vaultProfile: { id: 3 } });
const attempt = connectHost(vaultHost(), {
userId: "user-1",
purpose: "tmux",
});
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect(
connectHost(host({ authType: "stepca" }), {
connectHost(vaultHost(), {
userId: "user-1",
purpose: "tmux",
}).catch((error) => error.code),
).resolves.toBe("stepca-required");
).resolves.toBe("vault-required");
});
it("goes through the jump chain and forwards to the target", async () => {
@@ -628,18 +619,18 @@ describe("connectHost", () => {
it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getStepCaCert.mockReturnValueOnce({
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
await expect(
connectHost(host({ authType: "stepca" }), {
connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
userId: "user-1",
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7),
expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
);
});
@@ -1,184 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
const state = vi.hoisted(() => ({
sent: [] as Array<Record<string, unknown>>,
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<string, string> | 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("../../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],
}),
}));
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<string, string>) => {
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,
getStepCaCert,
invalidateStepCaCert,
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;
invalidateStepCaCert("user-1", 7);
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(getStepCaCert("user-1", 7)).toEqual({
sshCert: "CERT",
privateKey: "PRIVATE",
});
expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" });
});
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(getStepCaCert("user-1", 7)).toBeNull();
});
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(getStepCaCert("user-1", 7)).not.toBeNull();
});
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);
});
});
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastTls: null as Record<string, unknown> | null,
lastSignal: null as AbortSignal | null,
}));
@@ -61,7 +62,9 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
url: string,
init: RequestInit,
allowlist: readonly string[],
tls: Record<string, unknown> = {},
) => {
state.lastTls = tls;
state.lastSignal = init.signal ?? null;
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
@@ -184,6 +187,18 @@ describe("ctx.fetch", () => {
]);
});
it("passes a pinned CA and the fingerprint bootstrap flag to the guard", async () => {
const ctx = contextFor(["network:outbound"]);
await ctx.fetch("https://ca.lan/root/abc", {
tls: { rejectUnauthorized: false },
});
expect(state.lastTls).toEqual({ rejectUnauthorized: false });
await ctx.fetch("https://ca.lan/provisioners", { tls: { ca: "PEM" } });
expect(state.lastTls).toEqual({ ca: "PEM" });
await ctx.fetch("https://example.com");
expect(state.lastTls).toEqual({});
});
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
const ctx = contextFor(["network:outbound"]);
const controller = new AbortController();
@@ -1,103 +0,0 @@
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({});
});
});
@@ -0,0 +1,116 @@
/**
* The Step CA settings migration: a 2.8 CA configuration and its private
* endpoint allowlist come through into the step-ca plugin's admin settings,
* the old redirect URI stays in use, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{ key: string; value: string | null }>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "step-ca" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value });
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
step_ca_url: "https://ca.internal:9000",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
step_ca_private_endpoint_allowlist: JSON.stringify([
"ca.internal",
"SSO.internal",
]),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runStepCaSettingsMigration", () => {
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
const result = await runStepCaSettingsMigration();
expect(result.moved).toEqual([
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
]);
expect(result.legacyCallback).toBe(true);
expect(value("caUrl")).toBe("https://ca.internal:9000");
expect(value("fingerprint")).toBe("a".repeat(64));
expect(value("provisioner")).toBe("oidc");
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
expect(value("legacyCallback")).toBe(true);
});
it("changes nothing the second time", async () => {
await runStepCaSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const again = await runStepCaSettingsMigration();
expect(again).toEqual({ moved: [], legacyCallback: false });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("never overwrites a value set in the plugin", async () => {
state.pluginRows = [
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
{ key: "legacyCallback", value: JSON.stringify(false) },
];
await runStepCaSettingsMigration();
expect(value("caUrl")).toBe("https://new.ca");
expect(value("legacyCallback")).toBe(false);
expect(value("provisioner")).toBe("oidc");
});
it("leaves an install that never used Step CA alone", async () => {
state.legacy = {};
const result = await runStepCaSettingsMigration();
expect(result).toEqual({ moved: [], legacyCallback: false });
expect(state.pluginRows).toEqual([]);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runStepCaSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,110 @@
/**
* Moves the 2.8 Step CA configuration into the step-ca plugin.
*
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
* allowlist lived in core `settings` rows. They become the plugin's admin
* settings. An install that had a CA configured keeps sending the pre-2.9
* redirect URI (/host/step-ca-callback), because that is the one registered
* with its identity provider: the plugin's legacyCallback setting is turned
* on.
*
* Idempotent: a value already present in plugin_settings is never
* overwritten. The old rows stay for one release so a downgrade still works.
*/
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { parseNotificationAllowlist } from "../notification-egress.js";
const PLUGIN_ID = "step-ca";
const MOVES: { legacyKey: string; field: string }[] = [
{ legacyKey: "step_ca_url", field: "caUrl" },
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
];
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
export interface StepCaSettingsMigrationResult {
moved: string[];
legacyCallback: boolean;
}
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
const result: StepCaSettingsMigrationResult = {
moved: [],
legacyCallback: false,
};
try {
// plugin_settings has a foreign key to plugins: nothing to move into yet.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (field: string, value: unknown) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return false;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
);
return true;
};
for (const move of MOVES) {
const value = (await settings.get(move.legacyKey))?.trim();
if (!value) continue;
if (await writeIfUnset(move.field, value)) {
result.moved.push(move.legacyKey);
}
}
const hosts = parseNotificationAllowlist(
await settings.get(LEGACY_ALLOWLIST_KEY),
);
if (
hosts.length > 0 &&
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
) {
result.moved.push(LEGACY_ALLOWLIST_KEY);
}
if ((await settings.get("step_ca_url"))?.trim()) {
result.legacyCallback = await writeIfUnset("legacyCallback", true);
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
{
operation: "step_ca_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then reports "not configured".
databaseLogger.warn("Step CA settings migration failed", {
operation: "step_ca_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
-394
View File
@@ -1,394 +0,0 @@
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<string, unknown> {
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<T>(response: Response, what: string): Promise<T> {
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<string> {
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<StepCaOidcProvisioner> {
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<Record<string, unknown>>;
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<OidcEndpoints> {
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<string> {
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<string> {
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),
};
}
-13
View File
@@ -1,13 +0,0 @@
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<string[]> {
const raw = await createCurrentSettingsRepository().get(
STEP_CA_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}