"use strict"; const crypto = require("node:crypto"); // authorized_keys handling works on latin1 strings so every byte of unrelated lines, // including options, comments and line endings, is written back unchanged. const KEY_TYPE_PATTERN = /^(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]{1,80}$/; const ADDABLE_TYPES = new Set(["ssh-ed25519", "sk-ssh-ed25519@openssh.com"]); const MAX_KEYS_FILE_BYTES = 256 * 1024; const MAX_COMMENT_LENGTH = 200; class KeyError extends Error { constructor(code, message) { super(message); this.code = code; } } function fingerprint(blob) { return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`; } function readSshString(buffer, offset) { if (offset + 4 > buffer.length) return null; const length = buffer.readUInt32BE(offset); if (length > buffer.length - offset - 4) return null; return { value: buffer.subarray(offset + 4, offset + 4 + length), next: offset + 4 + length }; } function decodeBlob(type, base64) { if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64) || base64.length % 4 !== 0) return null; const blob = Buffer.from(base64, "base64"); const embedded = readSshString(blob, 0); if (!embedded || embedded.value.toString("latin1") !== type) return null; if (type === "ssh-ed25519") { const key = readSshString(blob, embedded.next); if (!key || key.value.length !== 32 || key.next !== blob.length) return null; } return blob; } function skipOptions(text) { let quoted = false; for (let index = 0; index < text.length; index += 1) { const character = text[index]; if (character === "\\" && quoted) { index += 1; continue; } if (character === "\"") quoted = !quoted; else if (!quoted && (character === " " || character === "\t")) return index; } return -1; } function utf8(text) { return Buffer.from(text, "latin1").toString("utf8").replace(/[\x00-\x1f\x7f]/g, " ").trim(); } function parseLine(raw, lineNumber) { const text = raw.replace(/\r$/, "").trim(); if (!text || text.startsWith("#")) return null; let options = ""; let rest = text; const firstToken = text.split(/[ \t]/, 1)[0]; if (!KEY_TYPE_PATTERN.test(firstToken)) { const end = skipOptions(text); if (end < 0) return { line: lineNumber, valid: false, reason: "unparsed" }; options = text.slice(0, end); rest = text.slice(end).trim(); } const [type = "", data = "", ...commentParts] = rest.split(/[ \t]+/); if (!KEY_TYPE_PATTERN.test(type)) return { line: lineNumber, valid: false, reason: "type" }; const blob = decodeBlob(type, data); if (!blob) return { line: lineNumber, valid: false, type, reason: "data" }; return { line: lineNumber, valid: true, type, ed25519: type === "ssh-ed25519" || type === "sk-ssh-ed25519@openssh.com", fingerprint: fingerprint(blob), comment: utf8(commentParts.join(" ")).slice(0, MAX_COMMENT_LENGTH), options: utf8(options).slice(0, 500), blob: blob.toString("base64") }; } function parseAuthorizedKeys(content) { const text = Buffer.isBuffer(content) ? content.toString("latin1") : String(content || ""); return text.split("\n").map((raw, index) => parseLine(raw, index + 1)).filter(Boolean); } // Accepts exactly one OpenSSH public key line without options. Only Ed25519 keys may be added. function normalizePublicKey(input, comment) { const text = String(input || "").replace(/^/, "").trim(); if (!text || text.length > 16 * 1024) throw new KeyError("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key."); const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")); if (lines.length !== 1) throw new KeyError("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key."); if (/PRIVATE KEY/.test(text)) throw new KeyError("USERS_KEY_PRIVATE", "This is a private key. Only public keys are uploaded to the server."); const [type = "", data = "", ...commentParts] = lines[0].split(/\s+/); if (!KEY_TYPE_PATTERN.test(type)) throw new KeyError("USERS_KEY_INVALID", "Key options are not accepted; paste the plain public key."); if (!ADDABLE_TYPES.has(type)) throw new KeyError("USERS_KEY_TYPE_UNSUPPORTED", "Only Ed25519 public keys can be added."); const blob = decodeBlob(type, data); if (!blob) throw new KeyError("USERS_KEY_INVALID", "The public key data is invalid."); const finalComment = String(comment ?? commentParts.join(" ")).replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim(); if (finalComment.length > MAX_COMMENT_LENGTH) throw new KeyError("USERS_KEY_INVALID", "The key comment is too long."); return { type, data, comment: finalComment, fingerprint: fingerprint(blob), blob: blob.toString("base64") }; } function appendKey(content, key) { const text = Buffer.isBuffer(content) ? content.toString("latin1") : String(content || ""); if (parseAuthorizedKeys(text).some((entry) => entry.valid && entry.blob === key.blob)) { throw new KeyError("USERS_KEY_DUPLICATE", "This public key is already authorized for the account."); } const comment = key.comment ? ` ${Buffer.from(key.comment, "utf8").toString("latin1")}` : ""; const separator = text && !text.endsWith("\n") ? "\n" : ""; const result = Buffer.from(`${text}${separator}${key.type} ${key.data}${comment}\n`, "latin1"); if (result.length > MAX_KEYS_FILE_BYTES) throw new KeyError("USERS_KEYS_FILE_TOO_LARGE", "The authorized_keys file would exceed 256 KiB."); return result; } // Removes exactly one line, identified by line number and fingerprint, leaving all other bytes untouched. function removeKey(content, lineNumber, expectedFingerprint) { const text = Buffer.isBuffer(content) ? content.toString("latin1") : String(content || ""); const lines = text.split("\n"); const index = Number(lineNumber) - 1; const entry = Number.isInteger(index) && index >= 0 && index < lines.length ? parseLine(lines[index], index + 1) : null; if (!entry?.valid || entry.fingerprint !== expectedFingerprint) { throw new KeyError("USERS_KEY_NOT_FOUND", "The selected key changed or no longer exists. Reload the key list."); } lines.splice(index, 1); return Buffer.from(lines.join("\n"), "latin1"); } function sha256Hex(content) { return crypto.createHash("sha256").update(content).digest("hex"); } module.exports = { ADDABLE_TYPES, KeyError, MAX_KEYS_FILE_BYTES, appendKey, fingerprint, normalizePublicKey, parseAuthorizedKeys, removeKey, sha256Hex };