Files
Termix/src/backend/database/routes/termix-id.ts
T

1425 lines
43 KiB
TypeScript
Raw Normal View History

2026-06-29 13:28:26 -05:00
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import {
+8
2026-07-19 12:29:52 -05:00
createCurrentCredentialRepository,
createCurrentTermixIdentityRepository,
createCurrentTermixIdentityCaRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
2026-06-29 13:28:26 -05:00
// ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named
// export, so we use a default import (esModuleInterop) and read `.utils` off it.
import ssh2 from "ssh2";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "./ssh-certificate.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Handle: github-like public namespace. lowercase, starts alphanumeric,
// 1-39 chars of [a-z0-9_-].
const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
// Max stored length for a free-text description.
const MAX_DESCRIPTION_LENGTH = 500;
function cleanDescription(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
}
// True when a write failed because of a UNIQUE constraint (handle / one-per-user),
// so the handler can return a clean 409 instead of a generic 500.
function isUniqueConstraintError(err: {
code?: string;
message?: string;
}): boolean {
return (
err?.code === "SQLITE_CONSTRAINT_UNIQUE" ||
(typeof err?.message === "string" &&
err.message.includes("UNIQUE constraint failed"))
);
}
// A create can violate either the handle or the one-per-user (user_id) UNIQUE
// constraint in a concurrent double-submit; report the right 409 for each.
function uniqueConstraintMessage(err: { message?: string }): string {
return typeof err?.message === "string" && err.message.includes("user_id")
? "You already have a Termix ID. Use update to rename it."
: "Handle already taken";
}
/**
* Best-effort derivation of a public key from a private key using ssh2, used
* when importing a credential that only stored a private key.
*/
async function derivePublicFromPrivate(
privateKey: string,
passphrase?: string,
): Promise<string | null> {
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
if (!parsed || parsed instanceof Error) return null;
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
async function getIdentityForUser(userId: string) {
+8
2026-07-19 12:29:52 -05:00
return createCurrentTermixIdentityRepository().findIdentityForUser(userId);
2026-06-29 13:28:26 -05:00
}
// Resolve the real username for audit_logs (the column expects a username, not
// the user id), matching how the credentials/snippets routes log.
async function getActorUsername(userId: string): Promise<string> {
+8
2026-07-19 12:29:52 -05:00
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
2026-06-29 13:28:26 -05:00
}
// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
// small HTML viewer for browsers, keyed by handle, with optional /<ALGO> filter.
// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed
// key could keep being served after revocation), and keep it out of search
// indexes. Applied to EVERY response — including early 404s.
function setResolverHeaders(res: Response) {
res.setHeader("Cache-Control", "no-store, max-age=0");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
}
async function resolveHandle(req: Request, res: Response) {
setResolverHeaders(res);
const handle = String(req.params.handle || "").toLowerCase();
const algoFilter = req.params.algo
? String(req.params.algo).toUpperCase()
: null;
if (!HANDLE_REGEX.test(handle)) {
return res.status(404).type("text/plain").send("Not found\n");
}
try {
+8
2026-07-19 12:29:52 -05:00
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
2026-06-29 13:28:26 -05:00
+8
2026-07-19 12:29:52 -05:00
if (!identity) {
2026-06-29 13:28:26 -05:00
return res.status(404).type("text/plain").send("Not found\n");
}
+8
2026-07-19 12:29:52 -05:00
let keys =
await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId(
identity.id,
);
2026-06-29 13:28:26 -05:00
if (algoFilter) {
keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter));
}
const wantsHtml = (req.headers.accept || "").includes("text/html");
if (wantsHtml) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.send(renderHtml(handle, keys));
}
const body = keys
.map((k) => `${k.publicKey} #termix-id @${handle}`)
.join("\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(body ? `${body}\n` : "");
} catch (err) {
authLogger.error("Termix ID resolve failed", err);
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function renderHtml(
handle: string,
keys: Array<{ algorithm: string; comment: string | null; publicKey: string }>,
): string {
const keyRows = keys.length
? keys
.map(
(k) =>
`<div class="key"><span class="algo">${escapeHtml(
k.algorithm,
)}</span><code>${escapeHtml(k.publicKey)}</code></div>`,
)
.join("")
: `<p class="empty">No public keys published yet.</p>`;
// Standalone page (not the React app), so the Termix 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 https://your-termix-host/termix-id/u/${escapeHtml(
handle,
)} >> ~/.ssh/authorized_keys</pre>
<script>
(function () {
var el = document.querySelector("pre.cmd");
if (el) el.textContent =
"curl -fsSL " + location.origin + "/termix-id/u/${escapeHtml(
handle,
)} >> ~/.ssh/authorized_keys";
})();
</script>
<div class="label">Published keys</div>
<div class="keys">${keyRows}</div>
<footer>Served by Termix</footer>
</div>
</body>
</html>`;
}
// Public CA key — for `TrustedUserCAKeys` or an `@cert-authority` line. Must be
// registered before `/u/:handle/:algo` so "ca" is not treated as an algo filter.
async function caResolver(req: Request, res: Response) {
setResolverHeaders(res);
const handle = String(req.params.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle)) {
return res.status(404).type("text/plain").send("Not found\n");
}
try {
+8
2026-07-19 12:29:52 -05:00
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
if (!identity) {
2026-06-29 13:28:26 -05:00
return res.status(404).type("text/plain").send("Not found\n");
}
+8
2026-07-19 12:29:52 -05:00
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
2026-06-29 13:28:26 -05:00
res.setHeader("Content-Type", "text/plain; charset=utf-8");
+8
2026-07-19 12:29:52 -05:00
if (!ca) {
2026-06-29 13:28:26 -05:00
return res.status(404).send("No CA configured\n");
}
+8
2026-07-19 12:29:52 -05:00
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
2026-06-29 13:28:26 -05:00
} catch (err) {
authLogger.error("Termix ID CA resolve failed", err);
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
router.get("/u/:handle/ca", caResolver);
router.get("/u/:handle", resolveHandle);
router.get("/u/:handle/:algo", resolveHandle);
// Authenticated management API.
/**
* @openapi
* /termix-id/me:
* get:
* summary: Get 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", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.json({ identity: null, keys: [] });
}
+8
2026-07-19 12:29:52 -05:00
const keys =
await createCurrentTermixIdentityRepository().listKeysByIdentityId(
identity.id,
);
2026-06-29 13:28:26 -05:00
res.json({
identity: {
...identity,
resolverPath: `/termix-id/u/${identity.handle}`,
},
keys,
});
} catch (err) {
authLogger.error("Failed to fetch Termix ID", err);
res.status(500).json({ error: "Failed to fetch Termix ID" });
}
});
/**
* @openapi
* /termix-id/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",
authenticateJWT,
async (req: Request, res: Response) => {
const handle = String(req.params.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.json({ available: false, valid: false });
}
try {
+8
2026-07-19 12:29:52 -05:00
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
res.json({ available: !taken, valid: true });
2026-06-29 13:28:26 -05:00
} catch (err) {
authLogger.error("Failed to check Termix ID handle", err);
res.status(500).json({ error: "Failed to check handle" });
}
},
);
/**
* @openapi
* /termix-id:
* 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
* 409:
* description: Handle taken or user already has an identity
*/
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const handle = String(req.body?.handle || "").toLowerCase();
const description = cleanDescription(req.body?.description);
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
try {
const owned = await getIdentityForUser(userId);
if (owned) {
return res.status(409).json({
error: "You already have a Termix ID. Use update to rename it.",
});
}
+8
2026-07-19 12:29:52 -05:00
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const taken = await termixIdentityRepository.isHandleTaken(handle);
if (taken) {
2026-06-29 13:28:26 -05:00
return res.status(409).json({ error: "Handle already taken" });
}
+8
2026-07-19 12:29:52 -05:00
const inserted = await termixIdentityRepository.createIdentity({
userId,
handle,
description,
});
2026-06-29 13:28:26 -05:00
databaseLogger.info("Termix ID created", {
operation: "termix_id_create",
userId,
handle,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "create_termix_id",
resourceType: "termix_id",
+8
2026-07-19 12:29:52 -05:00
resourceId: String(inserted.id),
2026-06-29 13:28:26 -05:00
resourceName: handle,
ipAddress,
userAgent,
success: true,
});
+8
2026-07-19 12:29:52 -05:00
res.status(201).json(inserted);
2026-06-29 13:28:26 -05:00
} catch (err) {
// The check-then-insert above is not atomic; the UNIQUE constraints on
// handle and user_id are the real guard, so map a violation to 409.
if (isUniqueConstraintError(err)) {
return res.status(409).json({ error: uniqueConstraintMessage(err) });
}
authLogger.error("Failed to create Termix ID", err);
res.status(500).json({ error: "Failed to create Termix ID" });
}
});
/**
* @openapi
* /termix-id:
* 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
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.status(404).json({ error: "No Termix ID found" });
}
const updates: { handle?: string; description?: string | null } = {};
if (req.body?.handle !== undefined) {
const handle = String(req.body.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
if (handle !== identity.handle) {
+8
2026-07-19 12:29:52 -05:00
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
if (taken) {
2026-06-29 13:28:26 -05:00
return res.status(409).json({ error: "Handle already taken" });
}
}
updates.handle = handle;
}
if (req.body?.description !== undefined) {
updates.description = cleanDescription(req.body.description);
}
+8
2026-07-19 12:29:52 -05:00
const updated =
await createCurrentTermixIdentityRepository().updateIdentityForUser(
userId,
{ ...updates, updatedAt: new Date().toISOString() },
);
2026-06-29 13:28:26 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "update_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
+8
2026-07-19 12:29:52 -05:00
resourceName: updated?.handle ?? identity.handle,
2026-06-29 13:28:26 -05:00
details:
updates.handle && updates.handle !== identity.handle
? `renamed ${identity.handle} -> ${updates.handle}`
: undefined,
ipAddress,
userAgent,
success: true,
});
+8
2026-07-19 12:29:52 -05:00
res.json(updated);
2026-06-29 13:28:26 -05:00
} catch (err) {
if (isUniqueConstraintError(err)) {
return res.status(409).json({ error: "Handle already taken" });
}
authLogger.error("Failed to update Termix ID", err);
res.status(500).json({ error: "Failed to update Termix ID" });
}
});
/**
* @openapi
* /termix-id:
* delete:
* summary: Delete Termix ID and all associated keys
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
*/
router.delete("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.status(404).json({ error: "No Termix ID found" });
}
+8
2026-07-19 12:29:52 -05:00
await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId);
2026-06-29 13:28:26 -05:00
databaseLogger.info("Termix ID deleted", {
operation: "termix_id_delete",
userId,
handle: identity.handle,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID", err);
res.status(500).json({ error: "Failed to delete Termix ID" });
}
});
/**
* @openapi
* /termix-id/keys:
* post:
* summary: Publish a public key
* 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
*/
router.post(
"/keys",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const label =
typeof req.body?.label === "string"
? req.body.label.trim() || null
: null;
const credentialId = req.body?.credentialId
? parseInt(String(req.body.credentialId), 10)
: null;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
let rawPublicKey: string | null = null;
let source = "manual";
let resolvedLabel = label;
if (credentialId) {
+8
2026-07-19 12:29:52 -05:00
const cred =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!cred) {
2026-06-29 13:28:26 -05:00
return res.status(404).json({ error: "Credential not found" });
}
// The UI only offers key credentials, but the UI is not authoritative —
// reject non-key credentials server-side before treating cred.key as a
// private key.
if (cred.authType !== "key") {
return res
.status(400)
.json({ error: "Selected credential is not an SSH key" });
}
source = "credential";
resolvedLabel = resolvedLabel || cred.name || null;
if (cred.publicKey && typeof cred.publicKey === "string") {
rawPublicKey = cred.publicKey;
} else {
const priv = cred.privateKey || cred.key || undefined;
if (priv) {
rawPublicKey = await derivePublicFromPrivate(
priv,
cred.keyPassword || undefined,
);
}
if (!rawPublicKey) {
return res.status(400).json({
error:
"This credential has no public key and one could not be derived. Paste the public key manually.",
});
}
}
} else {
rawPublicKey =
typeof req.body?.publicKey === "string" ? req.body.publicKey : null;
}
const parsed = parsePublicKey(rawPublicKey);
if (!parsed) {
return res.status(400).json({ error: "Invalid SSH public key" });
}
// Dedupe within this identity by normalized "<type> <blob>".
+8
2026-07-19 12:29:52 -05:00
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const existing = await termixIdentityRepository.listKeysByIdentityId(
identity.id,
);
2026-06-29 13:28:26 -05:00
if (existing.some((k) => k.publicKey === parsed.normalized)) {
return res.status(409).json({ error: "This key is already published" });
}
+8
2026-07-19 12:29:52 -05:00
const inserted = await termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: resolvedLabel,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
});
2026-06-29 13:28:26 -05:00
databaseLogger.info("Termix ID key added", {
operation: "termix_id_key_add",
userId,
algorithm: parsed.algorithm,
source,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "add_termix_id_key",
resourceType: "termix_id_key",
+8
2026-07-19 12:29:52 -05:00
resourceId: String(inserted.id),
2026-06-29 13:28:26 -05:00
resourceName: identity.handle,
details: `${parsed.algorithm} (${source})`,
ipAddress,
userAgent,
success: true,
});
+8
2026-07-19 12:29:52 -05:00
res.status(201).json(inserted);
2026-06-29 13:28:26 -05:00
} catch (err) {
authLogger.error("Failed to add Termix ID key", err);
res.status(500).json({ error: "Failed to add key" });
}
},
);
/**
* @openapi
* /termix-id/keys/generate:
* post:
* summary: Generate a new key pair and publish the public key
* 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 private key (shown once)
*/
router.post(
"/keys/generate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519";
const label =
typeof req.body?.label === "string"
? req.body.label.trim() || null
: null;
// Default to saving the generated key pair into the encrypted credential
// vault so it can be used to connect from Termix (opt out with false).
const saveCredential = req.body?.saveCredential !== false;
const credentialUsername =
typeof req.body?.username === "string" ? req.body.username.trim() : null;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
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" });
}
// Optionally persist the FULL key pair (incl. private key) into the
// encrypted ssh_credentials vault — the same secure, per-user-encrypted
// store used for host credentials. The Termix ID tables themselves only ever
// hold the public key, because the resolver endpoint is unauthenticated.
let credentialId: number | null = null;
if (saveCredential) {
const credData = {
userId,
name: `Termix ID @${identity.handle} (${parsed.algorithm})`,
description: "Auto-generated by Termix ID",
folder: null,
tags: "",
authType: "key",
username: credentialUsername || null,
password: null,
key: pair.private,
privateKey: pair.private,
publicKey: parsed.normalized,
keyPassword: null,
keyType: null,
detectedKeyType: parsed.type,
usageCount: 0,
lastUsed: null,
};
+8
2026-07-19 12:29:52 -05:00
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
credData,
);
2026-06-29 13:28:26 -05:00
credentialId = created.id;
}
+8
2026-07-19 12:29:52 -05:00
// There is no single transaction here because credential encryption is async,
// so if publishing the key fails
2026-06-29 13:28:26 -05:00
// after the vault credential was created, compensate by deleting it to
// avoid leaving an orphaned credential behind.
+8
2026-07-19 12:29:52 -05:00
const termixIdentityRepository = createCurrentTermixIdentityRepository();
2026-06-29 13:28:26 -05:00
const runInsert = () =>
+8
2026-07-19 12:29:52 -05:00
termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: label || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId,
});
2026-06-29 13:28:26 -05:00
let inserted: Awaited<ReturnType<typeof runInsert>>;
try {
inserted = await runInsert();
} catch (insertErr) {
if (credentialId !== null) {
try {
+8
2026-07-19 12:29:52 -05:00
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
2026-06-29 13:28:26 -05:00
} catch {
// best-effort cleanup
}
}
throw insertErr;
}
databaseLogger.info("Termix ID key generated", {
operation: "termix_id_key_generate",
userId,
algorithm: parsed.algorithm,
savedCredential: credentialId !== null,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "generate_termix_id_key",
resourceType: "termix_id_key",
+8
2026-07-19 12:29:52 -05:00
resourceId: String(inserted.id),
2026-06-29 13:28:26 -05:00
resourceName: identity.handle,
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
ipAddress,
userAgent,
success: true,
});
// The private key is also returned once so the user can download it; when
// saveCredential is true it additionally lives (encrypted) in the vault.
res.status(201).json({
+8
2026-07-19 12:29:52 -05:00
key: inserted,
2026-06-29 13:28:26 -05:00
privateKey: pair.private,
publicKey: parsed.normalized,
credentialId,
});
} catch (err) {
authLogger.error("Failed to generate Termix ID key", err);
res.status(500).json({ error: "Failed to generate key" });
}
},
);
/**
* @openapi
* /termix-id/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
*/
router.patch(
"/keys/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
const updates: { enabled?: boolean; label?: string | null } = {};
if (req.body?.enabled !== undefined) {
updates.enabled = !!req.body.enabled;
}
if (req.body?.label !== undefined) {
updates.label =
typeof req.body.label === "string"
? req.body.label.trim() || null
: null;
}
+8
2026-07-19 12:29:52 -05:00
const updated =
await createCurrentTermixIdentityRepository().updateKeyForUser(
userId,
id,
updates,
);
if (!updated) {
2026-06-29 13:28:26 -05:00
return res.status(404).json({ error: "Key not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "update_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
+8
2026-07-19 12:29:52 -05:00
resourceName: String(updated.label ?? updated.algorithm),
2026-06-29 13:28:26 -05:00
details:
updates.enabled !== undefined
? `enabled: ${updates.enabled}`
: "label updated",
ipAddress,
userAgent,
success: true,
});
+8
2026-07-19 12:29:52 -05:00
res.json(updated);
2026-06-29 13:28:26 -05:00
} catch (err) {
authLogger.error("Failed to update Termix ID key", err);
res.status(500).json({ error: "Failed to update key" });
}
},
);
/**
* @openapi
* /termix-id/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
*/
router.delete(
"/keys/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
+8
2026-07-19 12:29:52 -05:00
const deleted =
await createCurrentTermixIdentityRepository().deleteKeyForUser(
userId,
id,
);
if (!deleted) {
2026-06-29 13:28:26 -05:00
return res.status(404).json({ error: "Key not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: deleted[0].algorithm,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID key", err);
res.status(500).json({ error: "Failed to delete key" });
}
},
);
// Certificate authority — central revocation (rotate) + expiry (validity).
type CaRow = {
id: number;
publicKey: string;
privateKey: string;
validityDays: number;
};
function clampValidityDays(
value: number | string | null | undefined,
fallback: number,
): number {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(Math.floor(n), 3650);
}
async function getCaForUser(
userId: string,
identityId: number,
): Promise<CaRow | undefined> {
+8
2026-07-19 12:29:52 -05:00
return (
(await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId(
userId,
identityId,
)) ?? undefined
2026-06-29 13:28:26 -05:00
);
}
/**
* @openapi
* /termix-id/ca:
* get:
* summary: Get current user's certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: CA info or null
*/
router.get("/ca", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ ca: null });
+8
2026-07-19 12:29:52 -05:00
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
if (!ca) return res.json({ ca: null });
2026-06-29 13:28:26 -05:00
res.json({
ca: {
+8
2026-07-19 12:29:52 -05:00
publicKey: ca.publicKey,
validityDays: ca.validityDays,
2026-06-29 13:28:26 -05:00
resolverPath: `/termix-id/u/${identity.handle}/ca`,
},
});
} catch (err) {
authLogger.error("Failed to fetch Termix ID CA", err);
res.status(500).json({ error: "Failed to fetch CA" });
}
});
/**
* @openapi
* /termix-id/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
*/
router.post(
"/ca",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle first" });
}
+8
2026-07-19 12:29:52 -05:00
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (existing) {
2026-06-29 13:28:26 -05:00
return res.status(409).json({ error: "CA already exists" });
}
const validityDays = clampValidityDays(req.body?.validityDays, 90);
const generated = generateCa();
+8
2026-07-19 12:29:52 -05:00
await caRepository.createEncryptedForUser(userId, {
identityId: identity.id,
2026-06-29 13:28:26 -05:00
userId,
+8
2026-07-19 12:29:52 -05:00
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
});
2026-06-29 13:28:26 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "create_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({
publicKey: generated.publicKeyLine,
validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
});
} catch (err) {
authLogger.error("Failed to create Termix ID CA", err);
res.status(500).json({ error: "Failed to create CA" });
}
},
);
/**
* @openapi
* /termix-id/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
*/
router.post(
"/ca/rotate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
+8
2026-07-19 12:29:52 -05:00
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (!existing) {
2026-06-29 13:28:26 -05:00
return res.status(404).json({ error: "No CA to rotate" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
+8
2026-07-19 12:29:52 -05:00
existing.validityDays,
2026-06-29 13:28:26 -05:00
);
const generated = generateCa();
// Rotating invalidates every previously issued certificate — this IS the
// central revocation mechanism.
+8
2026-07-19 12:29:52 -05:00
await caRepository.updateEncryptedForIdentity(userId, identity.id, {
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
updatedAt: new Date().toISOString(),
});
2026-06-29 13:28:26 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "rotate_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({
publicKey: generated.publicKeyLine,
validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
});
} catch (err) {
authLogger.error("Failed to rotate Termix ID CA", err);
res.status(500).json({ error: "Failed to rotate CA" });
}
},
);
/**
* @openapi
* /termix-id/ca:
* delete:
* summary: Delete the certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
*/
router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.status(404).json({ error: "No Termix ID found" });
+8
2026-07-19 12:29:52 -05:00
const deleted =
await createCurrentTermixIdentityCaRepository().deleteByIdentityId(
identity.id,
);
if (!deleted) {
2026-06-29 13:28:26 -05:00
return res.status(404).json({ error: "No CA to delete" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID CA", err);
res.status(500).json({ error: "Failed to delete CA" });
}
});
/**
* @openapi
* /termix-id/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
*/
router.post(
"/keys/:id/certificate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
+8
2026-07-19 12:29:52 -05:00
const key = await createCurrentTermixIdentityRepository().findKeyForUser(
userId,
id,
);
if (!key) {
2026-06-29 13:28:26 -05:00
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 getIdentityForUser(userId);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const ca = await getCaForUser(userId, identity.id);
if (!ca) {
return res
.status(400)
.json({ error: "Enable a certificate authority first" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
ca.validityDays,
);
const principals = Array.isArray(req.body?.principals)
? req.body.principals
.filter((p: string) => 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: ca.privateKey,
caPublicKeyLine: ca.publicKey,
keyId,
principals,
validAfter: now - 60,
validBefore,
});
if (!certificate) {
return res.status(500).json({ error: "Failed to sign certificate" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "issue_termix_id_certificate",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: identity.handle,
details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`,
ipAddress,
userAgent,
success: true,
});
res.json({ certificate, keyId, validBefore, principals, validityDays });
} catch (err) {
authLogger.error("Failed to issue Termix ID certificate", err);
res.status(500).json({ error: "Failed to issue certificate" });
}
},
);
/**
* @openapi
* /termix-id/linked-credentials:
* get:
* summary: Get credential IDs that have at least one enabled published Termix ID key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of credential IDs
*/
router.get(
"/linked-credentials",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ credentialIds: [] });
+8
2026-07-19 12:29:52 -05:00
const credentialIds =
await createCurrentTermixIdentityRepository().listLinkedCredentialIds(
identity.id,
2026-06-29 13:28:26 -05:00
);
res.json({ credentialIds });
} catch (err) {
authLogger.error("Failed to fetch linked credential IDs", err);
res.status(500).json({ error: "Failed to fetch linked credentials" });
}
},
);
export default router;