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

@@ -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([]);
});
});