mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move vault ssh signing into a plugin (C5)
This commit is contained in:
1 parent
b027717c46
commit
6e56c90aac
198 files changed
+19717
-3445
No files matched your search
@@ -195,7 +195,6 @@ describe("HostResolutionRepository", () => {
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: null,
|
||||
authType: "password",
|
||||
parentHostId: null,
|
||||
folder: null,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { VaultProfileRepository } from "../../../database/repositories/vault-profile-repository.js";
|
||||
|
||||
describe("VaultProfileRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<VaultProfileRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (
|
||||
id, user_id, name, vault_addr, ssh_role, shared, updated_at
|
||||
)
|
||||
VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'),
|
||||
(2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'),
|
||||
(3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new VaultProfileRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("lists profiles owned by or shared with the user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const rows = await repo.listVisibleToUser("user-1");
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("creates and reads a profile", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const created = await repo.create({
|
||||
userId: "user-1",
|
||||
name: "new",
|
||||
description: "desc",
|
||||
folder: "folder",
|
||||
tags: "prod,ssh",
|
||||
vaultAddr: "https://vault.new",
|
||||
vaultNamespace: "ns",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "oidc-role",
|
||||
sshMount: "ssh",
|
||||
sshRole: "ssh-role",
|
||||
validPrincipals: "root",
|
||||
keyType: "ed25519",
|
||||
shared: true,
|
||||
});
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "new",
|
||||
vaultAddr: "https://vault.new",
|
||||
sshRole: "ssh-role",
|
||||
shared: true,
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates and deletes profiles", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.updateById(1, {
|
||||
name: "renamed",
|
||||
shared: true,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: 1,
|
||||
name: "renamed",
|
||||
shared: true,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
|
||||
expect(await repo.deleteById(1)).toEqual({ syncId: null });
|
||||
expect(await repo.deleteById(1)).toBeNull();
|
||||
expect(await repo.findById(1)).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all profiles for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-2")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findById(2)).toBeNull();
|
||||
expect(await repo.findById(3)).toBeNull();
|
||||
expect((await repo.findById(1))?.userId).toBe("user-1");
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { VaultTokenRepository } from "../../../database/repositories/vault-token-repository.js";
|
||||
|
||||
describe("VaultTokenRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<VaultTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role)
|
||||
VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r');
|
||||
INSERT INTO vault_tokens (
|
||||
user_id, profile_id, ssh_cert, private_key, expires_at
|
||||
)
|
||||
VALUES
|
||||
('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'),
|
||||
('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new VaultTokenRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts a token by user and profile", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findByUserAndProfile("user-1", 1);
|
||||
expect(existing?.sshCert).toBe("cert-1");
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
profileId: 1,
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.findByUserAndProfile("user-1", 1);
|
||||
expect(updated).toMatchObject({
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates last-used and deletes one token", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
|
||||
).toBe(true);
|
||||
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
|
||||
expect((await repo.findByUserAndProfile("user-1", 1))?.lastUsed).toBe(
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true);
|
||||
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false);
|
||||
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all tokens for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
profileId: 2,
|
||||
sshCert: "cert-extra",
|
||||
privateKey: "key-extra",
|
||||
expiresAt: "2099-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The OPKSSH and Step CA redirect URIs identity providers were registered with before
|
||||
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
|
||||
* 2.9 must keep reaching the plugin's callback, query intact.
|
||||
*/
|
||||
|
||||
@@ -62,3 +62,29 @@ describe("the old Step CA callback", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Vault callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const { registerVaultCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
const router = express.Router();
|
||||
registerVaultCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/vault", router);
|
||||
const vaultServer = http.createServer(app);
|
||||
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
|
||||
const port = (vaultServer.address() as AddressInfo).port;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ describe("isValidEntityType", () => {
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"vaultProfiles",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("sync references", () => {
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 4,
|
||||
parentHostId: 3,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
@@ -34,17 +33,14 @@ describe("sync references", () => {
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vaultProfiles-4",
|
||||
parentHostSyncId: "hosts-3",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
expect(row).not.toHaveProperty("vaultProfileId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["vaultProfiles:vault-sync", 37],
|
||||
["hosts:parent-sync", 52],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
@@ -55,7 +51,6 @@ describe("sync references", () => {
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vault-sync",
|
||||
parentHostSyncId: "parent-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
@@ -66,7 +61,6 @@ describe("sync references", () => {
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 37,
|
||||
parentHostId: 52,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
|
||||
@@ -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() }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -96,15 +96,16 @@ describe("core sync entities", () => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders and
|
||||
// dashboardServiceLinks/homepageItems, now registered by the
|
||||
// network-topology, snippets and homepage plugins rather than core.
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders,
|
||||
// dashboardServiceLinks/homepageItems and vaultProfiles, now registered by
|
||||
// the network-topology, snippets, homepage and vault plugins rather than core.
|
||||
const PLUGIN_OWNED_TYPES = new Set([
|
||||
"networkTopology",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"vaultProfiles",
|
||||
]);
|
||||
|
||||
it("registers every core-owned entity the Electron client knows about", () => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The Vault host settings migration.
|
||||
*
|
||||
* Every host's ssh_data.vault_profile_id must come through into the vault
|
||||
* plugin's profileId host setting, and an install that used Vault keeps the
|
||||
* 2.8 redirect URI. Running it twice must change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
legacyProfiles: 0,
|
||||
adoptedProfiles: 0,
|
||||
pluginInstalled: true,
|
||||
columnGone: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "vault" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: unknown) => {
|
||||
const text = JSON.stringify(query);
|
||||
if (text.includes("vault_profile_id")) {
|
||||
if (state.columnGone) throw new Error("no such column");
|
||||
return state.hostRows.filter((row) => row.vault_profile_id != null);
|
||||
}
|
||||
const count = text.includes("p_vault_profiles")
|
||||
? state.adoptedProfiles
|
||||
: state.legacyProfiles;
|
||||
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
|
||||
},
|
||||
runStatement: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
|
||||
|
||||
const setting = (scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
|
||||
?.value;
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.hostRows = [];
|
||||
state.legacyProfiles = 0;
|
||||
state.adoptedProfiles = 0;
|
||||
state.pluginInstalled = true;
|
||||
state.columnGone = false;
|
||||
});
|
||||
|
||||
describe("runVaultSettingsMigration", () => {
|
||||
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, vault_profile_id: 7 },
|
||||
{ id: 2, vault_profile_id: null },
|
||||
{ id: 3, vault_profile_id: "9" },
|
||||
];
|
||||
state.adoptedProfiles = 2;
|
||||
|
||||
const first = await runVaultSettingsMigration();
|
||||
expect(first).toEqual({
|
||||
hostsMoved: 2,
|
||||
hostsSkipped: 0,
|
||||
legacyCallback: true,
|
||||
});
|
||||
expect(setting("1", "profileId")).toBe("7");
|
||||
expect(setting("2", "profileId")).toBeUndefined();
|
||||
expect(setting("3", "profileId")).toBe("9");
|
||||
expect(setting(null, "legacyCallback")).toBe("true");
|
||||
|
||||
const rows = state.pluginRows.length;
|
||||
const second = await runVaultSettingsMigration();
|
||||
expect(second).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 2,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(state.pluginRows).toHaveLength(rows);
|
||||
});
|
||||
|
||||
it("keeps a host's value already set in the plugin", async () => {
|
||||
state.pluginRows.push({
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "profileId",
|
||||
value: "4",
|
||||
});
|
||||
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
|
||||
await runVaultSettingsMigration();
|
||||
expect(setting("1", "profileId")).toBe("4");
|
||||
});
|
||||
|
||||
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
|
||||
state.legacyProfiles = 1;
|
||||
const result = await runVaultSettingsMigration();
|
||||
expect(result.legacyCallback).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
|
||||
state.columnGone = true;
|
||||
const first = await runVaultSettingsMigration();
|
||||
expect(first.legacyCallback).toBe(false);
|
||||
expect(setting(null, "legacyCallback")).toBe("false");
|
||||
|
||||
state.adoptedProfiles = 3;
|
||||
await runVaultSettingsMigration();
|
||||
expect(setting(null, "legacyCallback")).toBe("false");
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
|
||||
await runVaultSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user