261 lines
9.2 KiB
JavaScript
261 lines
9.2 KiB
JavaScript
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:<base64-without-padding>
|
|
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 };
|
|
}
|