import crypto from 'node:crypto'; import db from './sql.mjs'; import lib from './lib.mjs'; import cfg from './config.mjs'; const SPKI_ED25519_HEADER = Buffer.from('302a300506032b6570032100', 'hex'); /** * Parse an OpenSSH formatted Ed25519 public key. * Format: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... [comment]" * @param {string} sshKey * @returns {{ keyObject: crypto.KeyObject, rawPub: Buffer, wirePub: Buffer, fingerprint: string, shortFingerprint: string }} */ export function parseOpenSshPubkey(sshKey) { if (!sshKey || typeof sshKey !== 'string') { throw new Error('Missing or invalid SSH public key'); } const parts = sshKey.trim().split(/\s+/); if (parts.length < 2 || parts[0] !== 'ssh-ed25519') { throw new Error('Only ssh-ed25519 keys are supported'); } const wirePub = Buffer.from(parts[1], 'base64'); if (wirePub.length < 19) { throw new Error('Invalid OpenSSH public key wire payload'); } const typeLen = wirePub.readUInt32BE(0); if (typeLen !== 11) { throw new Error('Invalid key type length in OpenSSH wire format'); } const type = wirePub.subarray(4, 4 + typeLen).toString('utf8'); if (type !== 'ssh-ed25519') { throw new Error(`Expected ssh-ed25519, got ${type}`); } const keyLenOffset = 4 + typeLen; const keyLen = wirePub.readUInt32BE(keyLenOffset); if (keyLen !== 32) { throw new Error(`Invalid Ed25519 key length: expected 32, got ${keyLen}`); } const rawPub = wirePub.subarray(keyLenOffset + 4, keyLenOffset + 4 + keyLen); if (rawPub.length !== 32) { throw new Error('Malformed Ed25519 raw public key'); } // Construct standard SPKI DER for crypto.createPublicKey const der = Buffer.concat([SPKI_ED25519_HEADER, rawPub]); const keyObject = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' }); // Standard OpenSSH SHA256 fingerprint: SHA256: const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(wirePub).digest('base64').replace(/=+$/, ''); const shortFingerprint = fingerprint.slice(7, 15); return { keyObject, rawPub, wirePub, fingerprint, shortFingerprint }; } /** * Verify an Ed25519 signature against an OpenSSH public key. * @param {string} sshPubkey * @param {string|Buffer} message * @param {string} signature (hex or base64) * @returns {boolean} */ export function verifySignature(sshPubkey, message, signature) { try { const { keyObject } = parseOpenSshPubkey(sshPubkey); const msgBuf = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8'); let sigBuf; if (typeof signature === 'string') { const isHex = /^[0-9a-fA-F]{128}$/.test(signature); sigBuf = isHex ? Buffer.from(signature, 'hex') : Buffer.from(signature, 'base64'); } else if (Buffer.isBuffer(signature)) { sigBuf = signature; } else { return false; } return crypto.verify(null, msgBuf, keyObject, sigBuf); } catch (err) { return false; } } import security from './security.mjs'; import { getHashUserIps } from './settings.mjs'; /** * Get IP for audit/logging, hashed if hash_user_ips is enabled in config. * @param {object} req * @returns {string} */ export function resolveAuditIP(req) { if (!req) return 'unknown'; const rawIp = security.getRealIP(req); return getHashUserIps() ? security.hashIP(rawIp) : rawIp; } /** * Log activity for an anonymous user (or session). * @param {object} req * @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params */ export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) { try { const rawIp = security.getRealIP(req); const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp; const userId = req?.session?.id || null; if (!userId) return; const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null; const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null; const numTargetId = targetId ? parseInt(targetId, 10) : null; await db` INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details) VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null}) `; await security.logUserIP(userId, rawIp); } catch (err) { console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err); } } /** * Find or create a shadow user in the database for an anonymous SSH identity. * @param {string} pubkey * @param {string} fingerprint * @param {object} [req] * @param {string} [hwFingerprint] * @returns {Promise<{ userId: number, isNew: boolean }>} */ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) { const normPubkey = pubkey.trim(); const auditIp = req ? resolveAuditIP(req) : null; const existing = await db` SELECT user_id FROM anon_identities WHERE pubkey = ${normPubkey} LIMIT 1 `; if (existing.length > 0) { await db` UPDATE anon_identities SET last_seen = NOW() ${auditIp ? db`, last_ip = ${auditIp}` : db``} ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} WHERE pubkey = ${normPubkey} `; return { userId: existing[0].user_id, isNew: false }; } // Generate unique shadow username const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8); let baseLogin = `anon_${shortHash}`; let finalLogin = baseLogin; let counter = 1; while (true) { const check = await db`SELECT id FROM "user" WHERE login = ${finalLogin} LIMIT 1`; if (check.length === 0) break; finalLogin = `${baseLogin}_${counter++}`; } const userRows = await db` INSERT INTO "user" (login, "user", password, admin, is_moderator, activated) VALUES (${finalLogin}, ${finalLogin}, '!', false, false, true) RETURNING id `; const userId = userRows[0].id; await db` INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name, use_alternative_infobox) VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous', ${cfg.websrv.user_alternative_infobox !== false}) ON CONFLICT (user_id) DO NOTHING `; await db` INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint) VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint}) ON CONFLICT (pubkey) DO UPDATE SET last_seen = NOW() ${auditIp ? db`, last_ip = ${auditIp}` : db``} ${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``} `; return { userId, isNew: true }; } /** * Create a valid session in user_sessions for this anonymous user. * @param {number} userId * @param {object} req * @param {string} [hwFingerprint] * @returns {Promise<{ session: string, csrf_token: string }>} */ export async function createAnonSession(userId, req, hwFingerprint = null) { const auditIp = resolveAuditIP(req); // Update anon_identities last_ip, created_ip, and hw_fingerprint await db` UPDATE anon_identities SET last_ip = ${auditIp}, created_ip = COALESCE(created_ip, ${auditIp}) ${hwFingerprint ? db`, hw_fingerprint = COALESCE(${hwFingerprint}, hw_fingerprint)` : db``} WHERE user_id = ${userId} `.catch(() => {}); // 1. If req.session is already active for this exact userId, reuse it! if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) { await logAnonActivity(req, { action: 'handshake', hwFingerprint }); return { session: req.cookies.session, csrf_token: req.session.csrf_token }; } // 2. If client has a session cookie that maps to this userId in DB, reuse it! if (req?.cookies?.session) { const existingHash = lib.sha256(req.cookies.session); const existing = await db` SELECT session, csrf_token FROM user_sessions WHERE user_id = ${userId} AND session = ${existingHash} LIMIT 1 `; if (existing.length > 0) { await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`; await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); return { session: req.cookies.session, csrf_token: existing[0].csrf_token }; } } const session = crypto.randomBytes(32).toString('hex'); const sessionHash = lib.sha256(session); const csrfToken = crypto.randomBytes(24).toString('hex'); const stamp = ~~(Date.now() / 1e3); const ip = req?.ip || req?.socket?.remoteAddress || '127.0.0.1'; const ua = req?.headers ? (req.headers['user-agent'] || '') : ''; const sessRecord = { user_id: userId, session: sessionHash, csrf_token: csrfToken, browser: ua, created_at: stamp, last_used: stamp, last_action: '/anon/session', kmsi: 1, ip: ip }; await db` INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')} `; await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); return { session, csrf_token: csrfToken }; }