feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

@@ -0,0 +1,127 @@
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 "../../src/backend/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 });
}
});
});
@@ -0,0 +1,144 @@
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,
type TestDbOptions,
} from "@termix/plugin-sdk/testing";
import type { PluginSshKeyCredential } 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;
/** Core's ssh_credentials, which the keys table points at. */
export const CREDENTIALS_DDL =
"CREATE TABLE IF NOT EXISTS ssh_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT)";
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: {
user?: string | null;
body?: unknown;
headers?: Record<string, string>;
},
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any; text: string; headers: Headers }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed, the acting user taken from the request,
* and a request without one refused unless its path is public.
*/
export async function startServer(
options: {
permissions?: string[];
capabilities?: string[];
sshKeyCredentials?: PluginSshKeyCredential[];
before?: TestDbOptions["before"];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
options.before?.(sqlite);
},
});
for (const user of ["user-1", "user-2"]) {
db.sqlite.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)").run(user);
}
// Credential ids the fake ctx.credentials hands out.
for (let id = 1; id <= 10; id++) {
db.sqlite
.prepare("INSERT OR IGNORE INTO ssh_credentials (id) VALUES (?)")
.run(id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
sshKeyCredentials: options.sshKeyCredentials,
actor: "user-1",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const publicPaths = mock.httpRouters[0]?.public ?? [];
const isPublic = (path: string) =>
publicPaths.some((pattern) => {
const want = pattern.split("/");
const got = path.split("/");
return (
want.length === got.length &&
want.every((part, i) => part.startsWith(":") || part === got[i])
);
});
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const user = req.header("x-test-user") || undefined;
if (!user && !isPublic(req.path)) {
res.status(401).json({ error: "Authentication required" });
return;
}
mock.setActor(user);
next();
});
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,
async request(method, path, { user = "user-1", body, headers } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
redirect: "manual",
headers: {
...(user ? { "x-test-user": user } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
return {
status: response.status,
body: parsed,
text,
headers: response.headers,
};
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import {
classifyAlgo,
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "../../src/backend/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);
});
});
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { CREDENTIALS_DDL, pluginDir } from "./helpers";
// The three tables as core's SQLite bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE termix_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
handle TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_termix_identities_user ON termix_identities(user_id);
CREATE TABLE termix_identity_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
algorithm TEXT NOT NULL,
label TEXT,
comment TEXT,
source TEXT NOT NULL DEFAULT 'manual',
credential_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
CREATE INDEX idx_termix_identity_keys_identity ON termix_identity_keys(identity_id);
CREATE TABLE termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexes = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?",
)
.all(table) as Array<{ name: string }>
).map((row) => row.name);
const count = (table: string) =>
(
db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
function seed() {
db!.sqlite.exec(`
INSERT INTO p_termix_identity_identities (id, user_id, handle) VALUES (1, 'user-1', 'alice');
INSERT INTO p_termix_identity_keys (identity_id, user_id, public_key, key_type, algorithm, credential_id)
VALUES (1, 'user-1', 'ssh-ed25519 AAAA', 'ssh-ed25519', 'ED25519', 3);
INSERT INTO p_termix_identity_ca (identity_id, user_id, public_key, private_key)
VALUES (1, 'user-1', 'ssh-ed25519 BBBB', 'sealed');
`);
}
describe("adopting the termix identity tables", () => {
it("keeps every existing row, index and link when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_credentials (id) VALUES (3)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO termix_identities (id, user_id, handle, description) VALUES (1, 'user-1', 'alice', 'me');
INSERT INTO termix_identity_keys (identity_id, user_id, public_key, key_type, algorithm, label, credential_id, enabled)
VALUES (1, 'user-1', 'ssh-ed25519 AAAA', 'ssh-ed25519', 'ED25519', 'Laptop', 3, 0);
INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (1, 'user-1', 'ssh-ed25519 BBBB', 'dek-encrypted', 30);
`);
},
});
expect(tableExists("termix_identities")).toBe(false);
expect(tableExists("termix_identity_keys")).toBe(false);
expect(tableExists("termix_identity_ca")).toBe(false);
expect(
db.sqlite
.prepare("SELECT handle, description FROM p_termix_identity_identities")
.get(),
).toEqual({ handle: "alice", description: "me" });
expect(
db.sqlite
.prepare(
"SELECT label, credential_id, enabled FROM p_termix_identity_keys",
)
.get(),
).toEqual({ label: "Laptop", credential_id: 3, enabled: 0 });
expect(
db.sqlite
.prepare("SELECT private_key, validity_days FROM p_termix_identity_ca")
.get(),
).toEqual({ private_key: "dek-encrypted", validity_days: 30 });
expect(indexes("p_termix_identity_identities")).toContain(
"idx_termix_identities_user",
);
expect(
indexes("p_termix_identity_keys").filter(
(name) => name === "idx_termix_identity_keys_identity",
),
).toHaveLength(1);
// Links follow the rename.
db.sqlite.exec("DELETE FROM ssh_credentials WHERE id = 3");
expect(
db.sqlite
.prepare("SELECT credential_id FROM p_termix_identity_keys")
.get(),
).toEqual({ credential_id: null });
db.sqlite.exec("DELETE FROM p_termix_identity_identities WHERE id = 1");
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
it("creates fresh tables with the same links when there is nothing to adopt", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(CREDENTIALS_DDL);
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_credentials (id) VALUES (3)");
},
});
expect(tableExists("termix_identities")).toBe(false);
seed();
expect(indexes("p_termix_identity_keys")).toContain(
"idx_termix_identity_keys_identity",
);
db.sqlite.exec("DELETE FROM ssh_credentials WHERE id = 3");
expect(
db.sqlite
.prepare("SELECT credential_id FROM p_termix_identity_keys")
.get(),
).toEqual({ credential_id: null });
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
expect(count("p_termix_identity_identities")).toBe(0);
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
});
@@ -0,0 +1,595 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import ssh2 from "ssh2";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { ed25519RawFromLine } from "../../src/backend/certificate.js";
import { manifest, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
function ed25519Line(comment = "") {
const pair = ssh2.utils.generateKeyPairSync("ed25519");
const parsed = ssh2.utils.parseKey(pair.public);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key instanceof Error) throw key;
const line = `${key.type} ${key.getPublicSSH().toString("base64")}`;
return comment ? `${line} ${comment}` : line;
}
/** Checks an OpenSSH user certificate's signature against a CA public key. */
function certificateVerifies(certLine: string, caPublicLine: string): boolean {
const blob = Buffer.from(certLine.split(" ")[1], "base64");
let offset = 0;
const skipString = () => {
offset += 4 + blob.readUInt32BE(offset);
};
// type, nonce, key, serial, cert type, key id, principals, valid after,
// valid before, critical options, extensions, reserved, signature key.
skipString();
skipString();
skipString();
offset += 8 + 4;
skipString();
skipString();
offset += 8 + 8;
skipString();
skipString();
skipString();
skipString();
const body = blob.subarray(0, offset);
const signature = blob.subarray(offset + 4);
const typeLength = signature.readUInt32BE(0);
const rawLength = signature.readUInt32BE(4 + typeLength);
const raw = signature.subarray(8 + typeLength, 8 + typeLength + rawLength);
const caRaw = ed25519RawFromLine(caPublicLine)!;
const publicKey = crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: caRaw.toString("base64url") },
format: "jwk",
});
return crypto.verify(null, body, publicKey, raw);
}
async function claim(handle = "alice", user = "user-1") {
const response = await server!.request("POST", "/", {
user,
body: { handle, description: "me" },
});
expect(response.status).toBe(201);
return response.body;
}
describe("identity", () => {
it("claims, checks, renames and deletes a handle", async () => {
server = await startServer();
expect((await server.request("GET", "/me")).body).toEqual({
identity: null,
keys: [],
});
expect((await server.request("GET", "/check/alice")).body).toEqual({
available: true,
valid: true,
});
expect((await server.request("GET", "/check/me")).body.valid).toBe(false);
expect((await server.request("GET", "/check/Bad!")).body.valid).toBe(false);
await claim("Alice");
const me = (await server.request("GET", "/me")).body;
expect(me.identity.handle).toBe("alice");
expect(me.identity.resolverPath).toBe(
"/plugin-api/termix-identity/u/alice",
);
expect(me.identity.resolverUrl).toBe(
"https://termix.test/plugin-api/termix-identity/u/alice",
);
expect((await server.request("GET", "/check/alice")).body.available).toBe(
false,
);
const renamed = await server.request("PUT", "/", {
body: { handle: "alice2" },
});
expect(renamed.status).toBe(200);
expect(renamed.body.handle).toBe("alice2");
expect((await server.request("DELETE", "/")).status).toBe(200);
expect((await server.request("GET", "/me")).body.identity).toBeNull();
expect(server.mock.audits.map((entry) => entry.action)).toEqual(
expect.arrayContaining([
"create_termix_id",
"update_termix_id",
"delete_termix_id",
]),
);
});
it("refuses a second handle, a taken one and an invalid one", async () => {
server = await startServer();
await claim("alice");
const again = await server.request("POST", "/", {
body: { handle: "bob" },
});
expect(again.status).toBe(409);
const taken = await server.request("POST", "/", {
user: "user-2",
body: { handle: "alice" },
});
expect(taken.status).toBe(409);
expect(taken.body.error).toBe("Handle already taken");
const invalid = await server.request("POST", "/", {
user: "user-2",
body: { handle: "-x" },
});
expect(invalid.status).toBe(400);
});
it("deletes the keys and CA with the identity", async () => {
server = await startServer();
await claim();
await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
await server.request("POST", "/ca", { body: {} });
await server.request("DELETE", "/");
const count = (table: string) =>
(
server!.db.sqlite
.prepare(`SELECT COUNT(*) AS n FROM ${table}`)
.get() as { n: number }
).n;
expect(count("p_termix_identity_keys")).toBe(0);
expect(count("p_termix_identity_ca")).toBe(0);
});
});
describe("key publishing", () => {
it("publishes a pasted key and serves it from the public resolver", async () => {
server = await startServer();
await claim();
const line = ed25519Line("laptop");
const added = await server.request("POST", "/keys", {
body: { publicKey: line, label: "Laptop" },
});
expect(added.status).toBe(201);
expect(added.body).toMatchObject({
algorithm: "ED25519",
keyType: "ssh-ed25519",
label: "Laptop",
comment: "laptop",
source: "manual",
enabled: true,
});
const duplicate = await server.request("POST", "/keys", {
body: { publicKey: line },
});
expect(duplicate.status).toBe(409);
const resolved = await server.request("GET", "/u/alice", { user: null });
expect(resolved.status).toBe(200);
expect(resolved.headers.get("cache-control")).toBe("no-store, max-age=0");
expect(resolved.headers.get("x-robots-tag")).toBe("noindex, nofollow");
expect(resolved.text).toBe(
`${line.split(" ").slice(0, 2).join(" ")} #termix-id @alice\n`,
);
});
it("rejects something that is not a public key", async () => {
server = await startServer();
await claim();
const response = await server.request("POST", "/keys", {
body: { publicKey: "not a key" },
});
expect(response.status).toBe(400);
});
it("needs a handle before a key", async () => {
server = await startServer();
const response = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(response.status).toBe(400);
});
it("filters by algorithm and hides disabled keys", async () => {
server = await startServer();
await claim();
const ed = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const rsa = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
await server.request("POST", "/keys", { body: { publicKey: rsa.public } });
const onlyEd = await server.request("GET", "/u/alice/ED25519", {
user: null,
});
expect(onlyEd.text.trim().split("\n")).toHaveLength(1);
expect(onlyEd.text).toContain("ssh-ed25519");
const all = await server.request("GET", "/u/alice", { user: null });
expect(all.text.trim().split("\n")).toHaveLength(2);
const disabled = await server.request("PATCH", `/keys/${ed.body.id}`, {
body: { enabled: false },
});
expect(disabled.body.enabled).toBe(false);
const after = await server.request("GET", "/u/alice", { user: null });
expect(after.text).not.toContain("ssh-ed25519");
});
it("serves an HTML viewer to browsers without an em dash", async () => {
server = await startServer();
await claim();
const page = await server.request("GET", "/u/alice", {
user: null,
headers: { accept: "text/html" },
});
expect(page.headers.get("content-type")).toContain("text/html");
expect(page.text).toContain(
"curl -fsSL https://termix.test/plugin-api/termix-identity/u/alice",
);
expect(page.text).not.toContain("—");
});
it("answers 404 for an unknown handle", async () => {
server = await startServer();
expect(
(await server.request("GET", "/u/nobody", { user: null })).status,
).toBe(404);
});
it("imports the public half of a saved key credential", async () => {
const line = ed25519Line();
server = await startServer({
sshKeyCredentials: [
{ id: 5, name: "Work key", username: "root", publicKey: line },
{ id: 6, name: "Broken", username: null, publicKey: null },
],
});
await claim();
const listed = await server.request("GET", "/credentials");
expect(listed.body.credentials).toEqual([
{ id: 5, name: "Work key" },
{ id: 6, name: "Broken" },
]);
const imported = await server.request("POST", "/keys", {
body: { credentialId: 5 },
});
expect(imported.status).toBe(201);
expect(imported.body).toMatchObject({
source: "credential",
credentialId: 5,
label: "Work key",
});
expect((await server.request("GET", "/linked-credentials")).body).toEqual({
credentialIds: [5],
});
expect(
(await server.request("POST", "/keys", { body: { credentialId: 6 } }))
.status,
).toBe(400);
expect(
(await server.request("POST", "/keys", { body: { credentialId: 99 } }))
.status,
).toBe(404);
});
it("generates a key pair, saves it as a credential and returns the private key once", async () => {
server = await startServer();
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: { type: "ed25519" },
});
expect(generated.status).toBe(201);
expect(generated.body.privateKey).toContain("PRIVATE KEY");
expect(generated.body.credentialId).toBe(1);
expect(server.mock.createdSshKeys).toHaveLength(1);
expect(server.mock.createdSshKeys[0]).toMatchObject({
name: "Termix ID @alice (ED25519)",
publicKey: generated.body.publicKey,
keyType: "ssh-ed25519",
});
const stored = server.db.sqlite
.prepare("SELECT * FROM p_termix_identity_keys")
.all() as Array<Record<string, unknown>>;
expect(stored).toHaveLength(1);
expect(stored[0].credential_id).toBe(1);
expect(JSON.stringify(stored)).not.toContain("PRIVATE KEY");
});
it("generates without saving when asked", async () => {
server = await startServer();
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: { saveCredential: false },
});
expect(generated.status).toBe(201);
expect(generated.body.credentialId).toBeNull();
expect(server.mock.createdSshKeys).toHaveLength(0);
});
it("removes the published key when saving the credential fails", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter(
(capability) => capability !== "credentials:write",
),
});
await claim();
const generated = await server.request("POST", "/keys/generate", {
body: {},
});
expect(generated.status).toBe(500);
expect((await server.request("GET", "/me")).body.keys).toEqual([]);
});
it("only touches the caller's own keys", async () => {
server = await startServer();
await claim();
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(
(
await server.request("DELETE", `/keys/${key.body.id}`, {
user: "user-2",
})
).status,
).toBe(404);
expect(
(await server.request("DELETE", `/keys/${key.body.id}`)).status,
).toBe(200);
});
});
describe("certificate authority", () => {
it("creates a CA, stores its key sealed and publishes the public key", async () => {
server = await startServer();
await claim();
const created = await server.request("POST", "/ca", {
body: { validityDays: 30 },
});
expect(created.status).toBe(201);
expect(created.body.validityDays).toBe(30);
expect(created.body.resolverUrl).toBe(
"https://termix.test/plugin-api/termix-identity/u/alice/ca",
);
const row = server.db.sqlite
.prepare("SELECT private_key FROM p_termix_identity_ca")
.get() as { private_key: string };
expect(row.private_key).not.toContain("PRIVATE KEY");
expect(await server.mock.ctx.secrets.unseal(row.private_key)).toContain(
"PRIVATE KEY",
);
const published = await server.request("GET", "/u/alice/ca", {
user: null,
});
expect(published.status).toBe(200);
expect(published.text).toBe(
`${created.body.publicKey} termix-id-ca@alice\n`,
);
expect((await server.request("POST", "/ca", { body: {} })).status).toBe(
409,
);
});
it("issues a certificate the CA signed, with the principals asked for", async () => {
server = await startServer();
await claim();
const ca = (await server.request("POST", "/ca", { body: {} })).body;
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const issued = await server.request(
"POST",
`/keys/${key.body.id}/certificate`,
{ body: { principals: ["root", " deploy ", ""], validityDays: 2 } },
);
expect(issued.status).toBe(200);
expect(issued.body.principals).toEqual(["root", "deploy"]);
expect(issued.body.validityDays).toBe(2);
expect(issued.body.keyId).toBe(`termix:@alice:${key.body.id}`);
expect(issued.body.certificate).toMatch(
/^ssh-ed25519-cert-v01@openssh.com /,
);
const now = Math.floor(Date.now() / 1000);
expect(issued.body.validBefore).toBeGreaterThan(now + 86400);
expect(issued.body.validBefore).toBeLessThanOrEqual(now + 2 * 86400 + 5);
expect(certificateVerifies(issued.body.certificate, ca.publicKey)).toBe(
true,
);
expect(server.mock.audits.map((entry) => entry.action)).toContain(
"issue_termix_id_certificate",
);
});
it("refuses a certificate without a CA or for a non-Ed25519 key", async () => {
server = await startServer();
await claim();
const ed = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
expect(
(
await server.request("POST", `/keys/${ed.body.id}/certificate`, {
body: {},
})
).status,
).toBe(400);
await server.request("POST", "/ca", { body: {} });
const rsa = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
const rsaKey = await server.request("POST", "/keys", {
body: { publicKey: rsa.public },
});
expect(
(
await server.request("POST", `/keys/${rsaKey.body.id}/certificate`, {
body: {},
})
).status,
).toBe(400);
});
it("rotation replaces the key, so old certificates stop verifying", async () => {
server = await startServer();
await claim();
const first = (await server.request("POST", "/ca", { body: {} })).body;
const key = await server.request("POST", "/keys", {
body: { publicKey: ed25519Line() },
});
const oldCert = (
await server.request("POST", `/keys/${key.body.id}/certificate`, {
body: {},
})
).body.certificate;
const rotated = await server.request("POST", "/ca/rotate", {
body: { validityDays: 7 },
});
expect(rotated.status).toBe(200);
expect(rotated.body.publicKey).not.toBe(first.publicKey);
expect(rotated.body.validityDays).toBe(7);
const published = (
await server.request("GET", "/u/alice/ca", { user: null })
).text;
expect(published).toContain(rotated.body.publicKey);
expect(certificateVerifies(oldCert, rotated.body.publicKey)).toBe(false);
const newCert = (
await server.request("POST", `/keys/${key.body.id}/certificate`, {
body: {},
})
).body;
expect(newCert.validityDays).toBe(7);
expect(
certificateVerifies(newCert.certificate, rotated.body.publicKey),
).toBe(true);
});
it("deletes the CA", async () => {
server = await startServer();
await claim();
await server.request("POST", "/ca", { body: {} });
expect((await server.request("DELETE", "/ca")).status).toBe(200);
expect((await server.request("GET", "/ca")).body).toEqual({ ca: null });
expect((await server.request("DELETE", "/ca")).status).toBe(404);
expect(
(await server.request("GET", "/u/alice/ca", { user: null })).status,
).toBe(404);
});
});
describe("access", () => {
const managementRoutes: Array<[string, string]> = [
["GET", "/me"],
["GET", "/check/alice"],
["POST", "/"],
["PUT", "/"],
["DELETE", "/"],
["POST", "/keys"],
["POST", "/keys/generate"],
["PATCH", "/keys/1"],
["DELETE", "/keys/1"],
["POST", "/keys/1/certificate"],
["GET", "/ca"],
["POST", "/ca"],
["POST", "/ca/rotate"],
["DELETE", "/ca"],
["GET", "/linked-credentials"],
["GET", "/credentials"],
];
it("serves the resolver without auth", async () => {
server = await startServer({ permissions: [] });
await server.db.sqlite
.prepare(
"INSERT INTO p_termix_identity_identities (user_id, handle) VALUES ('user-1', 'alice')",
)
.run();
for (const path of ["/u/alice", "/u/alice/ED25519"]) {
expect((await server.request("GET", path, { user: null })).status).toBe(
200,
);
}
expect(
(await server.request("GET", "/u/alice/ca", { user: null })).status,
).toBe(404);
});
it.each(managementRoutes)("needs a login for %s %s", async (method, path) => {
server = await startServer();
const response = await server.request(method, path, {
user: null,
body: method === "GET" ? undefined : {},
});
expect(response.status).toBe(401);
});
it.each(managementRoutes)(
"needs termix-identity.use for %s %s",
async (method, path) => {
server = await startServer({ permissions: [] });
const response = await server.request(method, path, {
body: method === "GET" ? undefined : {},
});
expect(response.status).toBe(403);
},
);
});
describe("activate", () => {
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(capability);
},
);
it("cannot seal a CA key without secrets:own", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
});
await claim();
expect((await server.request("POST", "/ca", { body: {} })).status).toBe(
500,
);
expect((await server.request("GET", "/ca")).body).toEqual({ ca: null });
});
it("cannot read saved keys without credentials:use", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter(
(c) => c !== "credentials:use",
),
sshKeyCredentials: [{ id: 5, name: "k", username: null, publicKey: "x" }],
});
expect((await server.request("GET", "/credentials")).status).toBe(500);
});
});