Vectrum
Sign In
95 lines · 3.3 KB · TypeScript
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:<unpadded base64 of the key blob's digest>. */
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() });
}