'use strict'; const { createHmac, createHash, timingSafeEqual } = require('node:crypto'); const config = require('../config'); /** * Compare two secrets in constant time (hashing first hides the length). */ function safeEqual(a, b) { const ha = createHash('sha256').update(String(a ?? '')).digest(); const hb = createHash('sha256').update(String(b ?? '')).digest(); return timingSafeEqual(ha, hb); } /** * Signing key. Stateless on purpose: it only depends on the configuration, so * tokens survive container restarts and are invalidated when a password changes. */ function secret() { if (config.tokenSecret) return String(config.tokenSecret); return createHash('sha256') .update(`psitransfer-token-v1\0${ config.uploadPass || '' }\0${ config.adminPass || '' }`) .digest('hex'); } function sign(payload, purpose) { return createHmac('sha256', `${ secret() }:${ purpose }`).update(payload).digest('base64url'); } /** * @param {object} data extra payload * @param {number} ttlSec lifetime in seconds * @param {string} purpose tokens of one purpose are not valid for another */ function issue(data, ttlSec, purpose = 'session', now = Date.now()) { const exp = now + ttlSec * 1000; const payload = Buffer.from(JSON.stringify({ ...data, exp })).toString('base64url'); return { token: `${ payload }.${ sign(payload, purpose) }`, exp }; } function verify(token, purpose = 'session', now = Date.now()) { if (typeof token !== 'string') return null; const dot = token.indexOf('.'); if (dot < 1) return null; const payload = token.slice(0, dot); const given = Buffer.from(token.slice(dot + 1)); const expected = Buffer.from(sign(payload, purpose)); if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null; let data; try { data = JSON.parse(Buffer.from(payload, 'base64url').toString()); } catch (e) { return null; } if (!data || typeof data.exp !== 'number' || data.exp <= now) return null; return data; } function bearer(req) { const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || ''); return m ? m[1] : null; } /** Does the request carry a valid (not expired) login token? */ function hasSession(req) { const token = bearer(req); return !!token && !!verify(token); } /** * Upload authentication. * @returns {'open'|'token'|'password'|null} null when the request is not allowed */ function uploadAuth(req) { if (!config.uploadPass) return 'open'; if (hasSession(req)) return 'token'; // legacy: raw password header, keeps scripts (curl) working const pass = req.get('x-passwd'); if (pass && safeEqual(pass, config.uploadPass)) return 'password'; return null; } function isAdmin(req) { return !!config.adminPass && safeEqual(req.get('x-passwd'), config.adminPass); } /** Answer a rejected request slowly to make brute force attacks harder. */ function deny(req, res, delay = 500) { const given = req.get('authorization') || req.get('x-passwd'); const status = given ? 403 : 401; setTimeout(() => res.status(status).send(status === 401 ? 'Unauthorized' : 'Forbidden'), delay); } module.exports = { safeEqual, issue, verify, hasSession, uploadAuth, isAdmin, deny };