import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import type { Request, Response } from "express"; import { createCurrentCredentialRepository, createCurrentTermixIdentityRepository, createCurrentTermixIdentityCaRepository, createCurrentUserRepository, } from "../repositories/factory.js"; // 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 { 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) { return createCurrentTermixIdentityRepository().findIdentityForUser(userId); } // 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 { const user = await createCurrentUserRepository().findById(userId); return user?.username ?? userId; } // Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a // small HTML viewer for browsers, keyed by handle, with optional / 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 { const identity = await createCurrentTermixIdentityRepository().findIdentityByHandle( handle, ); if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } let keys = await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId( identity.id, ); 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderHtml( handle: string, keys: Array<{ algorithm: string; comment: string | null; publicKey: string }>, ): string { const keyRows = keys.length ? keys .map( (k) => `
${escapeHtml( k.algorithm, )}${escapeHtml(k.publicKey)}
`, ) .join("") : `

No public keys published yet.

`; // Standalone page (not the React app), so the Termix dark theme is inlined. return ` Termix ID — @${escapeHtml(handle)}

Termix ID @${escapeHtml(handle)}

Provision a server
curl -fsSL https://your-termix-host/termix-id/u/${escapeHtml(
      handle,
    )} >> ~/.ssh/authorized_keys
Published keys
${keyRows}
Served by Termix
`; } // 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 { const identity = await createCurrentTermixIdentityRepository().findIdentityByHandle( handle, ); if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } const ca = await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( identity.id, ); res.setHeader("Content-Type", "text/plain; charset=utf-8"); if (!ca) { return res.status(404).send("No CA configured\n"); } return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`); } 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: [] }); } const keys = await createCurrentTermixIdentityRepository().listKeysByIdentityId( identity.id, ); 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 { const taken = await createCurrentTermixIdentityRepository().isHandleTaken(handle); res.json({ available: !taken, valid: true }); } 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.", }); } const termixIdentityRepository = createCurrentTermixIdentityRepository(); const taken = await termixIdentityRepository.isHandleTaken(handle); if (taken) { return res.status(409).json({ error: "Handle already taken" }); } const inserted = await termixIdentityRepository.createIdentity({ userId, handle, description, }); 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", resourceId: String(inserted.id), resourceName: handle, ipAddress, userAgent, success: true, }); res.status(201).json(inserted); } 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) { const taken = await createCurrentTermixIdentityRepository().isHandleTaken(handle); if (taken) { return res.status(409).json({ error: "Handle already taken" }); } } updates.handle = handle; } if (req.body?.description !== undefined) { updates.description = cleanDescription(req.body.description); } const updated = await createCurrentTermixIdentityRepository().updateIdentityForUser( userId, { ...updates, updatedAt: new Date().toISOString() }, ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: await getActorUsername(userId), action: "update_termix_id", resourceType: "termix_id", resourceId: String(identity.id), resourceName: updated?.handle ?? identity.handle, details: updates.handle && updates.handle !== identity.handle ? `renamed ${identity.handle} -> ${updates.handle}` : undefined, ipAddress, userAgent, success: true, }); res.json(updated); } 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" }); } await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId); 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) { const cred = await createCurrentCredentialRepository().findDecryptedByIdForUser( userId, credentialId, ); if (!cred) { 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 " ". const termixIdentityRepository = createCurrentTermixIdentityRepository(); const existing = await termixIdentityRepository.listKeysByIdentityId( identity.id, ); if (existing.some((k) => k.publicKey === parsed.normalized)) { return res.status(409).json({ error: "This key is already published" }); } 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, }); 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", resourceId: String(inserted.id), resourceName: identity.handle, details: `${parsed.algorithm} (${source})`, ipAddress, userAgent, success: true, }); res.status(201).json(inserted); } 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, }; const created = await createCurrentCredentialRepository().createEncryptedForUser( userId, credData, ); credentialId = created.id; } // There is no single transaction here because credential encryption is async, // so if publishing the key fails // after the vault credential was created, compensate by deleting it to // avoid leaving an orphaned credential behind. const termixIdentityRepository = createCurrentTermixIdentityRepository(); const runInsert = () => 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, }); let inserted: Awaited>; try { inserted = await runInsert(); } catch (insertErr) { if (credentialId !== null) { try { await createCurrentCredentialRepository().deleteForUser( userId, credentialId, ); } 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", resourceId: String(inserted.id), 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({ key: inserted, 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; } const updated = await createCurrentTermixIdentityRepository().updateKeyForUser( userId, id, updates, ); if (!updated) { 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), resourceName: String(updated.label ?? updated.algorithm), details: updates.enabled !== undefined ? `enabled: ${updates.enabled}` : "label updated", ipAddress, userAgent, success: true, }); res.json(updated); } 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 { const deleted = await createCurrentTermixIdentityRepository().deleteKeyForUser( userId, id, ); if (!deleted) { 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 { return ( (await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId( userId, identityId, )) ?? undefined ); } /** * @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 }); const ca = await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( identity.id, ); if (!ca) return res.json({ ca: null }); res.json({ ca: { publicKey: ca.publicKey, validityDays: ca.validityDays, 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" }); } const caRepository = createCurrentTermixIdentityCaRepository(); const existing = await caRepository.findPublicByIdentityId(identity.id); if (existing) { return res.status(409).json({ error: "CA already exists" }); } const validityDays = clampValidityDays(req.body?.validityDays, 90); const generated = generateCa(); await caRepository.createEncryptedForUser(userId, { identityId: identity.id, userId, publicKey: generated.publicKeyLine, privateKey: generated.privateKeyPem, validityDays, }); 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" }); const caRepository = createCurrentTermixIdentityCaRepository(); const existing = await caRepository.findPublicByIdentityId(identity.id); if (!existing) { return res.status(404).json({ error: "No CA to rotate" }); } const validityDays = clampValidityDays( req.body?.validityDays, existing.validityDays, ); const generated = generateCa(); // Rotating invalidates every previously issued certificate — this IS the // central revocation mechanism. await caRepository.updateEncryptedForIdentity(userId, identity.id, { publicKey: generated.publicKeyLine, privateKey: generated.privateKeyPem, validityDays, updatedAt: new Date().toISOString(), }); 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" }); const deleted = await createCurrentTermixIdentityCaRepository().deleteByIdentityId( identity.id, ); if (!deleted) { 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 { const key = await createCurrentTermixIdentityRepository().findKeyForUser( userId, 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 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: [] }); const credentialIds = await createCurrentTermixIdentityRepository().listLinkedCredentialIds( identity.id, ); 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;