feat: move termix identity into a plugin (C6)

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

No files matched your search

@@ -18,8 +18,6 @@ import {
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
@@ -95,10 +93,8 @@ export async function deleteUserAndRelatedData(
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way.
// plugins' tables cascade the same way, and so do termix-identity's.
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -69,3 +69,33 @@ export function registerVaultCompatRoutes(router: Router): void {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
/**
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
* scripts. Permanently redirected to the termix-identity plugin's public
* resolver; curl -L follows them.
*/
export function registerTermixIdCompatRoutes(router: Router): void {
/**
* @openapi
* /termix-id/u/{handle}:
* get:
* summary: Termix ID resolver (2.8 URL)
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
* tags:
* - Termix ID
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 308:
* description: Redirect to the termix-identity plugin's resolver.
*/
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
const rest = req.path.slice("/u".length);
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
});
}
@@ -1,145 +0,0 @@
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}`;
}
@@ -1,89 +0,0 @@
// Pure, dependency-free helpers for SSH public-key parsing/classification used
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
// 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();
}
File diff suppressed because it is too large. Load diff