mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move termix identity into a plugin (C6)
This commit is contained in:
1 parent
6e56c90aac
commit
4dbce08379
157 files changed
+20743
-5766
No files matched your search
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
|
||||
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
@@ -8,7 +9,7 @@ import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const { registerHostAuthCompatRoutes } =
|
||||
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
@@ -19,6 +20,9 @@ beforeEach(async () => {
|
||||
registerHostAuthCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/host", router);
|
||||
const termixId = express.Router();
|
||||
registerTermixIdCompatRoutes(termixId);
|
||||
app.use("/termix-id", termixId);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
@@ -88,3 +92,25 @@ describe("the old Vault callback", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Termix ID resolver URLs", () => {
|
||||
it.each([
|
||||
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
|
||||
[
|
||||
"/termix-id/u/alice/ED25519",
|
||||
"/plugin-api/termix-identity/u/alice/ED25519",
|
||||
],
|
||||
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
|
||||
])("permanently redirects %s", async (path, target) => {
|
||||
const response = await fetch(`${base}${path}`, { redirect: "manual" });
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toBe(target);
|
||||
});
|
||||
|
||||
it("does not forward the old management routes", async () => {
|
||||
const response = await fetch(`${base}/termix-id/me`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
generateCa,
|
||||
signUserCertificate,
|
||||
ed25519RawFromLine,
|
||||
} from "../../../database/routes/ssh-certificate.js";
|
||||
|
||||
function publicKeyObjectFromLine(line: string) {
|
||||
const raw = ed25519RawFromLine(line);
|
||||
if (!raw) throw new Error("not ed25519");
|
||||
return crypto.createPublicKey({
|
||||
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
|
||||
format: "jwk",
|
||||
});
|
||||
}
|
||||
|
||||
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
|
||||
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
|
||||
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
|
||||
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
|
||||
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
|
||||
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
|
||||
const rawSig = blob.subarray(blob.length - 64);
|
||||
return { body, rawSig };
|
||||
}
|
||||
|
||||
describe("generateCa", () => {
|
||||
it("produces a valid ed25519 public line and PKCS8 private key", () => {
|
||||
const ca = generateCa();
|
||||
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
|
||||
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
|
||||
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
|
||||
// The PEM must load as a usable signing key.
|
||||
expect(() =>
|
||||
crypto.createPrivateKey({
|
||||
key: ca.privateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUserCertificate", () => {
|
||||
it("returns null for non-ed25519 user keys", () => {
|
||||
const ca = generateCa();
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "x",
|
||||
principals: [],
|
||||
validAfter: 0,
|
||||
validBefore: 1,
|
||||
});
|
||||
expect(cert).toBeNull();
|
||||
});
|
||||
|
||||
it("produces a cert whose signature verifies against the CA key", () => {
|
||||
const ca = generateCa();
|
||||
const user = generateCa(); // reuse: a valid ed25519 public line
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix:@alice",
|
||||
principals: ["root", "ubuntu"],
|
||||
validAfter: 1000,
|
||||
validBefore: 2000,
|
||||
});
|
||||
expect(cert).not.toBeNull();
|
||||
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
|
||||
|
||||
const { body, rawSig } = splitCert(cert!);
|
||||
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
|
||||
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
|
||||
|
||||
// A different CA must NOT verify.
|
||||
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
|
||||
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
|
||||
});
|
||||
|
||||
it("is accepted and correctly parsed by ssh-keygen -L", () => {
|
||||
let sshKeygen: string;
|
||||
try {
|
||||
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
|
||||
void sshKeygen;
|
||||
} catch (e) {
|
||||
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
|
||||
// fine — it means the binary exists. Only skip if it's truly missing.
|
||||
if ((e as { code?: string }).code === "ENOENT") return;
|
||||
}
|
||||
|
||||
const ca = generateCa();
|
||||
const user = generateCa();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix-test-id",
|
||||
principals: ["deploy"],
|
||||
validAfter: now,
|
||||
validBefore: now + 3600,
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
|
||||
const file = path.join(dir, "id-cert.pub");
|
||||
try {
|
||||
fs.writeFileSync(file, cert + "\n");
|
||||
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(out).toContain("user certificate");
|
||||
expect(out).toContain('Key ID: "termix-test-id"');
|
||||
expect(out).toContain("deploy");
|
||||
expect(out).toMatch(/permit-pty/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyAlgo,
|
||||
parsePublicKey,
|
||||
matchesAlgoFilter,
|
||||
MAX_PUBLIC_KEY_LENGTH,
|
||||
} from "../../../database/routes/termix-id-keys.js";
|
||||
|
||||
// Build a valid OpenSSH public-key line for a given type by encoding a wire
|
||||
// blob whose first string field equals the type (what parsePublicKey checks).
|
||||
function makeKey(type: string, comment = ""): string {
|
||||
const typeBuf = Buffer.from(type, "utf8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(typeBuf.length, 0);
|
||||
const body = Buffer.alloc(40); // arbitrary trailing key material
|
||||
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
|
||||
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
|
||||
}
|
||||
|
||||
describe("classifyAlgo", () => {
|
||||
it("maps known types to normalized groups", () => {
|
||||
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
|
||||
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
|
||||
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
|
||||
expect(classifyAlgo("ssh-dss")).toBe("DSA");
|
||||
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
|
||||
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
|
||||
});
|
||||
|
||||
it("falls back by substring for unknown variants", () => {
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
|
||||
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
|
||||
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePublicKey", () => {
|
||||
it("parses a valid ed25519 key and extracts the comment", () => {
|
||||
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.type).toBe("ssh-ed25519");
|
||||
expect(parsed?.algorithm).toBe("ED25519");
|
||||
expect(parsed?.comment).toBe("alice@laptop");
|
||||
// Comment is stripped from the normalized (dedupe) form.
|
||||
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses SK (FIDO) key types", () => {
|
||||
expect(
|
||||
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
|
||||
).toBe("ED25519-SK");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null],
|
||||
[undefined],
|
||||
[""],
|
||||
[" "],
|
||||
["ssh-ed25519"], // missing blob
|
||||
["ssh-ed25519 not_base64!!"], // bad base64 charset
|
||||
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
|
||||
])("rejects malformed input %p", (input) => {
|
||||
expect(parsePublicKey(input as string)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an over-length line (amplification guard)", () => {
|
||||
const valid = makeKey("ssh-ed25519");
|
||||
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(parsePublicKey(padded)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a blob whose embedded type does not match the prefix", () => {
|
||||
// Declared ssh-rsa but the wire blob says ssh-ed25519.
|
||||
const blob = makeKey("ssh-ed25519").split(" ")[1];
|
||||
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAlgoFilter", () => {
|
||||
it("returns all keys when no filter", () => {
|
||||
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches exactly and is case-insensitive", () => {
|
||||
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
|
||||
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
|
||||
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
|
||||
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = "user-1";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
|
||||
findPublicByIdentityId: vi.fn(),
|
||||
findDecryptedByIdentityId: vi.fn(),
|
||||
createEncryptedForUser: vi.fn(),
|
||||
updateEncryptedForIdentity: vi.fn(),
|
||||
deleteByIdentityId: vi.fn(),
|
||||
})),
|
||||
createCurrentTermixIdentityRepository: vi.fn(() => ({
|
||||
findIdentityForUser: vi.fn(),
|
||||
findIdentityByHandle: vi.fn(),
|
||||
isHandleTaken: vi.fn(),
|
||||
createIdentity: vi.fn(),
|
||||
updateIdentityForUser: vi.fn(),
|
||||
deleteIdentityForUser: vi.fn(),
|
||||
listKeysByIdentityId: vi.fn(),
|
||||
listEnabledKeysByIdentityId: vi.fn(),
|
||||
listLinkedCredentialIds: vi.fn(),
|
||||
createKey: vi.fn(),
|
||||
updateKeyForUser: vi.fn(),
|
||||
deleteKeyForUser: vi.fn(),
|
||||
findKeyForUser: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/termix-id-keys.js", () => ({
|
||||
termixIdKeysRouter: { use: vi.fn() },
|
||||
matchesAlgoFilter: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"where",
|
||||
"set",
|
||||
"values",
|
||||
"returning",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"and",
|
||||
"eq",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
eb?: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb, eb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
|
||||
|
||||
describe("GET /termix-id/linked-credentials", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty list when user has no identity", async () => {
|
||||
// First select (getIdentityForUser) returns nothing; second should not be called
|
||||
mockSelect.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
|
||||
expect(router).toBeDefined();
|
||||
}, 15_000);
|
||||
|
||||
it("returns empty list when identity has no keys", async () => {
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
|
||||
.mockReturnValueOnce(makeChain([])); // keys lookup
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns deduplicated credentialIds for enabled keys", async () => {
|
||||
const keys = [
|
||||
{ credentialId: 10 },
|
||||
{ credentialId: 20 },
|
||||
{ credentialId: 10 }, // duplicate
|
||||
];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes keys with null credentialId", async () => {
|
||||
const keys = [{ credentialId: null }, { credentialId: 5 }];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user