feat: move vault ssh signing into a plugin (C5)

This commit is contained in:
LukeGus committed 2026-09-24 20:24:09 -05:00
1 parent b027717c46
commit 6e56c90aac
198 files changed
+19717 -3445

No files matched your search

@@ -19,8 +19,7 @@ const mocks = vi.hoisted(() => ({
verifier: () => {},
createHostVerifier: vi.fn(),
applyCertificateAuth: vi.fn(),
getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(),
forgetCert: vi.fn(),
applyAgentAuth: vi.fn(),
performPortKnocking: vi.fn(),
createJumpHostChain: vi.fn(),
@@ -41,10 +40,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
applyCertificateAuth: mocks.applyCertificateAuth,
}));
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
getVaultCert: mocks.getVaultCert,
deleteVaultCert: mocks.deleteVaultCert,
}));
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
applyAgentAuth: mocks.applyAgentAuth,
performPortKnocking: mocks.performPortKnocking,
@@ -107,6 +102,39 @@ function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
};
}
/**
* A plugin-style provider that needs a browser sign-in until it has a
* certificate, and forgets it when the host rejects it.
*/
function registerSignInProvider(hasCert: boolean) {
return registerSshAuthProvider({
type: "signin",
pluginId: "fixture-plugin",
labelKey: "fixture.label",
needsUserInteraction: true,
interaction: "signin",
prepare: async (config) => {
if (!hasCert) {
return {
status: "interaction-required",
interaction: "signin",
message: "Sign in first",
};
}
config.password = "cert";
return { status: "ready" };
},
onAuthFailed: (_host, env) => {
mocks.forgetCert(env.userId, env.hostId);
return {
status: "interaction-required",
interaction: "signin",
message: "Sign in again",
};
},
});
}
async function build(
target: SshConnectHost,
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
@@ -286,30 +314,20 @@ describe("buildConnectConfig per auth type", () => {
setSshAuthTypeOwnerSource(() => []);
});
it("vault needs a profile, then a cached certificate", async () => {
const noProfile = await build(host({ authType: "vault" }));
expect(noProfile.outcome).toMatchObject({ status: "error" });
// Vault is the vault plugin's provider: see
// plugins/vault/tests/backend/vault.test.ts.
mocks.getVaultCert.mockResolvedValueOnce(null);
const noCert = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
// The file manager turns the flag into requiresVaultAuth in its 401.
expect(noCert.outcome).toMatchObject({
status: "interaction-required",
interaction: "vault",
flag: "requiresVaultAuth",
it("a vault host without the vault plugin names it", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
]);
const { outcome } = await build(host({ authType: "vault" }));
expect(outcome).toEqual({
status: "error",
code: "provider-missing",
message: "This host uses vault, which needs the HashiCorp Vault plugin",
});
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const ready = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
expect(ready.outcome.status).toBe("ready");
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
setSshAuthTypeOwnerSource(() => []);
});
// Tailscale is a plugin-registered SSH auth provider now: see
@@ -454,20 +472,19 @@ describe("connectHost", () => {
});
it("throws the auth outcome instead of connecting", async () => {
mocks.getVaultCert.mockResolvedValue(null);
const vaultHost = () =>
host({ authType: "vault", vaultProfile: { id: 3 } });
const attempt = connectHost(vaultHost(), {
const dispose = registerSignInProvider(false);
const attempt = connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "tmux",
});
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect(
connectHost(vaultHost(), {
connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "tmux",
}).catch((error) => error.code),
).resolves.toBe("vault-required");
).resolves.toBe("signin-required");
dispose();
});
it("goes through the jump chain and forwards to the target", async () => {
@@ -619,19 +636,17 @@ describe("connectHost", () => {
it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const dispose = registerSignInProvider(true);
await expect(
connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
);
dispose();
});
it("pools under the old key shape", async () => {
@@ -11,7 +11,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostOwnerId,
findHostById,
}),
createCurrentVaultProfileRepository: () => ({}),
createCurrentUserRepository: () => ({ findById: vi.fn() }),
}));
+1 -10
View File
@@ -6,7 +6,6 @@ const state = vi.hoisted(() => ({
isAdminBypass: false,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
vaultProfile: null as Record<string, unknown> | null,
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
sharedSecret: null as Record<string, unknown> | null,
@@ -26,9 +25,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.overrideCredentialId,
}),
createCurrentVaultProfileRepository: () => ({
findById: async () => state.vaultProfile,
}),
createCurrentUserRepository: () => ({
findById: async (userId: string) => ({ id: userId, username: userId }),
}),
@@ -115,7 +111,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
keyType: null,
credentialId: null,
shareSshAuth: false,
vaultProfileId: null,
sudoPassword: "owner-sudo",
autostartPassword: "auto-pass",
autostartKey: null,
@@ -136,7 +131,6 @@ beforeEach(() => {
state.isAdminBypass = false;
state.overrideCredentialId = null;
state.credentials.clear();
state.vaultProfile = null;
state.auditCalls = [];
state.folderCredentialId = null;
state.sharedSecret = null;
@@ -372,13 +366,11 @@ describe("resolveHostById", () => {
expect(host.key).toBe("RECIPIENT-KEY");
});
it("fully replaces Vault authentication with the recipient override", async () => {
it("fully replaces a plugin auth type with the recipient override", async () => {
state.host = baseHost({
authType: "vault",
password: null,
vaultProfileId: 7,
});
state.vaultProfile = { id: 7 };
state.overrideCredentialId = 5;
state.credentials.set("5:recipient", {
id: 5,
@@ -399,7 +391,6 @@ describe("resolveHostById", () => {
expect(host.authType).toBe("key");
expect(host.key).toBe("RECIPIENT-KEY");
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
expect(host.vaultProfile).toBeUndefined();
});
it("falls back to the host username when the override credential has none", async () => {
@@ -1,268 +0,0 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import { execFileSync } from "child_process";
import { mkdtempSync, readFileSync, rmSync } from "fs";
import os from "os";
import path from "path";
import ssh2Pkg from "ssh2";
import {
generateEphemeralKeyPair,
parseCertValidBefore,
startVaultOidc,
completeVaultOidc,
signWithVault,
type VaultProfileConfig,
} from "../../hosts/vault-signer-core.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
// Both halves must be parseable by the same library that signs/connects.
const priv = ssh2Utils.parseKey(pair.privateKey);
expect(priv instanceof Error).toBe(false);
const pub = ssh2Utils.parseKey(pair.publicKey);
expect(pub instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/ca`,
"-N",
"",
"-q",
]);
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/user`,
"-N",
"",
"-q",
]);
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) {
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
return;
}
const validBefore = parseCertValidBefore(cert);
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("Vault HTTP flow (mocked fetch)", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(
impl: (
url: string,
init: RequestInit,
) => { status?: number; body: unknown },
) {
const calls: Array<{ url: string; init: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
const { status = 200, body } = impl(url, init);
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
} as Response;
}),
);
return calls;
}
it("startVaultOidc posts auth_url and extracts state", async () => {
const calls = mockFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
profile,
"https://termix/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init.method).toBe("POST");
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
role: "developer",
redirect_uri: "https://termix/vault/oidc/callback",
});
expect(body.client_nonce).toBe(result.clientNonce);
});
it("completeVaultOidc returns the client token", async () => {
const calls = mockFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const calls = mockFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
mockFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
});
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
describe("Vault live signing", () => {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
const run = !!addr && !!token;
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
const profile: VaultProfileConfig = {
id: 99,
vaultAddr: addr!,
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
validPrincipals: "root",
keyType: "ssh-ed25519",
};
const pair = generateEphemeralKeyPair(profile.keyType);
const before = Math.floor(Date.now() / 1000);
const cert = await signWithVault(profile, token!, pair.publicKey);
expect(cert).toMatch(/-cert-v01@openssh\.com /);
// The signed cert must parse with the same library used to connect.
const parsed = ssh2Utils.parseKey(cert);
expect(parsed instanceof Error).toBe(false);
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(before);
});
});