mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user