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

-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();