mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move step-ca into a plugin (C4)
This commit is contained in:
1 parent
98d367d7e2
commit
b027717c46
128 files changed
+3805
-1781
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user