mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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,145 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
|
||||
// Implemented in pure Node so we never shell out or move private keys to disk.
|
||||
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
|
||||
|
||||
const ED25519 = "ssh-ed25519";
|
||||
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
|
||||
const SSH2_CERT_TYPE_USER = 1;
|
||||
|
||||
// Standard login extensions OpenSSH grants by default; must be name-sorted.
|
||||
const DEFAULT_EXTENSIONS = [
|
||||
"permit-X11-forwarding",
|
||||
"permit-agent-forwarding",
|
||||
"permit-port-forwarding",
|
||||
"permit-pty",
|
||||
"permit-user-rc",
|
||||
];
|
||||
|
||||
// --- SSH wire-format primitives -------------------------------------------
|
||||
|
||||
function sshString(value: Buffer | string): Buffer {
|
||||
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length, 0);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
function sshUint32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n >>> 0, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function sshUint64(n: bigint): Buffer {
|
||||
const b = Buffer.alloc(8);
|
||||
b.writeBigUInt64BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ed25519PublicBlob(raw32: Buffer): Buffer {
|
||||
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
|
||||
}
|
||||
|
||||
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
|
||||
export function ed25519RawFromLine(line: string): Buffer | null {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] !== ED25519 || !parts[1]) return null;
|
||||
let blob: Buffer;
|
||||
try {
|
||||
blob = Buffer.from(parts[1], "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let off = 0;
|
||||
const typeLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
|
||||
off += typeLen;
|
||||
const keyLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
const pk = blob.subarray(off, off + keyLen);
|
||||
return pk.length === 32 ? pk : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- CA generation ---------------------------------------------------------
|
||||
|
||||
export interface GeneratedCa {
|
||||
publicKeyLine: string; // "ssh-ed25519 <base64>"
|
||||
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
|
||||
}
|
||||
|
||||
export function generateCa(): GeneratedCa {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const raw = Buffer.from(jwk.x, "base64url");
|
||||
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
|
||||
const privateKeyPem = privateKey
|
||||
.export({ format: "pem", type: "pkcs8" })
|
||||
.toString();
|
||||
return { publicKeyLine, privateKeyPem };
|
||||
}
|
||||
|
||||
// --- Certificate signing ---------------------------------------------------
|
||||
|
||||
export interface SignCertOptions {
|
||||
userPublicKeyLine: string; // ed25519 public key to certify
|
||||
caPrivateKeyPem: string;
|
||||
caPublicKeyLine: string;
|
||||
keyId: string;
|
||||
principals: string[]; // empty = valid for all usernames
|
||||
validAfter: number; // unix seconds
|
||||
validBefore: number; // unix seconds
|
||||
serial?: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an ed25519 user public key into an OpenSSH user certificate.
|
||||
* Returns the certificate line, or null if the inputs aren't ed25519.
|
||||
*/
|
||||
export function signUserCertificate(opts: SignCertOptions): string | null {
|
||||
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
|
||||
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
|
||||
if (!pk || !caRaw) return null;
|
||||
|
||||
const signatureKey = ed25519PublicBlob(caRaw);
|
||||
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
|
||||
const extensions = Buffer.concat(
|
||||
[...DEFAULT_EXTENSIONS]
|
||||
.sort()
|
||||
.map((name) => Buffer.concat([sshString(name), sshString("")])),
|
||||
);
|
||||
|
||||
// Everything up to (not including) the signature: this is what gets signed.
|
||||
const body = Buffer.concat([
|
||||
sshString(CERT_TYPE),
|
||||
sshString(crypto.randomBytes(32)), // nonce
|
||||
sshString(pk),
|
||||
sshUint64(opts.serial ?? 0n),
|
||||
sshUint32(SSH2_CERT_TYPE_USER),
|
||||
sshString(opts.keyId),
|
||||
sshString(principals),
|
||||
sshUint64(BigInt(opts.validAfter)),
|
||||
sshUint64(BigInt(opts.validBefore)),
|
||||
sshString(Buffer.alloc(0)), // critical options (none)
|
||||
sshString(extensions),
|
||||
sshString(Buffer.alloc(0)), // reserved
|
||||
sshString(signatureKey),
|
||||
]);
|
||||
|
||||
const caKey = crypto.createPrivateKey({
|
||||
key: opts.caPrivateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
});
|
||||
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
|
||||
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
|
||||
|
||||
const cert = Buffer.concat([body, sshString(signature)]);
|
||||
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** A GitHub-like public namespace: lowercase, starts alphanumeric, 1-39 chars. */
|
||||
export const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
|
||||
|
||||
const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
export function isValidHandle(handle: string): boolean {
|
||||
return HANDLE_REGEX.test(handle) && !RESERVED_HANDLES.has(handle);
|
||||
}
|
||||
|
||||
export function cleanDescription(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
|
||||
}
|
||||
|
||||
export function cleanLabel(value: unknown): string | null {
|
||||
return typeof value === "string" ? value.trim() || null : null;
|
||||
}
|
||||
|
||||
/** Validity in days: a positive whole number, at most ten years. */
|
||||
export function clampValidityDays(value: unknown, fallback: number): number {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days <= 0) return fallback;
|
||||
return Math.min(Math.floor(days), 3650);
|
||||
}
|
||||
|
||||
interface DbError {
|
||||
code?: string;
|
||||
errno?: number;
|
||||
message?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
function errorChain(error: unknown): DbError[] {
|
||||
const chain: DbError[] = [];
|
||||
let current = error as DbError | undefined;
|
||||
while (current && typeof current === "object" && chain.length < 5) {
|
||||
chain.push(current);
|
||||
current = current.cause as DbError | undefined;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** A UNIQUE violation on sqlite, Postgres or MySQL. */
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return errorChain(error).some(
|
||||
(entry) =>
|
||||
entry.code === "SQLITE_CONSTRAINT_UNIQUE" ||
|
||||
entry.code === "23505" ||
|
||||
entry.code === "ER_DUP_ENTRY" ||
|
||||
entry.errno === 1062 ||
|
||||
(typeof entry.message === "string" &&
|
||||
entry.message.includes("UNIQUE constraint failed")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a UNIQUE violation was on user_id (one handle per user) rather than the handle. */
|
||||
export function isUserIdViolation(error: unknown): boolean {
|
||||
return errorChain(error).some(
|
||||
(entry) =>
|
||||
typeof entry.message === "string" &&
|
||||
(entry.message.includes("user_id") || entry.message.includes("userId")),
|
||||
);
|
||||
}
|
||||
|
||||
export function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { ca, identities, keys } from "./tables.js";
|
||||
import { createStore } from "./store.js";
|
||||
import { PUBLIC_PATHS } from "./resolver.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const store = createStore(ctx, {
|
||||
identities: await ctx.db.define(identities),
|
||||
keys: await ctx.db.define(keys),
|
||||
ca: await ctx.db.define(ca),
|
||||
});
|
||||
|
||||
registerRoutes(ctx, ctx.http.router<Router>({ public: PUBLIC_PATHS }), store);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Pure, dependency-free helpers for SSH public-key parsing/classification used
|
||||
// by the Termix Identity plugin.
|
||||
|
||||
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
|
||||
// line is ~720 chars); this caps the value the unauthenticated resolver later
|
||||
// streams, preventing a multi-MB blob being stored and amplified.
|
||||
export const MAX_PUBLIC_KEY_LENGTH = 8192;
|
||||
|
||||
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
|
||||
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
|
||||
export const ALGO_GROUPS: Record<string, string> = {
|
||||
"ssh-rsa": "RSA",
|
||||
"rsa-sha2-256": "RSA",
|
||||
"rsa-sha2-512": "RSA",
|
||||
"ssh-dss": "DSA",
|
||||
"ssh-ed25519": "ED25519",
|
||||
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
|
||||
"ecdsa-sha2-nistp256": "ECDSA",
|
||||
"ecdsa-sha2-nistp384": "ECDSA",
|
||||
"ecdsa-sha2-nistp521": "ECDSA",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
|
||||
};
|
||||
|
||||
export function classifyAlgo(type: string): string {
|
||||
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
|
||||
if (type.startsWith("ecdsa-")) return "ECDSA";
|
||||
if (type.includes("ed25519")) return "ED25519";
|
||||
if (type.includes("rsa")) return "RSA";
|
||||
if (type.includes("dss") || type.includes("dsa")) return "DSA";
|
||||
return type.toUpperCase();
|
||||
}
|
||||
|
||||
export interface ParsedPublicKey {
|
||||
type: string;
|
||||
algorithm: string;
|
||||
comment: string;
|
||||
normalized: string; // "<type> <base64blob>"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a single OpenSSH public key line. Returns null when the
|
||||
* input is not a well-formed public key.
|
||||
*/
|
||||
export function parsePublicKey(
|
||||
raw: string | null | undefined,
|
||||
): ParsedPublicKey | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
|
||||
const line = raw.trim().replace(/\s+/g, " ");
|
||||
if (!line) return null;
|
||||
|
||||
const parts = line.split(" ");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const [type, blob, ...rest] = parts;
|
||||
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
|
||||
|
||||
const decoded = Buffer.from(blob, "base64");
|
||||
// The blob is an SSH wire-format string whose first field repeats the type.
|
||||
if (decoded.length < 8) return null;
|
||||
try {
|
||||
const len = decoded.readUInt32BE(0);
|
||||
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
|
||||
const embeddedType = decoded.toString("utf8", 4, 4 + len);
|
||||
if (embeddedType !== type) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
algorithm: classifyAlgo(type),
|
||||
comment: rest.join(" "),
|
||||
normalized: `${type} ${blob}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
|
||||
* filter. Exact match only: `ED25519` must NOT also return `ED25519-SK`.
|
||||
*/
|
||||
export function matchesAlgoFilter(
|
||||
algorithm: string,
|
||||
filter: string | null,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
return algorithm.toUpperCase() === filter.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { HANDLE_REGEX, asError } from "./handles.js";
|
||||
import { matchesAlgoFilter } from "./keys.js";
|
||||
import type { KeyRow, Store } from "./store.js";
|
||||
|
||||
/**
|
||||
* The resolver needs no login: servers fetch it from scripts. Paths are
|
||||
* relative to /plugin-api/termix-identity/.
|
||||
*/
|
||||
export const PUBLIC_PATHS = ["/u/:handle", "/u/:handle/:algo"];
|
||||
|
||||
/** The public resolver path for a handle, below the base URL. */
|
||||
export function resolverPath(handle: string): string {
|
||||
return `/plugin-api/termix-identity/u/${handle}`;
|
||||
}
|
||||
|
||||
// A disabled or removed key must not keep being served by a cache, and the
|
||||
// feed stays out of search indexes. Set on every response, 404s included.
|
||||
function setResolverHeaders(res: Response) {
|
||||
res.setHeader("Cache-Control", "no-store, max-age=0");
|
||||
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
||||
}
|
||||
|
||||
function notFound(res: Response, body = "Not found\n") {
|
||||
return res.status(404).type("text/plain").send(body);
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function renderHtml(
|
||||
handle: string,
|
||||
keys: Array<Pick<KeyRow, "algorithm" | "publicKey">>,
|
||||
resolverUrl: string,
|
||||
): string {
|
||||
const keyRows = keys.length
|
||||
? keys
|
||||
.map(
|
||||
(key) =>
|
||||
`<div class="key"><span class="algo">${escapeHtml(
|
||||
key.algorithm,
|
||||
)}</span><code>${escapeHtml(key.publicKey)}</code></div>`,
|
||||
)
|
||||
.join("")
|
||||
: `<p class="empty">No public keys published yet.</p>`;
|
||||
|
||||
// A standalone page, not the React app, so the dark theme is inlined.
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Termix ID @${escapeHtml(handle)}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0c0d0b; --panel: #141614; --panel-2: #181a17; --border: rgba(255,255,255,.12);
|
||||
--fg: #f8f8f6; --muted: #8f9189; --accent: #f59145;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); line-height: 1.5;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 32px 16px 48px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); }
|
||||
header.card { padding: 16px 18px; display: flex; align-items: center; gap: 10px; }
|
||||
h1 { font-size: 1.05rem; font-weight: 700; margin: 0; letter-spacing: .02em; color: var(--fg); }
|
||||
h1 .handle { color: var(--accent); }
|
||||
.label { font-size: 10px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 22px 0 8px; }
|
||||
pre.cmd { margin: 0; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
|
||||
padding: 12px 14px; overflow-x: auto; font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.keys { display: flex; flex-direction: column; gap: 8px; }
|
||||
.key { background: var(--panel); border: 1px solid var(--border); padding: 10px 12px;
|
||||
display: flex; align-items: center; gap: 10px; overflow-x: auto; }
|
||||
.algo { flex: 0 0 auto; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--accent); border: 1px solid rgba(245,145,69,.4); padding: 1px 5px; line-height: 1.4; }
|
||||
code { white-space: pre; word-break: break-all; font-size: 12px; color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.empty { color: var(--muted); font-size: 13px; }
|
||||
footer { margin-top: 28px; color: var(--muted); font-size: 11px; letter-spacing: .04em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="card">
|
||||
<h1>Termix ID <span class="handle">@${escapeHtml(handle)}</span></h1>
|
||||
</header>
|
||||
|
||||
<div class="label">Provision a server</div>
|
||||
<pre class="cmd">curl -fsSL ${escapeHtml(resolverUrl)} >> ~/.ssh/authorized_keys</pre>
|
||||
|
||||
<div class="label">Published keys</div>
|
||||
<div class="keys">${keyRows}</div>
|
||||
|
||||
<footer>Served by Termix</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function createResolver(ctx: PluginContext, store: Store) {
|
||||
const handleOf = (req: Request) =>
|
||||
String(req.params.handle || "").toLowerCase();
|
||||
|
||||
/** authorized_keys as text, or a small viewer for browsers. */
|
||||
async function keys(req: Request, res: Response) {
|
||||
setResolverHeaders(res);
|
||||
const handle = handleOf(req);
|
||||
if (!HANDLE_REGEX.test(handle)) return notFound(res);
|
||||
const algoFilter = req.params.algo
|
||||
? String(req.params.algo).toUpperCase()
|
||||
: null;
|
||||
|
||||
try {
|
||||
const identity = await store.identityByHandle(handle);
|
||||
if (!identity) return notFound(res);
|
||||
|
||||
const published = (await store.enabledKeys(identity.id)).filter((key) =>
|
||||
matchesAlgoFilter(key.algorithm, algoFilter),
|
||||
);
|
||||
|
||||
if ((req.headers.accept || "").includes("text/html")) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return res.send(
|
||||
renderHtml(
|
||||
handle,
|
||||
published,
|
||||
`${ctx.http.baseUrl(req)}${resolverPath(handle)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = published
|
||||
.map((key) => `${key.publicKey} #termix-id @${handle}`)
|
||||
.join("\n");
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
return res.send(body ? `${body}\n` : "");
|
||||
} catch (error) {
|
||||
ctx.log.error("Termix ID resolve failed", asError(error));
|
||||
return res.status(500).type("text/plain").send("Internal Server Error\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** The CA public key, for TrustedUserCAKeys or an @cert-authority line. */
|
||||
async function caKey(req: Request, res: Response) {
|
||||
setResolverHeaders(res);
|
||||
const handle = handleOf(req);
|
||||
if (!HANDLE_REGEX.test(handle)) return notFound(res);
|
||||
|
||||
try {
|
||||
const identity = await store.identityByHandle(handle);
|
||||
if (!identity) return notFound(res);
|
||||
const ca = await store.caForIdentity(identity.id);
|
||||
if (!ca) return notFound(res, "No CA configured\n");
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
|
||||
} catch (error) {
|
||||
ctx.log.error("Termix ID CA resolve failed", asError(error));
|
||||
return res.status(500).type("text/plain").send("Internal Server Error\n");
|
||||
}
|
||||
}
|
||||
|
||||
return { keys, caKey };
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
// ssh2 is CommonJS and its `utils` named export is not visible to ESM, so it
|
||||
// is read off the default import.
|
||||
import ssh2 from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { parsePublicKey } from "./keys.js";
|
||||
import {
|
||||
ed25519RawFromLine,
|
||||
generateCa,
|
||||
signUserCertificate,
|
||||
} from "./certificate.js";
|
||||
import {
|
||||
asError,
|
||||
clampValidityDays,
|
||||
cleanDescription,
|
||||
cleanLabel,
|
||||
isUniqueViolation,
|
||||
isUserIdViolation,
|
||||
isValidHandle,
|
||||
} from "./handles.js";
|
||||
import { createResolver, resolverPath } from "./resolver.js";
|
||||
import type { IdentityRow, Store } from "./store.js";
|
||||
|
||||
const ALREADY_HAVE_ONE =
|
||||
"You already have a Termix ID. Use update to rename it.";
|
||||
|
||||
function parseId(value: unknown): number | null {
|
||||
const id = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function registerRoutes(
|
||||
ctx: PluginContext,
|
||||
router: Router,
|
||||
store: Store,
|
||||
): void {
|
||||
const resolver = createResolver(ctx, store);
|
||||
|
||||
const userId = () => {
|
||||
const actor = ctx.currentActor();
|
||||
if (!actor) throw new Error("No acting user");
|
||||
return actor;
|
||||
};
|
||||
|
||||
const withUrls = (req: Request, handle: string, suffix = "") => {
|
||||
const path = `${resolverPath(handle)}${suffix}`;
|
||||
return {
|
||||
resolverPath: path,
|
||||
resolverUrl: `${ctx.http.baseUrl(req)}${path}`,
|
||||
};
|
||||
};
|
||||
|
||||
const fail = (res: Response, message: string, error: unknown) => {
|
||||
ctx.log.error(message, asError(error));
|
||||
res.status(500).json({ error: message });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/u/{handle}/ca:
|
||||
* get:
|
||||
* summary: Public CA key for a handle
|
||||
* description: Public. The certificate authority's public key, for TrustedUserCAKeys or an @cert-authority line. The 2.8 URL /termix-id/u/{handle}/ca redirects here.
|
||||
* tags: [Termix ID]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: handle
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The CA public key as text.
|
||||
* 404:
|
||||
* description: No such handle, or it has no CA.
|
||||
*/
|
||||
router.get("/u/:handle/ca", resolver.caKey);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/u/{handle}:
|
||||
* get:
|
||||
* summary: Published keys for a handle
|
||||
* description: Public. authorized_keys lines for every enabled key, or an HTML viewer when the client accepts text/html. Never cached. The 2.8 URL /termix-id/u/{handle} redirects here.
|
||||
* tags: [Termix ID]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: handle
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The keys as text or HTML.
|
||||
* 404:
|
||||
* description: No such handle.
|
||||
*/
|
||||
router.get("/u/:handle", resolver.keys);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/u/{handle}/{algo}:
|
||||
* get:
|
||||
* summary: Published keys of one algorithm for a handle
|
||||
* description: Public. Like /u/{handle}, keeping only keys whose algorithm group matches (RSA, ED25519, ECDSA and so on). The 2.8 URL /termix-id/u/{handle}/{algo} redirects here.
|
||||
* tags: [Termix ID]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: handle
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: algo
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The matching keys as text or HTML.
|
||||
* 404:
|
||||
* description: No such handle.
|
||||
*/
|
||||
router.get("/u/:handle/:algo", resolver.keys);
|
||||
|
||||
// Everything below needs a signed-in user holding termix-identity.use.
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/me:
|
||||
* get:
|
||||
* summary: Get the current user's Termix ID and keys
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Identity and keys (identity may be null)
|
||||
*/
|
||||
router.get("/me", async (req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity) return res.json({ identity: null, keys: [] });
|
||||
res.json({
|
||||
identity: { ...identity, ...withUrls(req, identity.handle) },
|
||||
keys: await store.keysForIdentity(identity.id),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to fetch Termix ID", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/check/{handle}:
|
||||
* get:
|
||||
* summary: Check if a handle is available
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: handle
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Availability and validity status
|
||||
*/
|
||||
router.get("/check/:handle", async (req, res) => {
|
||||
const handle = String(req.params.handle || "").toLowerCase();
|
||||
if (!isValidHandle(handle)) {
|
||||
return res.json({ available: false, valid: false });
|
||||
}
|
||||
try {
|
||||
res.json({
|
||||
available: !(await store.isHandleTaken(handle)),
|
||||
valid: true,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to check handle", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity:
|
||||
* post:
|
||||
* summary: Create a Termix ID
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [handle]
|
||||
* properties:
|
||||
* handle:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created identity
|
||||
* 400:
|
||||
* description: Invalid handle
|
||||
* 409:
|
||||
* description: Handle taken or user already has an identity
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
const handle = String(req.body?.handle || "").toLowerCase();
|
||||
if (!isValidHandle(handle)) {
|
||||
return res.status(400).json({ error: "Invalid handle" });
|
||||
}
|
||||
try {
|
||||
const owner = userId();
|
||||
if (await store.identityForUser(owner)) {
|
||||
return res.status(409).json({ error: ALREADY_HAVE_ONE });
|
||||
}
|
||||
if (await store.isHandleTaken(handle)) {
|
||||
return res.status(409).json({ error: "Handle already taken" });
|
||||
}
|
||||
const identity = await store.createIdentity({
|
||||
userId: owner,
|
||||
handle,
|
||||
description: cleanDescription(req.body?.description),
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "create_termix_id",
|
||||
resourceType: "termix_id",
|
||||
resourceId: String(identity.id),
|
||||
resourceName: handle,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json(identity);
|
||||
} catch (error) {
|
||||
// The checks above race a double submit; the UNIQUE constraints on
|
||||
// handle and user_id are the real guard.
|
||||
if (isUniqueViolation(error)) {
|
||||
return res.status(409).json({
|
||||
error: isUserIdViolation(error)
|
||||
? ALREADY_HAVE_ONE
|
||||
: "Handle already taken",
|
||||
});
|
||||
}
|
||||
fail(res, "Failed to create Termix ID", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity:
|
||||
* put:
|
||||
* summary: Update Termix ID handle or description
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* handle:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated identity
|
||||
* 404:
|
||||
* description: No Termix ID
|
||||
* 409:
|
||||
* description: Handle taken
|
||||
*/
|
||||
router.put("/", async (req, res) => {
|
||||
try {
|
||||
const owner = userId();
|
||||
const identity = await store.identityForUser(owner);
|
||||
if (!identity)
|
||||
return res.status(404).json({ error: "No Termix ID found" });
|
||||
|
||||
const update: { handle?: string; description?: string | null } = {};
|
||||
if (req.body?.handle !== undefined) {
|
||||
const handle = String(req.body.handle || "").toLowerCase();
|
||||
if (!isValidHandle(handle)) {
|
||||
return res.status(400).json({ error: "Invalid handle" });
|
||||
}
|
||||
if (handle !== identity.handle && (await store.isHandleTaken(handle))) {
|
||||
return res.status(409).json({ error: "Handle already taken" });
|
||||
}
|
||||
update.handle = handle;
|
||||
}
|
||||
if (req.body?.description !== undefined) {
|
||||
update.description = cleanDescription(req.body.description);
|
||||
}
|
||||
|
||||
const updated = await store.updateIdentity(owner, update);
|
||||
await ctx.audit.record({
|
||||
action: "update_termix_id",
|
||||
resourceType: "termix_id",
|
||||
resourceId: String(identity.id),
|
||||
resourceName: updated?.handle ?? identity.handle,
|
||||
details:
|
||||
update.handle && update.handle !== identity.handle
|
||||
? `renamed ${identity.handle} -> ${update.handle}`
|
||||
: undefined,
|
||||
success: true,
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
return res.status(409).json({ error: "Handle already taken" });
|
||||
}
|
||||
fail(res, "Failed to update Termix ID", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity:
|
||||
* delete:
|
||||
* summary: Delete Termix ID with its keys and CA
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted
|
||||
* 404:
|
||||
* description: No Termix ID
|
||||
*/
|
||||
router.delete("/", async (_req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity)
|
||||
return res.status(404).json({ error: "No Termix ID found" });
|
||||
await store.deleteIdentity(identity);
|
||||
await ctx.audit.record({
|
||||
action: "delete_termix_id",
|
||||
resourceType: "termix_id",
|
||||
resourceId: String(identity.id),
|
||||
resourceName: identity.handle,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to delete Termix ID", error);
|
||||
}
|
||||
});
|
||||
|
||||
const needIdentity = async (res: Response): Promise<IdentityRow | null> => {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Create a Termix ID handle before adding keys" });
|
||||
}
|
||||
return identity;
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/keys:
|
||||
* post:
|
||||
* summary: Publish a public key
|
||||
* description: Publishes a pasted key, or the public half of one of the user's saved SSH key credentials.
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* publicKey:
|
||||
* type: string
|
||||
* credentialId:
|
||||
* type: integer
|
||||
* label:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Published key
|
||||
* 400:
|
||||
* description: No identity, or the key is invalid
|
||||
* 404:
|
||||
* description: Credential not found
|
||||
* 409:
|
||||
* description: Already published
|
||||
*/
|
||||
router.post("/keys", async (req, res) => {
|
||||
const credentialId =
|
||||
req.body?.credentialId != null ? parseId(req.body.credentialId) : null;
|
||||
try {
|
||||
const identity = await needIdentity(res);
|
||||
if (!identity) return;
|
||||
|
||||
let rawPublicKey: string | null = null;
|
||||
let source = "manual";
|
||||
let label = cleanLabel(req.body?.label);
|
||||
|
||||
if (credentialId) {
|
||||
const credential = (await ctx.credentials.listSshKeys()).find(
|
||||
(key) => key.id === credentialId,
|
||||
);
|
||||
if (!credential) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
if (!credential.publicKey) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"This credential has no public key and one could not be derived. Paste the public key manually.",
|
||||
});
|
||||
}
|
||||
source = "credential";
|
||||
label = label || credential.name || null;
|
||||
rawPublicKey = credential.publicKey;
|
||||
} else if (typeof req.body?.publicKey === "string") {
|
||||
rawPublicKey = req.body.publicKey;
|
||||
}
|
||||
|
||||
const parsed = parsePublicKey(rawPublicKey);
|
||||
if (!parsed) {
|
||||
return res.status(400).json({ error: "Invalid SSH public key" });
|
||||
}
|
||||
|
||||
const existing = await store.keysForIdentity(identity.id);
|
||||
if (existing.some((key) => key.publicKey === parsed.normalized)) {
|
||||
return res.status(409).json({ error: "This key is already published" });
|
||||
}
|
||||
|
||||
const key = await store.createKey({
|
||||
identityId: identity.id,
|
||||
userId: identity.userId,
|
||||
publicKey: parsed.normalized,
|
||||
keyType: parsed.type,
|
||||
algorithm: parsed.algorithm,
|
||||
label,
|
||||
comment: parsed.comment || null,
|
||||
source,
|
||||
credentialId: credentialId || null,
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "add_termix_id_key",
|
||||
resourceType: "termix_id_key",
|
||||
resourceId: String(key.id),
|
||||
resourceName: identity.handle,
|
||||
details: `${parsed.algorithm} (${source})`,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json(key);
|
||||
} catch (error) {
|
||||
fail(res, "Failed to add key", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/keys/generate:
|
||||
* post:
|
||||
* summary: Generate a new key pair and publish the public key
|
||||
* description: The private key is returned once. With saveCredential (the default) the pair is also saved as an SSH key credential.
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [ed25519, rsa]
|
||||
* label:
|
||||
* type: string
|
||||
* saveCredential:
|
||||
* type: boolean
|
||||
* username:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Generated key info with the private key (shown once)
|
||||
*/
|
||||
router.post("/keys/generate", async (req, res) => {
|
||||
const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519";
|
||||
const saveCredential = req.body?.saveCredential !== false;
|
||||
const username =
|
||||
typeof req.body?.username === "string" ? req.body.username.trim() : null;
|
||||
try {
|
||||
const identity = await needIdentity(res);
|
||||
if (!identity) return;
|
||||
|
||||
const comment = `termix-${identity.handle}`;
|
||||
const pair =
|
||||
keyType === "rsa"
|
||||
? ssh2.utils.generateKeyPairSync("rsa", { bits: 4096, comment })
|
||||
: ssh2.utils.generateKeyPairSync("ed25519", { comment });
|
||||
const parsed = parsePublicKey(pair.public);
|
||||
if (!parsed) {
|
||||
return res.status(500).json({ error: "Key generation failed" });
|
||||
}
|
||||
|
||||
// The published row only ever holds the public key; the pair goes to
|
||||
// the user's encrypted credentials. Publish first so a failed save can
|
||||
// be undone here, since the plugin cannot delete credentials.
|
||||
const key = await store.createKey({
|
||||
identityId: identity.id,
|
||||
userId: identity.userId,
|
||||
publicKey: parsed.normalized,
|
||||
keyType: parsed.type,
|
||||
algorithm: parsed.algorithm,
|
||||
label: cleanLabel(req.body?.label) || `Generated ${parsed.algorithm}`,
|
||||
comment: parsed.comment || null,
|
||||
source: "generated",
|
||||
credentialId: null,
|
||||
});
|
||||
|
||||
let credentialId: number | null = null;
|
||||
if (saveCredential) {
|
||||
try {
|
||||
({ id: credentialId } = await ctx.credentials.createSshKey({
|
||||
name: `Termix ID @${identity.handle} (${parsed.algorithm})`,
|
||||
description: "Auto-generated by Termix ID",
|
||||
username,
|
||||
privateKey: pair.private,
|
||||
publicKey: parsed.normalized,
|
||||
keyType: parsed.type,
|
||||
}));
|
||||
await store.updateKey(identity.userId, key.id, { credentialId });
|
||||
} catch (error) {
|
||||
await store.deleteKey(identity.userId, key.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.audit.record({
|
||||
action: "generate_termix_id_key",
|
||||
resourceType: "termix_id_key",
|
||||
resourceId: String(key.id),
|
||||
resourceName: identity.handle,
|
||||
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json({
|
||||
key: { ...key, credentialId },
|
||||
privateKey: pair.private,
|
||||
publicKey: parsed.normalized,
|
||||
credentialId,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to generate key", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/keys/{id}:
|
||||
* patch:
|
||||
* summary: Update key metadata (enabled state or label)
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* label:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated key
|
||||
* 404:
|
||||
* description: Key not found
|
||||
*/
|
||||
router.patch("/keys/:id", async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid key id" });
|
||||
try {
|
||||
const update: { enabled?: boolean; label?: string | null } = {};
|
||||
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
|
||||
if (req.body?.label !== undefined)
|
||||
update.label = cleanLabel(req.body.label);
|
||||
const key = await store.updateKey(userId(), id, update);
|
||||
if (!key) return res.status(404).json({ error: "Key not found" });
|
||||
await ctx.audit.record({
|
||||
action: "update_termix_id_key",
|
||||
resourceType: "termix_id_key",
|
||||
resourceId: String(id),
|
||||
resourceName: key.label ?? key.algorithm,
|
||||
details:
|
||||
update.enabled !== undefined
|
||||
? `enabled: ${update.enabled}`
|
||||
: "label updated",
|
||||
success: true,
|
||||
});
|
||||
res.json(key);
|
||||
} catch (error) {
|
||||
fail(res, "Failed to update key", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/keys/{id}:
|
||||
* delete:
|
||||
* summary: Revoke and delete a published key
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted
|
||||
* 404:
|
||||
* description: Key not found
|
||||
*/
|
||||
router.delete("/keys/:id", async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid key id" });
|
||||
try {
|
||||
const deleted = await store.deleteKey(userId(), id);
|
||||
if (!deleted) return res.status(404).json({ error: "Key not found" });
|
||||
await ctx.audit.record({
|
||||
action: "delete_termix_id_key",
|
||||
resourceType: "termix_id_key",
|
||||
resourceId: String(id),
|
||||
resourceName: deleted.algorithm,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to delete key", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/ca:
|
||||
* get:
|
||||
* summary: Get the current user's certificate authority
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CA info or null
|
||||
*/
|
||||
router.get("/ca", async (req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity) return res.json({ ca: null });
|
||||
const ca = await store.caForIdentity(identity.id);
|
||||
if (!ca) return res.json({ ca: null });
|
||||
res.json({
|
||||
ca: {
|
||||
publicKey: ca.publicKey,
|
||||
validityDays: ca.validityDays,
|
||||
...withUrls(req, identity.handle, "/ca"),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to fetch CA", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/ca:
|
||||
* post:
|
||||
* summary: Create a certificate authority
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* validityDays:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created CA
|
||||
* 409:
|
||||
* description: CA already exists
|
||||
*/
|
||||
router.post("/ca", async (req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Create a Termix ID handle first" });
|
||||
}
|
||||
if (await store.caForIdentity(identity.id)) {
|
||||
return res.status(409).json({ error: "CA already exists" });
|
||||
}
|
||||
const validityDays = clampValidityDays(req.body?.validityDays, 90);
|
||||
const generated = generateCa();
|
||||
await store.createCa({
|
||||
identityId: identity.id,
|
||||
userId: identity.userId,
|
||||
publicKey: generated.publicKeyLine,
|
||||
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
|
||||
validityDays,
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "create_termix_id_ca",
|
||||
resourceType: "termix_id_ca",
|
||||
resourceName: identity.handle,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json({
|
||||
publicKey: generated.publicKeyLine,
|
||||
validityDays,
|
||||
...withUrls(req, identity.handle, "/ca"),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
return res.status(409).json({ error: "CA already exists" });
|
||||
}
|
||||
fail(res, "Failed to create CA", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/ca/rotate:
|
||||
* post:
|
||||
* summary: Rotate the certificate authority (revokes all issued certificates)
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* validityDays:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New CA public key
|
||||
* 404:
|
||||
* description: No Termix ID or no CA
|
||||
*/
|
||||
router.post("/ca/rotate", async (req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity)
|
||||
return res.status(404).json({ error: "No Termix ID found" });
|
||||
const existing = await store.caForIdentity(identity.id);
|
||||
if (!existing) return res.status(404).json({ error: "No CA to rotate" });
|
||||
|
||||
const validityDays = clampValidityDays(
|
||||
req.body?.validityDays,
|
||||
existing.validityDays,
|
||||
);
|
||||
// A new key invalidates every certificate the old one signed: this is
|
||||
// the revocation mechanism.
|
||||
const generated = generateCa();
|
||||
await store.replaceCa(identity.id, {
|
||||
publicKey: generated.publicKeyLine,
|
||||
privateKey: await ctx.secrets.seal(generated.privateKeyPem),
|
||||
validityDays,
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "rotate_termix_id_ca",
|
||||
resourceType: "termix_id_ca",
|
||||
resourceName: identity.handle,
|
||||
success: true,
|
||||
});
|
||||
res.json({
|
||||
publicKey: generated.publicKeyLine,
|
||||
validityDays,
|
||||
...withUrls(req, identity.handle, "/ca"),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to rotate CA", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/ca:
|
||||
* delete:
|
||||
* summary: Delete the certificate authority
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted
|
||||
* 404:
|
||||
* description: No Termix ID or no CA
|
||||
*/
|
||||
router.delete("/ca", async (_req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity)
|
||||
return res.status(404).json({ error: "No Termix ID found" });
|
||||
if (!(await store.deleteCa(identity.id))) {
|
||||
return res.status(404).json({ error: "No CA to delete" });
|
||||
}
|
||||
await ctx.audit.record({
|
||||
action: "delete_termix_id_ca",
|
||||
resourceType: "termix_id_ca",
|
||||
resourceName: identity.handle,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to delete CA", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/keys/{id}/certificate:
|
||||
* post:
|
||||
* summary: Issue an SSH certificate for a key (Ed25519 only)
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* validityDays:
|
||||
* type: integer
|
||||
* principals:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Issued certificate
|
||||
* 400:
|
||||
* description: Not an Ed25519 key, or no CA
|
||||
* 404:
|
||||
* description: Key not found
|
||||
*/
|
||||
router.post("/keys/:id/certificate", async (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid key id" });
|
||||
try {
|
||||
const owner = userId();
|
||||
const key = await store.keyForUser(owner, id);
|
||||
if (!key) return res.status(404).json({ error: "Key not found" });
|
||||
if (!ed25519RawFromLine(key.publicKey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Certificates are only supported for Ed25519 keys" });
|
||||
}
|
||||
const identity = await store.identityForUser(owner);
|
||||
if (!identity)
|
||||
return res.status(404).json({ error: "No Termix ID found" });
|
||||
const ca = await store.caForIdentity(identity.id);
|
||||
const caPrivateKey = ca ? await ctx.secrets.unseal(ca.privateKey) : null;
|
||||
if (!ca || !caPrivateKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Enable a certificate authority first" });
|
||||
}
|
||||
|
||||
const validityDays = clampValidityDays(
|
||||
req.body?.validityDays,
|
||||
ca.validityDays,
|
||||
);
|
||||
const principals: string[] = Array.isArray(req.body?.principals)
|
||||
? req.body.principals
|
||||
.filter((p: unknown) => typeof p === "string" && p.trim())
|
||||
.map((p: string) => p.trim())
|
||||
.slice(0, 32)
|
||||
: [];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const validBefore = now + validityDays * 86400;
|
||||
const keyId = `termix:@${identity.handle}:${id}`;
|
||||
const certificate = signUserCertificate({
|
||||
userPublicKeyLine: key.publicKey,
|
||||
caPrivateKeyPem: caPrivateKey,
|
||||
caPublicKeyLine: ca.publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
validAfter: now - 60,
|
||||
validBefore,
|
||||
});
|
||||
if (!certificate) {
|
||||
return res.status(500).json({ error: "Failed to sign certificate" });
|
||||
}
|
||||
|
||||
await ctx.audit.record({
|
||||
action: "issue_termix_id_certificate",
|
||||
resourceType: "termix_id_key",
|
||||
resourceId: String(id),
|
||||
resourceName: identity.handle,
|
||||
details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`,
|
||||
success: true,
|
||||
});
|
||||
res.json({ certificate, keyId, validBefore, principals, validityDays });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to issue certificate", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/linked-credentials:
|
||||
* get:
|
||||
* summary: Credential ids with at least one enabled published key
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of credential ids
|
||||
*/
|
||||
router.get("/linked-credentials", async (_req, res) => {
|
||||
try {
|
||||
const identity = await store.identityForUser(userId());
|
||||
if (!identity) return res.json({ credentialIds: [] });
|
||||
res.json({
|
||||
credentialIds: await store.linkedCredentialIds(identity.id),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to fetch linked credentials", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/termix-identity/credentials:
|
||||
* get:
|
||||
* summary: The user's SSH key credentials that can be published
|
||||
* description: Id and name of each saved SSH key credential, for the import picker. No key material.
|
||||
* tags: [Termix ID]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of key credentials
|
||||
*/
|
||||
router.get("/credentials", async (_req, res) => {
|
||||
try {
|
||||
const keys = await ctx.credentials.listSshKeys();
|
||||
res.json({
|
||||
credentials: keys.map((key) => ({ id: key.id, name: key.name })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, "Failed to fetch credentials", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { and, asc, eq, isNotNull } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the tables back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface IdentityRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
handle: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface KeyRow {
|
||||
id: number;
|
||||
identityId: number;
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
keyType: string;
|
||||
algorithm: string;
|
||||
label: string | null;
|
||||
comment: string | null;
|
||||
source: string;
|
||||
credentialId: number | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CaRow {
|
||||
id: number;
|
||||
identityId: number;
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
/** Sealed with ctx.secrets.seal. */
|
||||
privateKey: string;
|
||||
validityDays: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type NewKey = Omit<KeyRow, "id" | "enabled" | "createdAt">;
|
||||
|
||||
export interface Tables {
|
||||
identities: Table;
|
||||
keys: Table;
|
||||
ca: Table;
|
||||
}
|
||||
|
||||
function toKey(row: Record<string, unknown>): KeyRow {
|
||||
return {
|
||||
...(row as unknown as KeyRow),
|
||||
enabled: row.enabled === true || row.enabled === 1 || row.enabled === "1",
|
||||
};
|
||||
}
|
||||
|
||||
export type Store = ReturnType<typeof createStore>;
|
||||
|
||||
export function createStore(ctx: PluginContext, tables: Tables) {
|
||||
const { identities, keys, ca } = tables;
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
|
||||
async function first<T>(query: Promise<T[]>): Promise<T | null> {
|
||||
const rows = await query;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
const identityForUser = async (userId: string) =>
|
||||
first<IdentityRow>(
|
||||
(await client())
|
||||
.select()
|
||||
.from(identities)
|
||||
.where(eq(identities.userId, userId))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
const identityByHandle = async (handle: string) =>
|
||||
first<IdentityRow>(
|
||||
(await client())
|
||||
.select()
|
||||
.from(identities)
|
||||
.where(eq(identities.handle, handle))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
const keysForIdentity = async (identityId: number): Promise<KeyRow[]> =>
|
||||
(
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(keys)
|
||||
.where(eq(keys.identityId, identityId))
|
||||
.orderBy(asc(keys.id))
|
||||
).map(toKey);
|
||||
|
||||
const keyForUser = async (userId: string, id: number) => {
|
||||
const row = await first<Record<string, unknown>>(
|
||||
(await client())
|
||||
.select()
|
||||
.from(keys)
|
||||
.where(and(eq(keys.id, id), eq(keys.userId, userId)))
|
||||
.limit(1),
|
||||
);
|
||||
return row ? toKey(row) : null;
|
||||
};
|
||||
|
||||
const caForIdentity = async (identityId: number) =>
|
||||
first<CaRow>(
|
||||
(await client())
|
||||
.select()
|
||||
.from(ca)
|
||||
.where(eq(ca.identityId, identityId))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
return {
|
||||
identityForUser,
|
||||
identityByHandle,
|
||||
|
||||
async isHandleTaken(handle: string): Promise<boolean> {
|
||||
return (await identityByHandle(handle)) !== null;
|
||||
},
|
||||
|
||||
async createIdentity(input: {
|
||||
userId: string;
|
||||
handle: string;
|
||||
description: string | null;
|
||||
}): Promise<IdentityRow> {
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(identities)
|
||||
.values({ ...input, createdAt: now, updatedAt: now });
|
||||
await ctx.db.persist();
|
||||
return (await identityForUser(input.userId)) as IdentityRow;
|
||||
},
|
||||
|
||||
async updateIdentity(
|
||||
userId: string,
|
||||
update: { handle?: string; description?: string | null },
|
||||
): Promise<IdentityRow | null> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(identities)
|
||||
.set({ ...update, updatedAt: new Date().toISOString() })
|
||||
.where(eq(identities.userId, userId));
|
||||
await ctx.db.persist();
|
||||
return identityForUser(userId);
|
||||
},
|
||||
|
||||
/** Deletes the identity with its keys and CA. */
|
||||
async deleteIdentity(identity: IdentityRow): Promise<void> {
|
||||
const db = await client();
|
||||
await db.delete(keys).where(eq(keys.identityId, identity.id));
|
||||
await db.delete(ca).where(eq(ca.identityId, identity.id));
|
||||
await db.delete(identities).where(eq(identities.id, identity.id));
|
||||
await ctx.db.persist();
|
||||
},
|
||||
|
||||
keysForIdentity,
|
||||
keyForUser,
|
||||
|
||||
async enabledKeys(identityId: number): Promise<KeyRow[]> {
|
||||
return (await keysForIdentity(identityId)).filter((key) => key.enabled);
|
||||
},
|
||||
|
||||
async createKey(input: NewKey): Promise<KeyRow> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(keys)
|
||||
.values({
|
||||
...input,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await ctx.db.persist();
|
||||
// A public key appears once per identity, so it finds the new row.
|
||||
const row = await first<Record<string, unknown>>(
|
||||
(await client())
|
||||
.select()
|
||||
.from(keys)
|
||||
.where(
|
||||
and(
|
||||
eq(keys.identityId, input.identityId),
|
||||
eq(keys.publicKey, input.publicKey),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
);
|
||||
return toKey(row as Record<string, unknown>);
|
||||
},
|
||||
|
||||
async updateKey(
|
||||
userId: string,
|
||||
id: number,
|
||||
update: {
|
||||
enabled?: boolean;
|
||||
label?: string | null;
|
||||
credentialId?: number | null;
|
||||
},
|
||||
): Promise<KeyRow | null> {
|
||||
if (!(await keyForUser(userId, id))) return null;
|
||||
if (Object.keys(update).length > 0) {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(keys)
|
||||
.set(update)
|
||||
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
|
||||
await ctx.db.persist();
|
||||
}
|
||||
return keyForUser(userId, id);
|
||||
},
|
||||
|
||||
async deleteKey(userId: string, id: number): Promise<KeyRow | null> {
|
||||
const existing = await keyForUser(userId, id);
|
||||
if (!existing) return null;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.delete(keys)
|
||||
.where(and(eq(keys.id, id), eq(keys.userId, userId)));
|
||||
await ctx.db.persist();
|
||||
return existing;
|
||||
},
|
||||
|
||||
async linkedCredentialIds(identityId: number): Promise<number[]> {
|
||||
const rows: Array<{ credentialId: number | null }> = await (
|
||||
await client()
|
||||
)
|
||||
.select({ credentialId: keys.credentialId })
|
||||
.from(keys)
|
||||
.where(
|
||||
and(
|
||||
eq(keys.identityId, identityId),
|
||||
eq(keys.enabled, true),
|
||||
isNotNull(keys.credentialId),
|
||||
),
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => Number(row.credentialId))
|
||||
.filter((id) => Number.isInteger(id) && id > 0),
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
caForIdentity,
|
||||
|
||||
async createCa(input: {
|
||||
identityId: number;
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
validityDays: number;
|
||||
}): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(ca)
|
||||
.values({ ...input, createdAt: now, updatedAt: now });
|
||||
await ctx.db.persist();
|
||||
},
|
||||
|
||||
async replaceCa(
|
||||
identityId: number,
|
||||
update: { publicKey: string; privateKey: string; validityDays: number },
|
||||
): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(ca)
|
||||
.set({ ...update, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ca.identityId, identityId));
|
||||
await ctx.db.persist();
|
||||
},
|
||||
|
||||
async deleteCa(identityId: number): Promise<boolean> {
|
||||
if (!(await caForIdentity(identityId))) return false;
|
||||
await (await client()).delete(ca).where(eq(ca.identityId, identityId));
|
||||
await ctx.db.persist();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/** One public handle per user. Adopted from core's termix_identities. */
|
||||
export const identities = adoptLegacyTable(
|
||||
"termix_identities",
|
||||
defineTable("identities", {
|
||||
id: id(),
|
||||
userId: refUser().unique(),
|
||||
handle: varchar().notNull().unique(),
|
||||
description: text(),
|
||||
createdAt: text().notNull().defaultNow(),
|
||||
updatedAt: text().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Published public keys, plaintext because the resolver serves them without
|
||||
* auth. Adopted from core's termix_identity_keys. The migrations keep the
|
||||
* legacy links to identities (cascade) and ssh_credentials (set null) by hand.
|
||||
*/
|
||||
export const keys = adoptLegacyTable(
|
||||
"termix_identity_keys",
|
||||
defineTable(
|
||||
"keys",
|
||||
{
|
||||
id: id(),
|
||||
identityId: integer().notNull(),
|
||||
userId: refUser(),
|
||||
publicKey: text().notNull(),
|
||||
keyType: text().notNull(),
|
||||
algorithm: text().notNull(),
|
||||
label: text(),
|
||||
comment: text(),
|
||||
source: text().notNull().default("manual"),
|
||||
credentialId: integer(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
createdAt: text().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_termix_identity_keys_identity",
|
||||
columns: ["identityId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* One certificate authority per identity. The private key is sealed with
|
||||
* ctx.secrets.seal; 2.8 rows are resealed by core's
|
||||
* termix-identity-ca-migration. Adopted from core's termix_identity_ca.
|
||||
*/
|
||||
export const ca = adoptLegacyTable(
|
||||
"termix_identity_ca",
|
||||
defineTable("ca", {
|
||||
id: id(),
|
||||
identityId: integer().notNull().unique(),
|
||||
userId: refUser(),
|
||||
publicKey: text().notNull(),
|
||||
privateKey: text().notNull(),
|
||||
validityDays: integer().notNull().default(90),
|
||||
createdAt: text().notNull().defaultNow(),
|
||||
updatedAt: text().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [identities, keys, ca];
|
||||
Reference in new issue
Block a user