+ Implement SSH PubKey authentication

* Security related items to config/security dir
This commit is contained in:
Bryan Ashby 2019-02-20 23:55:09 -07:00
parent 65ef1feb6c
commit 57938e761e
No known key found for this signature in database
GPG Key ID: B49EB437951D2542
7 changed files with 164 additions and 96 deletions

View File

@ -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!

View File

@ -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<x>/
@ -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',

View File

@ -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() {

View File

@ -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) => {

View File

@ -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;

View File

@ -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
};

View File

@ -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
//