import { createHash } from "node:crypto"; import { db, filter, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db"; export type SshKey = { id: string; userId: string; title: string; keyType: string; publicKey: string; fingerprint: string; createdAt: number; lastUsedAt: number | null; }; const toKey = (r: RecordModel): SshKey => ({ id: r.id, userId: r.user, title: r.title, keyType: r.key_type, publicKey: r.public_key, fingerprint: r.fingerprint, createdAt: r.created_at, lastUsedAt: orNull(r.last_used_at), }); const ALLOWED_TYPES = new Set([ "ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com", ]); /** Same format OpenSSH prints: SHA256:. */ export function fingerprintOf(blob: Buffer) { return `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`; } export class KeyError extends Error {} /** Parses an OpenSSH public key line ("type base64 [comment]"). */ export function parsePublicKey(line: string) { const [type, data, ...comment] = line.trim().split(/\s+/); if (!type || !data || !ALLOWED_TYPES.has(type)) { throw new KeyError("Key is invalid. You must supply a key in OpenSSH public key format (ssh-ed25519, ssh-rsa or ecdsa)."); } const blob = Buffer.from(data, "base64"); // The blob starts with a length-prefixed copy of the key type; it must match. const len = blob.length >= 4 ? blob.readUInt32BE(0) : 0; if (blob.length < 4 + len || blob.subarray(4, 4 + len).toString() !== type) { throw new KeyError("Key is invalid. The key data does not match its type."); } if (type === "ssh-rsa" && blob.length < 270) throw new KeyError("RSA keys must be at least 2048 bits."); return { type, blob, publicKey: `${type} ${data}`, comment: comment.join(" ") }; } export async function addSshKey(userId: string, title: string, keyText: string) { const parsed = parsePublicKey(keyText); const fingerprint = fingerprintOf(parsed.blob); if (await findKeyByBlob(parsed.blob)) throw new KeyError("Key is already in use."); try { await (await db()).collection("ssh_keys").create({ user: userId, title: (title.trim() || parsed.comment || parsed.type).slice(0, 100), key_type: parsed.type, public_key: parsed.publicKey, fingerprint, created_at: Date.now(), }); } catch (e) { if (isUniqueViolation(e)) throw new KeyError("Key is already in use."); throw e; } } export async function listSshKeys(userId: string) { const rows = await (await db()).collection("ssh_keys").getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at" }); return rows.map(toKey); } export async function deleteSshKey(userId: string, keyId: string) { const key = await findFirst("ssh_keys", filter("id = {:keyId} && user = {:userId}", { keyId, userId }), { fields: "id" }); if (key) await (await db()).collection("ssh_keys").delete(key.id); } export async function findKeyByBlob(blob: Buffer) { const row = await findFirst("ssh_keys", filter("fingerprint = {:fp}", { fp: fingerprintOf(blob) })); return row ? toKey(row) : null; } export async function touchSshKey(keyId: string) { await (await db()).collection("ssh_keys").update(keyId, { last_used_at: Date.now() }); }