From 57938e761eebaf21a5b136760c4e280acf10261c Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 20 Feb 2019 23:55:09 -0700 Subject: [PATCH] + Implement SSH PubKey authentication * Security related items to config/security dir --- UPGRADE.md | 2 +- core/config.js | 11 ++--- core/servers/login/ssh.js | 82 +++++++++++++++++++++++------------ core/user.js | 71 +++++++++++++++++++++--------- core/user_login.js | 9 +++- core/user_property.js | 81 +++++++++++++++++----------------- misc/config_template.in.hjson | 4 +- 7 files changed, 164 insertions(+), 96 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 215089ad..ed5e687d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -41,7 +41,7 @@ Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or [file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues). # 0.0.9-alpha to 0.0.10-alpha - +* Security related files such as private keys and certs are now looked for in `config/security` by default. # 0.0.8-alpha to 0.0.9-alpha * Development is now against Node.js 10.x LTS. Follow your standard upgrade path to update to Node 10.x before using 0.0.9-alpha! diff --git a/core/config.js b/core/config.js index 5c1f9c42..d6cdadba 100644 --- a/core/config.js +++ b/core/config.js @@ -250,6 +250,7 @@ function getDefaultConfig() { paths : { config : paths.join(__dirname, './../config/'), + security : paths.join(__dirname, './../config/security'), // certs, keys, etc. mods : paths.join(__dirname, './../mods/'), loginServers : paths.join(__dirname, './servers/login/'), contentServers : paths.join(__dirname, './servers/content/'), @@ -259,7 +260,7 @@ function getDefaultConfig() { art : paths.join(__dirname, './../art/general/'), themes : paths.join(__dirname, './../art/themes/'), - logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such + logs : paths.join(__dirname, './../logs/'), db : paths.join(__dirname, './../db/'), modsDb : paths.join(__dirname, './../db/mods/'), dropFiles : paths.join(__dirname, './../drop/'), // + "/node/ @@ -284,10 +285,10 @@ function getDefaultConfig() { // // > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ // -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \ - // -out ./config/ssh_private_key.pem -aes128 + // -out ./config/security/ssh_private_key.pem -aes128 // - // (The above is a more modern equivelant of the following): - // > openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048 + // (The above is a more modern equivalent of the following): + // > openssl genrsa -aes128 -out ./config/security/ssh_private_key.pem 2048 // // 2 - Set 'privateKeyPass' to the password you used in step #1 // @@ -297,7 +298,7 @@ function getDefaultConfig() { // - https://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/ // - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b // - privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'), + privateKeyPem : paths.join(__dirname, './../config/security/ssh_private_key.pem'), firstMenu : 'sshConnected', firstMenuNewUser : 'sshConnectedNewUser', diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index ee63ac78..3b0e852f 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -14,6 +14,8 @@ const { Errors, ErrorReasons } = require('../../enig_error.js'); +const User = require('../../user.js'); +const UserProps = require('../../user_property.js'); // deps const ssh2 = require('ssh2'); @@ -21,6 +23,7 @@ const fs = require('graceful-fs'); const util = require('util'); const _ = require('lodash'); const assert = require('assert'); +const crypto = require('crypto'); const ModuleInfo = exports.moduleInfo = { name : 'SSH', @@ -42,8 +45,6 @@ function SSHClient(clientConn) { clientConn.on('authentication', function authAttempt(ctx) { const username = ctx.username || ''; - const password = ctx.password || ''; - const config = Config(); self.isNewUser = (config.users.newUserNames || []).indexOf(username) > -1; @@ -106,37 +107,36 @@ function SSHClient(clientConn) { } }; - // - // If the system is open and |isNewUser| is true, the login - // sequence is hijacked in order to start the application process. - // - if(false === config.general.closedSystem && self.isNewUser) { - return ctx.accept(); - } + const authWithPasswordOrPubKey = (authType) => { + if('pubKey' !== authType || !self.user.isAuthenticated() || !ctx.signature) { + // step 1: login/auth using PubKey + userLogin(self, ctx.username, ctx.password, { authType, ctx }, (err) => { + if(err) { + if(isSpecialHandleError(err)) { + return handleSpecialError(err, username); + } - if(username.length > 0 && password.length > 0) { - userLogin(self, ctx.username, ctx.password, function authResult(err) { - if(err) { - if(isSpecialHandleError(err)) { - return handleSpecialError(err, username); + if(Errors.BadLogin().code === err.code) { + return slowTerminateConnection(); + } + + return safeContextReject(SSHClient.ValidAuthMethods); } - if(Errors.BadLogin().code === err.code) { - return slowTerminateConnection(); - } - - return safeContextReject(SSHClient.ValidAuthMethods); + ctx.accept(); + }); + } else { + // step 2: verify signature + const pubKeyActual = ssh2.utils.parseKey(self.user.getProperty(UserProps.LoginPubKey)); + if(!pubKeyActual || !pubKeyActual.verify(ctx.blob, ctx.signature)) { + return slowTerminateConnection(); } - - ctx.accept(); - }); - } else { - if(-1 === SSHClient.ValidAuthMethods.indexOf(ctx.method)) { - return safeContextReject(SSHClient.ValidAuthMethods); + return ctx.accept(); } + }; + const authKeyboardInteractive = () => { if(0 === username.length) { - // :TODO: can we display something here? return safeContextReject(); } @@ -176,6 +176,30 @@ function SSHClient(clientConn) { } }); }); + }; + + // + // If the system is open and |isNewUser| is true, the login + // sequence is hijacked in order to start the application process. + // + if(false === config.general.closedSystem && self.isNewUser) { + return ctx.accept(); + } + + switch(ctx.method) { + case 'password' : + return authWithPasswordOrPubKey('password'); + //return authWithPassword(); + + case 'publickey' : + return authWithPasswordOrPubKey('pubKey'); + //return authWithPubKey(); + + case 'keyboard-interactive' : + return authKeyboardInteractive(); + + default : + return safeContextReject(SSHClient.ValidAuthMethods); } }); @@ -293,7 +317,11 @@ function SSHClient(clientConn) { util.inherits(SSHClient, baseClient.Client); -SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ]; +SSHClient.ValidAuthMethods = [ + 'password', + 'keyboard-interactive', + 'publickey', +]; exports.getModule = class SSHServerModule extends LoginServerModule { constructor() { diff --git a/core/user.js b/core/user.js index 43779bca..36a2f72f 100644 --- a/core/user.js +++ b/core/user.js @@ -21,6 +21,7 @@ const async = require('async'); const _ = require('lodash'); const moment = require('moment'); const sanatizeFilename = require('sanitize-filename'); +const ssh2 = require('ssh2'); exports.isRootUserId = function(id) { return 1 === id; }; @@ -47,7 +48,10 @@ module.exports = class User { static get StandardPropertyGroups() { return { - password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ], + auth : [ + UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk, + UserProps.LoginPubKey, UserProps.LoginPubKeyFingerprintSHA256, + ], }; } @@ -174,10 +178,49 @@ module.exports = class User { }); } - authenticate(username, password, cb) { + authenticate(username, password, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + const self = this; const tempAuthInfo = {}; + const validatePassword = (props, callback) => { + User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => { + if(err) { + return callback(err); + } + + // + // Use constant time comparison here for security feel-goods + // + const passDkBuf = Buffer.from(dk, 'hex'); + const propsDkBuf = Buffer.from(props[UserProps.PassPbkdf2Dk], 'hex'); + + return callback(crypto.timingSafeEqual(passDkBuf, propsDkBuf) ? + null : + Errors.AccessDenied('Invalid password') + ); + }); + }; + + const validatePubKey = (props, callback) => { + const pubKeyActual = ssh2.utils.parseKey(props[UserProps.LoginPubKey]); + if(!pubKeyActual) { + return callback(Errors.AccessDenied('Invalid public key')); + } + + if(options.ctx.key.algo != pubKeyActual.type || + !crypto.timingSafeEqual(options.ctx.key.data, pubKeyActual.getPublicSSH())) + { + return callback(Errors.AccessDenied('Invalid public key')); + } + + return callback(null); + }; + async.waterfall( [ function fetchUserId(callback) { @@ -191,27 +234,15 @@ module.exports = class User { }, function getRequiredAuthProperties(callback) { // fetch properties required for authentication - User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => { + User.loadProperties( tempAuthInfo.userId, { names : User.StandardPropertyGroups.auth }, (err, props) => { return callback(err, props); }); }, - function getDkWithSalt(props, callback) { - // get DK from stored salt and password provided - User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => { - return callback(err, dk, props[UserProps.PassPbkdf2Dk]); - }); - }, - function validateAuth(passDk, propsDk, callback) { - // - // Use constant time comparison here for security feel-goods - // - const passDkBuf = Buffer.from(passDk, 'hex'); - const propsDkBuf = Buffer.from(propsDk, 'hex'); - - return callback(crypto.timingSafeEqual(passDkBuf, propsDkBuf) ? - null : - Errors.AccessDenied('Invalid password') - ); + function validatePassOrPubKey(props, callback) { + if('pubKey' === options.authType) { + return validatePubKey(props, callback); + } + return validatePassword(props, callback); }, function initProps(callback) { User.loadProperties(tempAuthInfo.userId, (err, allProps) => { diff --git a/core/user_login.js b/core/user_login.js index 3e3f5f04..1f1180da 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -22,7 +22,12 @@ const _ = require('lodash'); exports.userLogin = userLogin; -function userLogin(client, username, password, cb) { +function userLogin(client, username, password, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + const config = Config(); if(config.users.badUserNames.includes(username.toLowerCase())) { @@ -34,7 +39,7 @@ function userLogin(client, username, password, cb) { }, 2000); } - client.user.authenticate(username, password, err => { + client.user.authenticate(username, password, options, err => { if(err) { client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1; const disconnect = config.users.failedLogin.disconnect; diff --git a/core/user_property.js b/core/user_property.js index 56e47e66..c3aabcd0 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -8,54 +8,57 @@ // can utilize their own properties as well! // module.exports = { - PassPbkdf2Salt : 'pw_pbkdf2_salt', - PassPbkdf2Dk : 'pw_pbkdf2_dk', + PassPbkdf2Salt : 'pw_pbkdf2_salt', + PassPbkdf2Dk : 'pw_pbkdf2_dk', - AccountStatus : 'account_status', // See User.AccountStatus enum + AccountStatus : 'account_status', // See User.AccountStatus enum - RealName : 'real_name', - Sex : 'sex', - Birthdate : 'birthdate', - Location : 'location', - Affiliations : 'affiliation', - EmailAddress : 'email_address', - WebAddress : 'web_address', - TermHeight : 'term_height', - TermWidth : 'term_width', - ThemeId : 'theme_id', - AccountCreated : 'account_created', - LastLoginTs : 'last_login_timestamp', - LoginCount : 'login_count', - UserComment : 'user_comment', // NYI + RealName : 'real_name', + Sex : 'sex', + Birthdate : 'birthdate', + Location : 'location', + Affiliations : 'affiliation', + EmailAddress : 'email_address', + WebAddress : 'web_address', + TermHeight : 'term_height', + TermWidth : 'term_width', + ThemeId : 'theme_id', + AccountCreated : 'account_created', + LastLoginTs : 'last_login_timestamp', + LoginCount : 'login_count', + UserComment : 'user_comment', // NYI - DownloadQueue : 'dl_queue', // download_queue.js + DownloadQueue : 'dl_queue', // download_queue.js - FailedLoginAttempts : 'failed_login_attempts', - AccountLockedTs : 'account_locked_timestamp', - AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out + FailedLoginAttempts : 'failed_login_attempts', + AccountLockedTs : 'account_locked_timestamp', + AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out - EmailPwResetToken : 'email_password_reset_token', - EmailPwResetTokenTs : 'email_password_reset_token_ts', + EmailPwResetToken : 'email_password_reset_token', + EmailPwResetTokenTs : 'email_password_reset_token_ts', - FileAreaTag : 'file_area_tag', - FileBaseFilters : 'file_base_filters', - FileBaseFilterActiveUuid : 'file_base_filter_active_uuid', - FileBaseLastViewedId : 'user_file_base_last_viewed', - FileDlTotalCount : 'dl_total_count', - FileUlTotalCount : 'ul_total_count', - FileDlTotalBytes : 'dl_total_bytes', - FileUlTotalBytes : 'ul_total_bytes', + FileAreaTag : 'file_area_tag', + FileBaseFilters : 'file_base_filters', + FileBaseFilterActiveUuid : 'file_base_filter_active_uuid', + FileBaseLastViewedId : 'user_file_base_last_viewed', + FileDlTotalCount : 'dl_total_count', + FileUlTotalCount : 'ul_total_count', + FileDlTotalBytes : 'dl_total_bytes', + FileUlTotalBytes : 'ul_total_bytes', - MessageConfTag : 'message_conf_tag', - MessageAreaTag : 'message_area_tag', - MessagePostCount : 'post_count', + MessageConfTag : 'message_conf_tag', + MessageAreaTag : 'message_area_tag', + MessagePostCount : 'post_count', - DoorRunTotalCount : 'door_run_total_count', - DoorRunTotalMinutes : 'door_run_total_minutes', + DoorRunTotalCount : 'door_run_total_count', + DoorRunTotalMinutes : 'door_run_total_minutes', - AchievementTotalCount : 'achievement_total_count', - AchievementTotalPoints : 'achievement_total_points', + AchievementTotalCount : 'achievement_total_count', + AchievementTotalPoints : 'achievement_total_points', - MinutesOnlineTotalCount : 'minutes_online_total_count', + MinutesOnlineTotalCount : 'minutes_online_total_count', + + LoginPubKey : 'login_public_key', // OpenSSL format + //LoginPubKeyFingerprintSHA256 : 'login_public_key_fp_sha256', // hint: ssh-kegen -lf id_rsa.pub }; diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson index 5e523e72..38482a6a 100644 --- a/misc/config_template.in.hjson +++ b/misc/config_template.in.hjson @@ -118,10 +118,10 @@ // // > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ // -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \ - // -out ./config/ssh_private_key.pem -aes128 + // -out ./config/security/ssh_private_key.pem -aes128 // // (The above is a more modern equivelant of the following): - // > openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048 + // > openssl genrsa -aes128 -out ./config/security/ssh_private_key.pem 2048 // // 2 - Set 'privateKeyPass' to the password you used in step #1 //