+ Implement SSH PubKey authentication
* Security related items to config/security dir
This commit is contained in:
parent
65ef1feb6c
commit
57938e761e
|
@ -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!
|
||||
|
|
|
@ -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',
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
71
core/user.js
71
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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
//
|
||||
|
|
Loading…
Reference in New Issue