+ 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).
|
[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues).
|
||||||
|
|
||||||
# 0.0.9-alpha to 0.0.10-alpha
|
# 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
|
# 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!
|
* 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 : {
|
paths : {
|
||||||
config : paths.join(__dirname, './../config/'),
|
config : paths.join(__dirname, './../config/'),
|
||||||
|
security : paths.join(__dirname, './../config/security'), // certs, keys, etc.
|
||||||
mods : paths.join(__dirname, './../mods/'),
|
mods : paths.join(__dirname, './../mods/'),
|
||||||
loginServers : paths.join(__dirname, './servers/login/'),
|
loginServers : paths.join(__dirname, './servers/login/'),
|
||||||
contentServers : paths.join(__dirname, './servers/content/'),
|
contentServers : paths.join(__dirname, './servers/content/'),
|
||||||
|
@ -259,7 +260,7 @@ function getDefaultConfig() {
|
||||||
|
|
||||||
art : paths.join(__dirname, './../art/general/'),
|
art : paths.join(__dirname, './../art/general/'),
|
||||||
themes : paths.join(__dirname, './../art/themes/'),
|
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/'),
|
db : paths.join(__dirname, './../db/'),
|
||||||
modsDb : paths.join(__dirname, './../db/mods/'),
|
modsDb : paths.join(__dirname, './../db/mods/'),
|
||||||
dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
|
dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
|
||||||
|
@ -284,10 +285,10 @@ function getDefaultConfig() {
|
||||||
//
|
//
|
||||||
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||||
// -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \
|
// -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):
|
// (The above is a more modern equivalent 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
|
// 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://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/
|
||||||
// - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b
|
// - 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',
|
firstMenu : 'sshConnected',
|
||||||
firstMenuNewUser : 'sshConnectedNewUser',
|
firstMenuNewUser : 'sshConnectedNewUser',
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ const {
|
||||||
Errors,
|
Errors,
|
||||||
ErrorReasons
|
ErrorReasons
|
||||||
} = require('../../enig_error.js');
|
} = require('../../enig_error.js');
|
||||||
|
const User = require('../../user.js');
|
||||||
|
const UserProps = require('../../user_property.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const ssh2 = require('ssh2');
|
const ssh2 = require('ssh2');
|
||||||
|
@ -21,6 +23,7 @@ const fs = require('graceful-fs');
|
||||||
const util = require('util');
|
const util = require('util');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const ModuleInfo = exports.moduleInfo = {
|
const ModuleInfo = exports.moduleInfo = {
|
||||||
name : 'SSH',
|
name : 'SSH',
|
||||||
|
@ -42,8 +45,6 @@ function SSHClient(clientConn) {
|
||||||
|
|
||||||
clientConn.on('authentication', function authAttempt(ctx) {
|
clientConn.on('authentication', function authAttempt(ctx) {
|
||||||
const username = ctx.username || '';
|
const username = ctx.username || '';
|
||||||
const password = ctx.password || '';
|
|
||||||
|
|
||||||
const config = Config();
|
const config = Config();
|
||||||
self.isNewUser = (config.users.newUserNames || []).indexOf(username) > -1;
|
self.isNewUser = (config.users.newUserNames || []).indexOf(username) > -1;
|
||||||
|
|
||||||
|
@ -106,37 +107,36 @@ function SSHClient(clientConn) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
//
|
const authWithPasswordOrPubKey = (authType) => {
|
||||||
// If the system is open and |isNewUser| is true, the login
|
if('pubKey' !== authType || !self.user.isAuthenticated() || !ctx.signature) {
|
||||||
// sequence is hijacked in order to start the application process.
|
// step 1: login/auth using PubKey
|
||||||
//
|
userLogin(self, ctx.username, ctx.password, { authType, ctx }, (err) => {
|
||||||
if(false === config.general.closedSystem && self.isNewUser) {
|
if(err) {
|
||||||
return ctx.accept();
|
if(isSpecialHandleError(err)) {
|
||||||
}
|
return handleSpecialError(err, username);
|
||||||
|
}
|
||||||
|
|
||||||
if(username.length > 0 && password.length > 0) {
|
if(Errors.BadLogin().code === err.code) {
|
||||||
userLogin(self, ctx.username, ctx.password, function authResult(err) {
|
return slowTerminateConnection();
|
||||||
if(err) {
|
}
|
||||||
if(isSpecialHandleError(err)) {
|
|
||||||
return handleSpecialError(err, username);
|
return safeContextReject(SSHClient.ValidAuthMethods);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Errors.BadLogin().code === err.code) {
|
ctx.accept();
|
||||||
return slowTerminateConnection();
|
});
|
||||||
}
|
} else {
|
||||||
|
// step 2: verify signature
|
||||||
return safeContextReject(SSHClient.ValidAuthMethods);
|
const pubKeyActual = ssh2.utils.parseKey(self.user.getProperty(UserProps.LoginPubKey));
|
||||||
|
if(!pubKeyActual || !pubKeyActual.verify(ctx.blob, ctx.signature)) {
|
||||||
|
return slowTerminateConnection();
|
||||||
}
|
}
|
||||||
|
return ctx.accept();
|
||||||
ctx.accept();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if(-1 === SSHClient.ValidAuthMethods.indexOf(ctx.method)) {
|
|
||||||
return safeContextReject(SSHClient.ValidAuthMethods);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authKeyboardInteractive = () => {
|
||||||
if(0 === username.length) {
|
if(0 === username.length) {
|
||||||
// :TODO: can we display something here?
|
|
||||||
return safeContextReject();
|
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);
|
util.inherits(SSHClient, baseClient.Client);
|
||||||
|
|
||||||
SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ];
|
SSHClient.ValidAuthMethods = [
|
||||||
|
'password',
|
||||||
|
'keyboard-interactive',
|
||||||
|
'publickey',
|
||||||
|
];
|
||||||
|
|
||||||
exports.getModule = class SSHServerModule extends LoginServerModule {
|
exports.getModule = class SSHServerModule extends LoginServerModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
71
core/user.js
71
core/user.js
|
@ -21,6 +21,7 @@ const async = require('async');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const sanatizeFilename = require('sanitize-filename');
|
const sanatizeFilename = require('sanitize-filename');
|
||||||
|
const ssh2 = require('ssh2');
|
||||||
|
|
||||||
exports.isRootUserId = function(id) { return 1 === id; };
|
exports.isRootUserId = function(id) { return 1 === id; };
|
||||||
|
|
||||||
|
@ -47,7 +48,10 @@ module.exports = class User {
|
||||||
|
|
||||||
static get StandardPropertyGroups() {
|
static get StandardPropertyGroups() {
|
||||||
return {
|
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 self = this;
|
||||||
const tempAuthInfo = {};
|
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(
|
async.waterfall(
|
||||||
[
|
[
|
||||||
function fetchUserId(callback) {
|
function fetchUserId(callback) {
|
||||||
|
@ -191,27 +234,15 @@ module.exports = class User {
|
||||||
},
|
},
|
||||||
function getRequiredAuthProperties(callback) {
|
function getRequiredAuthProperties(callback) {
|
||||||
// fetch properties required for authentication
|
// 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);
|
return callback(err, props);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
function getDkWithSalt(props, callback) {
|
function validatePassOrPubKey(props, callback) {
|
||||||
// get DK from stored salt and password provided
|
if('pubKey' === options.authType) {
|
||||||
User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => {
|
return validatePubKey(props, callback);
|
||||||
return callback(err, dk, props[UserProps.PassPbkdf2Dk]);
|
}
|
||||||
});
|
return validatePassword(props, callback);
|
||||||
},
|
|
||||||
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 initProps(callback) {
|
function initProps(callback) {
|
||||||
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
|
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
|
||||||
|
|
|
@ -22,7 +22,12 @@ const _ = require('lodash');
|
||||||
|
|
||||||
exports.userLogin = userLogin;
|
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();
|
const config = Config();
|
||||||
|
|
||||||
if(config.users.badUserNames.includes(username.toLowerCase())) {
|
if(config.users.badUserNames.includes(username.toLowerCase())) {
|
||||||
|
@ -34,7 +39,7 @@ function userLogin(client, username, password, cb) {
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.user.authenticate(username, password, err => {
|
client.user.authenticate(username, password, options, err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
|
client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
|
||||||
const disconnect = config.users.failedLogin.disconnect;
|
const disconnect = config.users.failedLogin.disconnect;
|
||||||
|
|
|
@ -8,54 +8,57 @@
|
||||||
// can utilize their own properties as well!
|
// can utilize their own properties as well!
|
||||||
//
|
//
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PassPbkdf2Salt : 'pw_pbkdf2_salt',
|
PassPbkdf2Salt : 'pw_pbkdf2_salt',
|
||||||
PassPbkdf2Dk : 'pw_pbkdf2_dk',
|
PassPbkdf2Dk : 'pw_pbkdf2_dk',
|
||||||
|
|
||||||
AccountStatus : 'account_status', // See User.AccountStatus enum
|
AccountStatus : 'account_status', // See User.AccountStatus enum
|
||||||
|
|
||||||
RealName : 'real_name',
|
RealName : 'real_name',
|
||||||
Sex : 'sex',
|
Sex : 'sex',
|
||||||
Birthdate : 'birthdate',
|
Birthdate : 'birthdate',
|
||||||
Location : 'location',
|
Location : 'location',
|
||||||
Affiliations : 'affiliation',
|
Affiliations : 'affiliation',
|
||||||
EmailAddress : 'email_address',
|
EmailAddress : 'email_address',
|
||||||
WebAddress : 'web_address',
|
WebAddress : 'web_address',
|
||||||
TermHeight : 'term_height',
|
TermHeight : 'term_height',
|
||||||
TermWidth : 'term_width',
|
TermWidth : 'term_width',
|
||||||
ThemeId : 'theme_id',
|
ThemeId : 'theme_id',
|
||||||
AccountCreated : 'account_created',
|
AccountCreated : 'account_created',
|
||||||
LastLoginTs : 'last_login_timestamp',
|
LastLoginTs : 'last_login_timestamp',
|
||||||
LoginCount : 'login_count',
|
LoginCount : 'login_count',
|
||||||
UserComment : 'user_comment', // NYI
|
UserComment : 'user_comment', // NYI
|
||||||
|
|
||||||
DownloadQueue : 'dl_queue', // download_queue.js
|
DownloadQueue : 'dl_queue', // download_queue.js
|
||||||
|
|
||||||
FailedLoginAttempts : 'failed_login_attempts',
|
FailedLoginAttempts : 'failed_login_attempts',
|
||||||
AccountLockedTs : 'account_locked_timestamp',
|
AccountLockedTs : 'account_locked_timestamp',
|
||||||
AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out
|
AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out
|
||||||
|
|
||||||
EmailPwResetToken : 'email_password_reset_token',
|
EmailPwResetToken : 'email_password_reset_token',
|
||||||
EmailPwResetTokenTs : 'email_password_reset_token_ts',
|
EmailPwResetTokenTs : 'email_password_reset_token_ts',
|
||||||
|
|
||||||
FileAreaTag : 'file_area_tag',
|
FileAreaTag : 'file_area_tag',
|
||||||
FileBaseFilters : 'file_base_filters',
|
FileBaseFilters : 'file_base_filters',
|
||||||
FileBaseFilterActiveUuid : 'file_base_filter_active_uuid',
|
FileBaseFilterActiveUuid : 'file_base_filter_active_uuid',
|
||||||
FileBaseLastViewedId : 'user_file_base_last_viewed',
|
FileBaseLastViewedId : 'user_file_base_last_viewed',
|
||||||
FileDlTotalCount : 'dl_total_count',
|
FileDlTotalCount : 'dl_total_count',
|
||||||
FileUlTotalCount : 'ul_total_count',
|
FileUlTotalCount : 'ul_total_count',
|
||||||
FileDlTotalBytes : 'dl_total_bytes',
|
FileDlTotalBytes : 'dl_total_bytes',
|
||||||
FileUlTotalBytes : 'ul_total_bytes',
|
FileUlTotalBytes : 'ul_total_bytes',
|
||||||
|
|
||||||
MessageConfTag : 'message_conf_tag',
|
MessageConfTag : 'message_conf_tag',
|
||||||
MessageAreaTag : 'message_area_tag',
|
MessageAreaTag : 'message_area_tag',
|
||||||
MessagePostCount : 'post_count',
|
MessagePostCount : 'post_count',
|
||||||
|
|
||||||
DoorRunTotalCount : 'door_run_total_count',
|
DoorRunTotalCount : 'door_run_total_count',
|
||||||
DoorRunTotalMinutes : 'door_run_total_minutes',
|
DoorRunTotalMinutes : 'door_run_total_minutes',
|
||||||
|
|
||||||
AchievementTotalCount : 'achievement_total_count',
|
AchievementTotalCount : 'achievement_total_count',
|
||||||
AchievementTotalPoints : 'achievement_total_points',
|
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 \
|
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||||
// -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \
|
// -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):
|
// (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
|
// 2 - Set 'privateKeyPass' to the password you used in step #1
|
||||||
//
|
//
|
||||||
|
|
Loading…
Reference in New Issue