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

@@ -0,0 +1,136 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import {
buildAuthorizationUrl,
certificateFingerprint,
createPkce,
decodeJwtClaims,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
normalizeCaUrl,
normalizeFingerprint,
parseSshCertificate,
} from "../../src/backend/client.js";
import { parseHostList } from "../../src/backend/auth-session.js";
import { CA_URL, createFakeCa, createSshCa, fakeRoot } from "./fake-ca.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 root = fakeRoot();
expect(certificateFingerprint(root.pem)).toBe(root.fingerprint);
});
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 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 cert = createSshCa().sign({
publicKeyBlob: publicKeyLine.split(" ")[1],
keyId: "alice@example",
principals: ["alice", "ops"],
validAfter: 1_700_000_000,
validBefore: 1_700_057_600,
});
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/);
});
it("reads the private endpoint list with commas or new lines", () => {
expect(parseHostList("ca.internal, SSO.internal\nidp.lan")).toEqual([
"ca.internal",
"sso.internal",
"idp.lan",
]);
expect(parseHostList(undefined)).toEqual([]);
});
});
describe("talking to the CA", () => {
it("refuses a root whose fingerprint does not match", async () => {
const ca = createFakeCa({ nonce: () => "" });
await expect(
fetchRootCertificate({
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: "b".repeat(64),
allowedPrivateHosts: [],
}),
).rejects.toThrow();
});
it("finds the OIDC provisioner by name and passes the private hosts", async () => {
const ca = createFakeCa({ nonce: () => "" });
const target = {
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
allowedPrivateHosts: ["ca.test"],
};
const root = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(target, root, "oidc");
expect(provisioner.clientID).toBe("termix");
expect(ca.calls.every((call) => call.init?.allowPrivateHosts)).toBe(true);
await expect(findOidcProvisioner(target, root, "jwk")).rejects.toThrow(
/not found/,
);
});
});
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({});
});
});
+160
View File
@@ -0,0 +1,160 @@
import crypto from "node:crypto";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
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]);
}
function uint64(value: bigint): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(value, 0);
return out;
}
function uint32(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value, 0);
return out;
}
/** An ed25519 CA that signs OpenSSH user certificates, like step-ca does. */
export function createSshCa() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const raw = Buffer.from(
(publicKey.export({ format: "jwk" }) as { x: string }).x,
"base64url",
);
const caBlob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
return {
sign(input: {
publicKeyBlob: string;
keyId: string;
principals: string[];
validAfter: number;
validBefore: number;
}): string {
const userBlob = Buffer.from(input.publicKeyBlob, "base64");
// ssh-ed25519 blob: string type, string pk
const pk = userBlob.subarray(4 + 11 + 4);
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)),
sshString(pk),
uint64(1n),
uint32(1),
sshString(input.keyId),
sshString(Buffer.concat(input.principals.map((p) => sshString(p)))),
uint64(BigInt(input.validAfter)),
uint64(BigInt(input.validBefore)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(caBlob),
]);
const signature = Buffer.concat([
sshString("ssh-ed25519"),
sshString(crypto.sign(null, body, privateKey)),
]);
return `${CERT_TYPE} ${Buffer.concat([body, sshString(signature)]).toString("base64")}`;
},
};
}
/** A root "certificate" the client fingerprints by the sha256 of its DER. */
export function fakeRoot() {
const der = crypto.randomBytes(128);
return {
pem: `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`,
fingerprint: crypto.createHash("sha256").update(der).digest("hex"),
};
}
export function jwt(claims: Record<string, unknown>): string {
const part = (value: object) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${part({ alg: "none" })}.${part(claims)}.sig`;
}
export const CA_URL = "https://ca.test";
export const DISCOVERY_URL =
"https://idp.test/.well-known/openid-configuration";
export interface FakeCaOptions {
/** Principals the CA puts on the certificate; defaults to what was asked. */
principals?: string[];
/** Nonce the identity provider puts in the id token. */
nonce: () => string;
/** Status for /1.0/ssh/sign. */
signStatus?: number;
}
/**
* Answers ctx.fetch as a step-ca server plus its identity provider would.
* Records every call so a test can check the TLS options.
*/
export function createFakeCa(options: FakeCaOptions) {
const root = fakeRoot();
const ca = createSshCa();
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
if (url === `${CA_URL}/root/${root.fingerprint}`) {
return json({ ca: root.pem });
}
if (url.startsWith(`${CA_URL}/provisioners`)) {
return json({
provisioners: [
{ name: "jwk", type: "JWK" },
{
name: "oidc",
type: "OIDC",
clientID: "termix",
configurationEndpoint: DISCOVERY_URL,
},
],
});
}
if (url === DISCOVERY_URL) {
return json({
authorization_endpoint: "https://idp.test/authorize",
token_endpoint: "https://idp.test/token",
});
}
if (url === "https://idp.test/token") {
return json({
id_token: jwt({ email: "alice@example.com", nonce: options.nonce() }),
});
}
if (url === `${CA_URL}/1.0/ssh/sign`) {
if (options.signStatus && options.signStatus !== 200) {
return json({ message: "not allowed" }, options.signStatus);
}
const body = JSON.parse(init?.body ?? "{}");
const now = Math.floor(Date.now() / 1000);
return json({
crt: ca.sign({
publicKeyBlob: body.publicKey,
keyId: body.keyID,
principals: options.principals ?? body.principals,
validAfter: now - 60,
validBefore: now + 16 * 3600,
}),
});
}
return new Response("not found", { status: 404 });
};
return { fetch, calls, root, ca };
}
+105
View File
@@ -0,0 +1,105 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginFetchInit,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
/** A fake terminal socket that records what it was sent. */
export function fakeSocket() {
const sent: Array<Record<string, unknown>> = [];
const closers: Array<() => void> = [];
return {
sent,
socket: {
send: (data: string) => sent.push(JSON.parse(data)),
on: (_event: "close", listener: () => void) => closers.push(listener),
},
close: () => closers.forEach((listener) => listener()),
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
provider: () => PluginSshAuthProvider;
get: (path: string) => Promise<{ status: number; body: string }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
settings?: Record<string, unknown>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)")
.run("user-1");
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
settings: options.settings,
fetch: options.fetch,
baseUrl: "https://termix.test",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
provider: () =>
mock.auth.sshAuthProviders.find(
(provider) => provider.type === "stepca",
)!,
async get(path) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
redirect: "manual",
});
return { status: response.status, body: await response.text() };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const env = (hostId = 7) => ({
client: {},
userId: "user-1",
hostId,
purpose: "terminal",
interactive: true,
log: () => {},
});
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
@@ -0,0 +1,295 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
import { CA_URL, createFakeCa } from "./fake-ca.js";
import {
HOST,
env,
fakeSocket,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers.js";
const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() }));
vi.mock("@termix/plugin-sdk/ssh-certs", () => certs);
let server: TestServer | null = null;
let nonce = "";
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
nonce = "";
});
afterEach(async () => {
await server?.close();
server = null;
});
async function startWithCa(
options: {
principals?: string[];
signStatus?: number;
wrongNonce?: boolean;
settings?: Record<string, unknown>;
} = {},
) {
const ca = createFakeCa({
principals: options.principals,
signStatus: options.signStatus,
nonce: () => (options.wrongNonce ? "other" : nonce),
});
server = await startServer({
fetch: ca.fetch,
settings: {
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
provisioner: "oidc",
...options.settings,
},
});
return ca;
}
/** Starts a sign-in and returns the socket and the authorization URL. */
async function startSignIn() {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId: "user-1",
hostId: 7,
host: { name: "box", ip: "10.0.0.7", username: "root" },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
const chooser = socket.sent.find((m) => m.stage === "chooser");
const url = chooser ? new URL(String(chooser.url)) : null;
nonce = url?.searchParams.get("nonce") ?? "";
return { socket, url, state: url?.searchParams.get("state") ?? "" };
}
describe("the certs table", () => {
it("is created on a fresh install", async () => {
const db = await createTestDb(pluginDir);
expect(
db.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE name = 'p_step_ca_certs'",
)
.get(),
).toBeTruthy();
db.close();
});
});
describe("activate", () => {
it("registers the stepca auth type and its callback route", async () => {
server = await startServer();
expect(server.provider()).toMatchObject({
type: "stepca",
interaction: "stepca",
supportsBackground: false,
});
expect(server.mock.httpRouters).toHaveLength(1);
});
it.each(["db:own", "auth:provide", "network:serve"])(
"fails closed without %s",
async (capability) => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: (await createTestDb(pluginDir)).database,
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
},
);
});
describe("issuing a certificate", () => {
it("runs the OIDC flow against the CA and stores the certificate sealed", async () => {
const ca = await startWithCa();
const { socket, url, state } = await startSignIn();
expect(url?.origin + url?.pathname).toBe("https://idp.test/authorize");
expect(url?.searchParams.get("client_id")).toBe("termix");
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/plugin-api/step-ca/callback",
);
expect(url?.searchParams.get("code_challenge_method")).toBe("S256");
// The root is fetched unverified and checked by fingerprint, then pinned.
expect(ca.calls[0].init?.tls).toEqual({ rejectUnauthorized: false });
expect(ca.calls[1].init?.tls).toEqual({ ca: ca.root.pem });
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(200);
expect(response.body).toContain("Signed in");
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_completed",
requestId: state,
});
const sign = ca.calls.find((call) => call.url.endsWith("/1.0/ssh/sign"));
expect(JSON.parse(sign!.init!.body!)).toMatchObject({
certType: "user",
principals: ["root"],
keyID: "alice@example.com",
});
expect(sign!.init!.tls).toEqual({ ca: ca.root.pem });
const row = server!.db.sqlite
.prepare("SELECT * FROM p_step_ca_certs")
.get() as Record<string, string>;
expect(row.user_id).toBe("user-1");
expect(row.email).toBe("alice@example.com");
expect(row.ssh_cert).toMatch(/^sealed:/);
expect(row.private_key).toMatch(/^sealed:/);
});
it("answers a second use of the same callback with an error", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const again = await server!.get(`/callback?state=${state}&code=abc`);
expect(again.status).toBe(400);
expect(again.body).toContain("no longer active");
});
it("refuses an id token issued for another sign-in", async () => {
await startWithCa({ wrongNonce: true });
const { socket, state } = await startSignIn();
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("does not match"),
});
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("reports the CA refusing to sign", async () => {
await startWithCa({ signStatus: 403 });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("HTTP 403"),
});
});
it("rejects a certificate without the host username", async () => {
await startWithCa({ principals: ["someone-else"] });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("host username"),
});
});
it("passes an identity provider error through", async () => {
await startWithCa();
const { socket, state } = await startSignIn();
const response = await server!.get(
`/callback?state=${state}&error=access_denied&error_description=Denied`,
);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: "Step CA: Denied",
});
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
await startWithCa({ settings: { legacyCallback: true } });
const { url } = await startSignIn();
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/host/step-ca-callback",
);
});
it("tells the terminal when Step CA is not configured", async () => {
server = await startServer();
const { socket } = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({ type: "stepca_config_error" }),
]);
});
it("lets a pending sign-in be cancelled", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.provider().cancelInteraction!({
userId: "user-1",
requestId: state,
});
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
});
});
describe("the stepca provider", () => {
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "stepca",
});
expect(certs.applyCertificateAuth).not.toHaveBeenCalled();
});
it("connects with the issued certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const config: Record<string, unknown> = {};
const outcome = await server!.provider().prepare(config, HOST, env());
expect(outcome).toEqual({ status: "ready" });
const [passedConfig, , credentials, username] =
certs.applyCertificateAuth.mock.calls[0];
expect(passedConfig).toBe(config);
expect(username).toBe("root");
expect(credentials.certificate).toMatch(
/^ssh-ed25519-cert-v01@openssh\.com /,
);
expect(credentials.privateKey).toContain("BEGIN PRIVATE KEY");
});
it("forgets an expired certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
server!.db.sqlite
.prepare("UPDATE p_step_ca_certs SET expires_at = ?")
.run("2000-01-01T00:00:00.000Z");
const outcome = await server!.provider().prepare({}, HOST, env());
expect(outcome.status).toBe("interaction-required");
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("drops the certificate when the host rejects it", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const decision = server!.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
} as never);
expect(decision).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0),
);
});
});