๐ Initial commit
parent
6eec58f29f
commit
1d59208f2a
|
@ -0,0 +1,5 @@
|
|||
.idea
|
||||
node_modules
|
||||
data
|
||||
host.*
|
||||
config.json
|
|
@ -0,0 +1,7 @@
|
|||
Copyright 2020 KaKi87
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,59 @@
|
|||
# synced-over-ssh
|
||||
|
||||
This project is, at the same time, an SSH server, an SSH client, and a password manager.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Installation
|
||||
|
||||
Requirements :
|
||||
- `nodejs`
|
||||
- `npm`
|
||||
- `yarn`
|
||||
- `ssh-keygen`
|
||||
|
||||
Start :
|
||||
- Run `yarn keygen` to generate a SSK key pair
|
||||
- Copy `config.example.json` as `config.json` and specify a custom port
|
||||
- Run `yarn start` to start the SSH server
|
||||
|
||||
Stop : press `Ctrl + C`
|
||||
|
||||
### Testing
|
||||
|
||||
Start :
|
||||
- Run `yarn install` for development dependencies
|
||||
- Run `yarn test` to run Mocha test suite
|
||||
|
||||
### Usage
|
||||
|
||||
- Connect using any SSH client
|
||||
- Follow the steps
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why should I use synced-over-ssh ?
|
||||
|
||||
#### It is open source.
|
||||
|
||||
This project is released under the [MIT license](LICENSE.md).
|
||||
|
||||
#### It is multiplatform.
|
||||
|
||||
Firstly, the server can run on any platform supporting NodeJS.
|
||||
|
||||
Secondly, the server can be accessed from any SSH client.
|
||||
|
||||
#### It respects your privacy.
|
||||
|
||||
Your data is encrypted using the AES-256 asymmetric encryption algorithm using your own as password.
|
||||
|
||||
The standard TOTP 2FA is supported.
|
||||
|
||||
### Why shouldn't I use synced-over-ssh ?
|
||||
|
||||
#### No direct connection.
|
||||
|
||||
Since this project is a server-side service, the SSH connection to your target host is not initiated from your client itself but from the synced-over-ssh then streamed to you.
|
||||
|
||||
Therefore, your SSH client-side logs will always log your connections to the synced-over-ssh server and your SSH server-side logs will always log the connections from the synced-over-ssh server.
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"sshPort": 1234
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
require('console-stamp')(console);
|
||||
|
||||
const
|
||||
{ sshPort } = require('./config'),
|
||||
{ Server } = require('ssh2'),
|
||||
fs = require('fs'),
|
||||
tui = require('./lib/tui');
|
||||
|
||||
new Server({
|
||||
hostKeys: [ fs.readFileSync('./host.key') ]
|
||||
}, client => {
|
||||
client.on('authentication', async authentication => {
|
||||
if(authentication.method !== 'password')
|
||||
return authentication.reject(['password']);
|
||||
const { username, password } = authentication;
|
||||
authentication.accept();
|
||||
client.once('session', async accept => await tui(accept(), username, password));
|
||||
});
|
||||
client.on('error', error => {
|
||||
if(error.code === 'ECONNRESET') return;
|
||||
console.error(error);
|
||||
});
|
||||
client.on('end', tui.sessionEnd);
|
||||
}).listen(sshPort, '0.0.0.0', () => console.log(`SSH server running on port ${sshPort}`));
|
|
@ -0,0 +1,184 @@
|
|||
const
|
||||
fs = require('fs'),
|
||||
path = require('path'),
|
||||
{ pwnedPassword } = require('hibp'),
|
||||
{ argon2, aes, otp } = require('./enc');
|
||||
|
||||
const account = username => {
|
||||
const
|
||||
userPasswordFile = (_username = username) => path.join(__dirname, `../data/${_username}.pwd`),
|
||||
userDataFile = (_username = username) => path.join(__dirname, `../data/${_username}.aes`),
|
||||
userKeyFile = (_username = username) => path.join(__dirname, `../data/${_username}.key`);
|
||||
const _account = {
|
||||
isExisting: () => fs.existsSync(userPasswordFile()),
|
||||
createOrUpdateUsername: newUsername => {
|
||||
if(fs.existsSync(userPasswordFile()))
|
||||
fs.renameSync(userPasswordFile(), userPasswordFile(newUsername));
|
||||
if(fs.existsSync(userDataFile()))
|
||||
fs.renameSync(userDataFile(), userDataFile(newUsername));
|
||||
if(fs.existsSync(userKeyFile()))
|
||||
fs.renameSync(userKeyFile(), userKeyFile(newUsername));
|
||||
return account(newUsername);
|
||||
},
|
||||
verifyPassword: async password => await argon2.verify(fs.readFileSync(userPasswordFile(), 'utf8'), password),
|
||||
createOrUpdatePassword: async password => fs.writeFileSync(userPasswordFile(), await argon2.hash(password), 'utf8'),
|
||||
isOtpEnabled: () => fs.existsSync(userKeyFile()),
|
||||
verifyOtp: (password, code) => otp.verify(code, aes.decrypt(fs.readFileSync(userKeyFile(), 'utf8'), password)),
|
||||
setOtpKey: (password, key) => fs.writeFileSync(userKeyFile(), aes.encrypt(key, password)),
|
||||
disableOtp: () => fs.unlinkSync(userKeyFile()),
|
||||
unregister: () => {
|
||||
fs.unlinkSync(userPasswordFile());
|
||||
fs.unlinkSync(userDataFile());
|
||||
if(_account.isOtpEnabled())
|
||||
fs.unlinkSync(userKeyFile());
|
||||
},
|
||||
getData: password => JSON.parse(aes.decrypt(fs.readFileSync(userDataFile(), 'utf8'), password)),
|
||||
setData: (password, data) => fs.writeFileSync(userDataFile(), aes.encrypt(JSON.stringify(data), password), 'utf8')
|
||||
};
|
||||
return _account;
|
||||
};
|
||||
|
||||
const Account = function(username, password){
|
||||
let _account = account(username);
|
||||
let _isOtpLocked = _account.isOtpEnabled();
|
||||
const _requirePassword = async password => {
|
||||
if(_account.isExisting() && !await _account.verifyPassword(password))
|
||||
throw new Error('WRONG_PASSWORD');
|
||||
};
|
||||
const _requireOtp = (code, verify) => {
|
||||
if(!_account.isOtpEnabled()) return;
|
||||
if(_isOtpLocked || (verify && !code))
|
||||
throw new Error('OTP_REQUIRED');
|
||||
if(verify && !_account.verifyOtp(password, code))
|
||||
throw new Error('WRONG_OTP');
|
||||
};
|
||||
const _getData = () => _account.getData(password);
|
||||
const _setData = data => _account.setData(password, data);
|
||||
const _hostExists = name => !!_getData().hosts.find(host => host.name === name);
|
||||
const _setHost = host => {
|
||||
const data = _getData();
|
||||
const _host = data.hosts.find(_host => _host.name === host.name);
|
||||
if(typeof host.name !== 'string')
|
||||
throw new Error('INVALID_NAME');
|
||||
if(typeof host.address !== 'string')
|
||||
throw new Error('INVALID_ADDRESS');
|
||||
if(typeof host.port !== 'number' || !Number.isInteger(host.port))
|
||||
throw new Error('INVALID_PORT');
|
||||
if(typeof host.user !== 'string')
|
||||
throw new Error('INVALID_USER');
|
||||
if(typeof host.password !== 'string')
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
if(_host)
|
||||
Object.assign(_host, host);
|
||||
else
|
||||
data.hosts.push(host);
|
||||
data.hosts = data.hosts.sort((host1, host2) => host1.name.toLowerCase() < host2.name.toLowerCase() ? -1 : 1);
|
||||
_setData(data);
|
||||
};
|
||||
this.isOtpEnabled = _account.isOtpEnabled;
|
||||
this.enableOtp = async () => {
|
||||
if(_account.isOtpEnabled())
|
||||
throw new Error('ALREADY_ENABLED');
|
||||
const { key, url, qr } = await otp.generate();
|
||||
return {
|
||||
url,
|
||||
qr,
|
||||
verify: code => {
|
||||
const isValid = otp.verify(code, key);
|
||||
if(isValid) _account.setOtpKey(password, key);
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
};
|
||||
this.disableOtp = code => {
|
||||
_requireOtp(code, true);
|
||||
_account.disableOtp();
|
||||
return true;
|
||||
};
|
||||
this.isOtpLocked = () => _isOtpLocked;
|
||||
this.unlockOtp = code => {
|
||||
const isValid = _account.verifyOtp(password, code);
|
||||
if(isValid) _isOtpLocked = false;
|
||||
return isValid;
|
||||
};
|
||||
this.unregister = async (currentPassword, code) => {
|
||||
await _requirePassword(currentPassword);
|
||||
_requireOtp(code, true);
|
||||
_account.unregister();
|
||||
return true;
|
||||
};
|
||||
this.getUsername = () => username;
|
||||
this.setUsername = async (currentPassword, newUsername, code) => {
|
||||
if(!/^[a-zA-Z0-9]+$/.test(username))
|
||||
throw new Error('INVALID_USERNAME');
|
||||
if(account(newUsername).isExisting())
|
||||
throw new Error('ALREADY_EXISTS');
|
||||
await _requirePassword(currentPassword);
|
||||
_requireOtp(code, true);
|
||||
_account = _account.createOrUpdateUsername(newUsername);
|
||||
username = newUsername;
|
||||
return true;
|
||||
};
|
||||
this.setPassword = async (currentPassword, newPassword, code) => {
|
||||
if(await pwnedPassword(newPassword))
|
||||
throw new Error('PWNED_PASSWORD');
|
||||
await _requirePassword(currentPassword);
|
||||
_requireOtp(code, true);
|
||||
await _account.createOrUpdatePassword(newPassword);
|
||||
try { _account.setData(newPassword, _getData()); } catch(_){}
|
||||
password = newPassword;
|
||||
return true;
|
||||
};
|
||||
this.getHosts = () => {
|
||||
_requireOtp();
|
||||
return _getData().hosts;
|
||||
};
|
||||
this.addHost = host => {
|
||||
_requireOtp();
|
||||
if(_hostExists(host.name))
|
||||
throw new Error('ALREADY_EXISTS');
|
||||
_setHost(host);
|
||||
return true;
|
||||
};
|
||||
this.removeHost = name => {
|
||||
_requireOtp();
|
||||
const data = _getData();
|
||||
if(!_hostExists(name))
|
||||
throw new Error('WRONG_NAME');
|
||||
data.hosts.splice(data.hosts.indexOf(data.hosts.find(_host => _host.name === name)), 1);
|
||||
_setData(data);
|
||||
return true;
|
||||
};
|
||||
this.editHost = (currentName, host) => {
|
||||
_requireOtp();
|
||||
_setHost(host);
|
||||
if(currentName !== host.name)
|
||||
this.removeHost(currentName);
|
||||
return true;
|
||||
};
|
||||
this._initialize = async () => {
|
||||
await this.setUsername(undefined, username);
|
||||
await this.setPassword(undefined, password);
|
||||
_setData({
|
||||
hosts: []
|
||||
});
|
||||
return true;
|
||||
};
|
||||
this._login = async () => {
|
||||
if(!_account.isExisting())
|
||||
throw new Error('WRONG_USERNAME');
|
||||
await _requirePassword(password);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.register = async (username, password) => {
|
||||
const account = new Account(username, password);
|
||||
await account._initialize();
|
||||
return account;
|
||||
};
|
||||
|
||||
module.exports.login = async (username, password) => {
|
||||
const account = new Account(username, password);
|
||||
await account._login();
|
||||
return account;
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
const
|
||||
Cryptr = require('cryptr'),
|
||||
argon2 = require('argon2'),
|
||||
otp = require('2fa'),
|
||||
qr = require('qrcode-terminal'),
|
||||
name = require('../package').name;
|
||||
module.exports = {
|
||||
aes: {
|
||||
encrypt: (value, password) => new Cryptr(password).encrypt(value),
|
||||
decrypt: (value, password) => new Cryptr(password).decrypt(value)
|
||||
},
|
||||
argon2: {
|
||||
hash: async password => await argon2.hash(password, { type: argon2.argon2id }),
|
||||
verify: argon2.verify
|
||||
},
|
||||
otp: {
|
||||
generate: () => new Promise((resolve, reject) => otp.generateKey((error, key) => {
|
||||
if(error) return reject(error);
|
||||
const url = `otpauth://totp/${name}/?secret=${otp.base32Encode(key)}`;
|
||||
qr.generate(url, { small: true }, qr => {
|
||||
resolve({
|
||||
key,
|
||||
url,
|
||||
qr
|
||||
});
|
||||
});
|
||||
})),
|
||||
verify: (code, key) => otp.verifyTOTP(key, code)
|
||||
}
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
const { Client } = require('ssh2');
|
||||
|
||||
module.exports = {
|
||||
test: ({ address, port, user, password }) => new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
client.on('error', ({ level: error }) => {
|
||||
if(error === 'client-authentication')
|
||||
reject('WRONG_CREDENTIALS');
|
||||
else if(['client-socket', 'client-timeout'].includes(error))
|
||||
reject('WRONG_DESTINATION');
|
||||
else
|
||||
reject();
|
||||
});
|
||||
client.on('ready', () => {
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
client.connect({
|
||||
host: address,
|
||||
port,
|
||||
username: user,
|
||||
password
|
||||
});
|
||||
}),
|
||||
session: ({ address, port, user, password }, onReady, onClose, onError) => {
|
||||
const client = new Client();
|
||||
client.on('ready', () => client.shell((error, clientStream) => {
|
||||
if(error) return onError(error);
|
||||
clientStream.on('unpipe', () => {
|
||||
onClose(clientStream);
|
||||
client.end();
|
||||
});
|
||||
onReady(clientStream);
|
||||
}));
|
||||
client.on('error', onError);
|
||||
client.connect({
|
||||
host: address,
|
||||
port,
|
||||
username: user,
|
||||
password
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,148 @@
|
|||
const
|
||||
blessed = require('blessed'),
|
||||
contrib = require('blessed-contrib');
|
||||
|
||||
const keys = true;
|
||||
const padding = {
|
||||
left: 1,
|
||||
right: 1
|
||||
};
|
||||
const border = 'line';
|
||||
const type = border;
|
||||
const white = 'white';
|
||||
const black = 'black';
|
||||
|
||||
module.exports = screen => ({
|
||||
form: (
|
||||
{ top, width, height, padding, hidden } = {},
|
||||
{ borders } = {},
|
||||
parent = screen
|
||||
) => blessed.form({
|
||||
parent,
|
||||
keys,
|
||||
top,
|
||||
width: `${width || 100}%`,
|
||||
height,
|
||||
...(borders ? {
|
||||
border: {
|
||||
type,
|
||||
fb: white
|
||||
}
|
||||
} : {}),
|
||||
padding,
|
||||
hidden
|
||||
}),
|
||||
text: (
|
||||
{ top, content, hidden } = {},
|
||||
{
|
||||
color,
|
||||
borders,
|
||||
borderBottom
|
||||
} = {},
|
||||
parent = screen
|
||||
) => {
|
||||
const _text = blessed.text({
|
||||
parent,
|
||||
top,
|
||||
content: borderBottom ? `${content}\n${content.replace(/./g, '_')}` : content,
|
||||
...(color ? {
|
||||
style: {
|
||||
fg: color
|
||||
}
|
||||
} : {}),
|
||||
...(borders ? {
|
||||
border: {
|
||||
type,
|
||||
fg: color
|
||||
},
|
||||
padding
|
||||
} : {}),
|
||||
hidden
|
||||
});
|
||||
_text.$height = content ? content.split('\n').length : undefined;
|
||||
return _text;
|
||||
},
|
||||
button: (
|
||||
{ top, content, hidden },
|
||||
{ color } = {},
|
||||
parent = screen
|
||||
) => blessed.button({
|
||||
parent,
|
||||
top,
|
||||
content,
|
||||
border: {
|
||||
type,
|
||||
fg: color
|
||||
},
|
||||
padding,
|
||||
style: {
|
||||
fg: color,
|
||||
focus: color ? {
|
||||
fg: white,
|
||||
bg: color
|
||||
} : {
|
||||
fg: black,
|
||||
bg: white
|
||||
}
|
||||
},
|
||||
shrink: true,
|
||||
hidden
|
||||
}),
|
||||
textbox: (
|
||||
{ top, content, name, censor },
|
||||
parent = screen
|
||||
) => {
|
||||
const _textbox = blessed.textbox({
|
||||
parent,
|
||||
top,
|
||||
name,
|
||||
censor,
|
||||
inputOnFocus: true,
|
||||
border,
|
||||
height: 3
|
||||
});
|
||||
if(content) _textbox.setValue(content.toString());
|
||||
return _textbox;
|
||||
},
|
||||
checkbox: (
|
||||
{ top, content, name },
|
||||
parent = screen
|
||||
) => blessed.checkbox({
|
||||
parent,
|
||||
top,
|
||||
content,
|
||||
name
|
||||
}),
|
||||
flexContainer: (
|
||||
{ top, width, height } = {},
|
||||
parent = screen
|
||||
) => blessed.layout({
|
||||
parent,
|
||||
top,
|
||||
width: `${width || 100}%`,
|
||||
height: height || 3
|
||||
}),
|
||||
table: (
|
||||
{ top, width, height, columnsWidth, headers, data },
|
||||
parent = screen
|
||||
) => {
|
||||
const _width = screen.width * width / 100;
|
||||
const _table = contrib.table({
|
||||
parent,
|
||||
keys,
|
||||
top,
|
||||
width: _width,
|
||||
height,
|
||||
border,
|
||||
columnSpacing: 0,
|
||||
columnWidth: columnsWidth.map(colW => Math.floor(_width * colW / 100)),
|
||||
selectedFg: black,
|
||||
selectedBg: white
|
||||
});
|
||||
_table.setData({
|
||||
headers: [ ` ${headers[0]}`, ...headers.slice(1) ],
|
||||
data
|
||||
});
|
||||
return _table;
|
||||
}
|
||||
});
|
|
@ -0,0 +1,606 @@
|
|||
const
|
||||
blessed = require('blessed'),
|
||||
ssh = require('../ssh'),
|
||||
helper = require('./helper');
|
||||
|
||||
const sessionEnd = (() => {
|
||||
const listeners = [];
|
||||
return {
|
||||
listen: callback => listeners.push(callback),
|
||||
trigger: () => listeners.forEach(callback => callback())
|
||||
}
|
||||
})();
|
||||
|
||||
const escape = (() => {
|
||||
let listener = () => {};
|
||||
return {
|
||||
listen: callback => listener = callback,
|
||||
trigger: () => listener()
|
||||
}
|
||||
})();
|
||||
|
||||
const getStream = session => new Promise(resolve => {
|
||||
session.on('pty', (accept, reject, { rows, cols }) => {
|
||||
accept();
|
||||
session.on('shell', accept => {
|
||||
const stream = accept();
|
||||
const updateStreamSize = (rows, cols) => {
|
||||
stream.rows = rows;
|
||||
stream.columns = cols;
|
||||
stream.emit('resize');
|
||||
};
|
||||
session.on('window-change', (accept, reject, { rows, cols }) => updateStreamSize(rows, cols));
|
||||
updateStreamSize(rows, cols);
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getScreen = stream => blessed.screen({
|
||||
smartCSR: true,
|
||||
terminal: 'xterm-256color',
|
||||
input: stream.stdout,
|
||||
output: stream.stdin,
|
||||
autoPadding: true
|
||||
});
|
||||
|
||||
// FIXME tab/arrow down twice
|
||||
const getViews = screen => {
|
||||
screen.on('keypress', (undefined, { name }) => {
|
||||
if(name === 'escape')
|
||||
escape.trigger();
|
||||
});
|
||||
const { form, text, button, textbox, checkbox, flexContainer, table } = helper(screen);
|
||||
return {
|
||||
disclaimer: () => new Promise(resolve => {
|
||||
const { name, version, description, homepage, author, license } = require('../../package');
|
||||
const disclaimerForm = form();
|
||||
text({ content: `${name} v${version} | ${description}\n\nBy ${author.name} | ${homepage}\n\nDistributed under the ${license} licence.` });
|
||||
let n = 6;
|
||||
const submitButton = button({ top: n, content: 'Proceed' }, undefined, disclaimerForm);
|
||||
text({ top: n + 4, content: 'Quit to abort.' });
|
||||
disclaimerForm.on('submit', () => resolve());
|
||||
submitButton.on('press', () => disclaimerForm.submit());
|
||||
screen.render();
|
||||
submitButton.focus();
|
||||
}),
|
||||
wrongPassword: () => {
|
||||
text(
|
||||
{ content: '[!] Wrong password.\nPlease quit and try again.' },
|
||||
{ color: 'red', borders: true }
|
||||
);
|
||||
screen.render();
|
||||
},
|
||||
pwnedPassword: () => {
|
||||
text(
|
||||
{ content: '[!] Your password has been breached.\nSee https://haveibeenpwned.com/Passwords for more details.\nPlease quit and try again.' },
|
||||
{ color: 'red', borders: true }
|
||||
);
|
||||
screen.render();
|
||||
},
|
||||
invalidUsername: () => {
|
||||
text(
|
||||
{ content: '[!] Your username is invalid.\nOnly alphanumeric characters are allowed.\nPlease quit and try again.' },
|
||||
{ color: 'red', borders: true }
|
||||
);
|
||||
screen.render();
|
||||
},
|
||||
loginOtp: (isCancellable, callback) => {
|
||||
const otpLoginForm = form();
|
||||
text({ content: 'OTP login :' }, undefined, otpLoginForm);
|
||||
let n = 2;
|
||||
const codeInput = textbox({ top: n, name: 'code' }, otpLoginForm);
|
||||
const actions = flexContainer({ top: n += 4 }, otpLoginForm);
|
||||
button({ content: 'Cancel', hidden: !isCancellable }, { color: 'gray' }, actions)
|
||||
.on('press', () => callback({ isCancelled: true }));
|
||||
button({ content: 'Submit' }, undefined, actions)
|
||||
.on('press', () => otpLoginForm.submit());
|
||||
const errorText = text({ top: n + 4, content: 'Wrong OTP code', hidden: true }, { color: 'red' }, otpLoginForm);
|
||||
otpLoginForm.on('submit', ({ code }) => callback({
|
||||
code,
|
||||
codeError: () => {
|
||||
errorText.show();
|
||||
codeInput.clearValue();
|
||||
codeInput.focus();
|
||||
}
|
||||
}));
|
||||
screen.render();
|
||||
codeInput.focus();
|
||||
},
|
||||
register: ({ username }, callback) => {
|
||||
const registrationForm = form();
|
||||
text({ content: `Welcome ${username}, please confirm your password to create your account.` }, undefined, registrationForm);
|
||||
let n = 2;
|
||||
const repeatPasswordInput = textbox({ top: n, name: 'repeatPassword', censor: true }, registrationForm);
|
||||
checkbox({ top: n += 4, name: 'enableOtp', content: 'Enable OTP (next step)' }, registrationForm);
|
||||
const submitButton = button({ top: n += 2, content: 'Submit' }, undefined, registrationForm);
|
||||
const passwordMismatchError = text(
|
||||
{ top: n + 4, content: 'This password does not match the one you entered via SSH.', hidden: true },
|
||||
{ color: 'red' },
|
||||
registrationForm
|
||||
);
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
registrationForm.on('submit', ({ repeatPassword, enableOtp }) => callback({
|
||||
repeatPassword,
|
||||
enableOtp,
|
||||
passwordMismatchError: () => {
|
||||
passwordMismatchError.show();
|
||||
repeatPasswordInput.clearValue();
|
||||
repeatPasswordInput.focus();
|
||||
}
|
||||
}));
|
||||
submitButton.on('press', () => registrationForm.submit());
|
||||
screen.render();
|
||||
repeatPasswordInput.focus();
|
||||
},
|
||||
enableOtp: ({ url, qr }, callback) => {
|
||||
const otpSetupForm = form();
|
||||
text({ content: 'OTP setup' }, { borderBottom: true });
|
||||
let n = 3;
|
||||
text({ top: n, content: `URL : ${url}` }, otpSetupForm);
|
||||
const qrText = text({ top: n + 2, content: `QR code :\n${qr}` }, otpSetupForm);
|
||||
text({ top: n += qrText.$height + 2, content: 'Input OTP code to confirm :' }, otpSetupForm);
|
||||
const codeInput = textbox({ top: n += 2, name: 'code' }, otpSetupForm);
|
||||
const actions = flexContainer({ top: n += 4 }, otpSetupForm);
|
||||
button({ content: 'Cancel' }, { color: 'gray' }, actions)
|
||||
.on('press', () => callback({ isCancelled: true }));
|
||||
button({ content: 'Submit' }, undefined, actions)
|
||||
.on('press', () => otpSetupForm.submit());
|
||||
const successText = text({ top: n += 4, content: 'OTP successfully enabled', hidden: true }, { color: 'green' }, otpSetupForm);
|
||||
const errorText = text({ top: n, content: 'Wrong OTP code', hidden: true }, { color: 'red' }, otpSetupForm);
|
||||
otpSetupForm.on('submit', ({ code }) => callback({
|
||||
code,
|
||||
codeError: () => {
|
||||
successText.hide();
|
||||
errorText.show();
|
||||
codeInput.clearValue();
|
||||
codeInput.focus();
|
||||
}
|
||||
}));
|
||||
screen.render();
|
||||
codeInput.focus();
|
||||
},
|
||||
home: ({ hosts, isOtpEnabled }, callback) => {
|
||||
const mainActionsForm = form();
|
||||
let n = 3;
|
||||
const mainActionsFormContainer = flexContainer(undefined, mainActionsForm);
|
||||
const selectButton = button({ content: 'Select host', hidden: hosts.length === 0 }, undefined, mainActionsFormContainer);
|
||||
const addButton = button({ content: 'Add host' }, undefined, mainActionsFormContainer);
|
||||
addButton.on('press', () => callback({ action: 'add' }));
|
||||
button({ content: `${isOtpEnabled ? 'Disable' : 'Enable'} OTP` }, undefined, mainActionsFormContainer)
|
||||
.on('press', () => callback({ action: 'toggle-otp' }));
|
||||
button({ content: 'Edit user/pass' }, undefined, mainActionsFormContainer)
|
||||
.on('press', () => callback({ action: 'edit-userpass' }));
|
||||
button({ content: 'Delete account' }, undefined, mainActionsFormContainer)
|
||||
.on('press', () => callback({ action: 'delete-account' }));
|
||||
let selectedHost;
|
||||
const tableHeight = Math.floor(screen.height / 4);
|
||||
const hostsTable = table({
|
||||
top: n += 1,
|
||||
width: 50,
|
||||
height: tableHeight,
|
||||
columnsWidth: [ 50, 50 ],
|
||||
headers: [ 'Host', 'URI' ],
|
||||
data: hosts.map(host => [ host.name, `${host.user}@${host.address}:${host.port}` ])
|
||||
});
|
||||
selectButton.on('press', () => {
|
||||
hostsTable.focus();
|
||||
escape.listen(() => selectButton.focus());
|
||||
});
|
||||
const selectedHostForm = form({
|
||||
top: n + tableHeight + 1,
|
||||
width: 50,
|
||||
height: 9,
|
||||
padding: 1,
|
||||
hidden: true
|
||||
}, { borders: true });
|
||||
const selectedHostText = text(undefined, undefined, selectedHostForm);
|
||||
const selectedHostActions = flexContainer({ top: 2, width: 50 }, selectedHostForm);
|
||||
const cancelButton = button({ content: 'Cancel' }, { color: 'gray' }, selectedHostActions);
|
||||
cancelButton.on('press', () => {
|
||||
selectedHostForm.hide();
|
||||
screen.render();
|
||||
hostsTable.focus();
|
||||
});
|
||||
hostsTable.rows.on('select', (undefined, index) => {
|
||||
selectedHost = hosts[index];
|
||||
selectedHostText.setContent(`Selected host : ${selectedHost.name}`);
|
||||
selectedHostForm.show();
|
||||
screen.render();
|
||||
cancelButton.focus();
|
||||
});
|
||||
button({ content: 'Connect' }, undefined, selectedHostActions)
|
||||
.on('press', () => callback({ action: 'connect', host: selectedHost }));
|
||||
button({ content: 'Edit' }, undefined, selectedHostActions)
|
||||
.on('press', () => callback({ action: 'edit', host: selectedHost }));
|
||||
const removeButton = button({ content: 'Remove' }, { color: 'red' }, selectedHostActions);
|
||||
removeButton.on('press', () => {
|
||||
if(removeButton.content === 'Remove'){
|
||||
removeButton.setContent('Confirm');
|
||||
screen.render();
|
||||
}
|
||||
else if(removeButton.content === 'Confirm')
|
||||
callback({ action: 'remove', host: selectedHost });
|
||||
});
|
||||
screen.render();
|
||||
if(hosts.length)
|
||||
selectButton.focus();
|
||||
else
|
||||
addButton.focus();
|
||||
},
|
||||
setHost: ({ name, address, port, user, password } = {}, callback) => {
|
||||
let action;
|
||||
const hostForm = form();
|
||||
text({ content: 'Add host' }, { borderBottom: true }, hostForm);
|
||||
let n = 3;
|
||||
text({ content: 'Name :', top: n }, hostForm);
|
||||
const nameInput = textbox({ top: n += 2, name: 'name', content: name }, hostForm);
|
||||
text({ top: n += 4, content: 'Address :' }, hostForm);
|
||||
const addressInput = textbox({ top: n += 2, name: 'address', content: address }, hostForm);
|
||||
text({ top: n += 4, content: 'Port :' }, hostForm);
|
||||
textbox({ top: n += 2, name: 'port', content: port }, hostForm);
|
||||
text({ top: n += 4, content: 'User :' }, hostForm);
|
||||
const userInput = textbox({ top: n += 2, name: 'user', content: user }, hostForm);
|
||||
text({ top: n += 4, content: 'Password :' }, hostForm);
|
||||
textbox({ top: n += 2, name: 'password', censor: true, content: password }, hostForm);
|
||||
const actions = flexContainer({ top: n += 4, width: 100 }, hostForm);
|
||||
button({ content: 'Cancel' }, { color: 'gray' }, actions)
|
||||
.on('press', () => callback({ action: 'cancel' }));
|
||||
const testButton = button({ content: 'Test' }, undefined, actions);
|
||||
testButton.on('press', () => {
|
||||
action = 'test';
|
||||
hostForm.submit();
|
||||
});
|
||||
button({ content: 'Submit' }, undefined, actions).on('press', () => {
|
||||
action = 'submit';
|
||||
hostForm.submit();
|
||||
});
|
||||
const successText = text(
|
||||
{ top: n += 4, content: 'SSH connection successfull', hidden: true },
|
||||
{ color: 'green' },
|
||||
hostForm
|
||||
);
|
||||
const errorText = text(
|
||||
{ top: n, hidden: true },
|
||||
{ color: 'red' },
|
||||
hostForm
|
||||
);
|
||||
hostForm.on('submit', ({ name, address, port, user, password }) => {
|
||||
port = parseInt(port);
|
||||
if(action === 'test'){
|
||||
testButton.setContent('Testing');
|
||||
screen.render();
|
||||
}
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
callback({
|
||||
action,
|
||||
host: { name, address, port, user, password },
|
||||
res: res => {
|
||||
if(action === 'test'){
|
||||
testButton.setContent('Test');
|
||||
if(res === true){
|
||||
successText.show();
|
||||
errorText.hide();
|
||||
}
|
||||
else {
|
||||
successText.hide();
|
||||
const error = ({
|
||||
WRONG_CREDENTIALS: 'wrong user or password',
|
||||
WRONG_DESTINATION: 'wrong address or port'
|
||||
})[res];
|
||||
errorText.setContent(`SSH connection failed : ${error || `unknown error (${res})`}`);
|
||||
errorText.show();
|
||||
}
|
||||
}
|
||||
else if(action === 'submit'){
|
||||
successText.hide();
|
||||
const error = ({
|
||||
ALREADY_EXISTS: 'duplicate host name',
|
||||
INVALID_PORT: 'invalid port'
|
||||
})[res];
|
||||
errorText.setContent(`Submit failed : ${error || `unknown error (${res})`}`);
|
||||
errorText.show();
|
||||
}
|
||||
screen.render();
|
||||
switch(res){
|
||||
case 'WRONG_CREDENTIALS': {
|
||||
userInput.focus();
|
||||
break;
|
||||
}
|
||||
case 'WRONG_DESTINATION': {
|
||||
addressInput.focus();
|
||||
break;
|
||||
}
|
||||
case 'ALREADY_EXISTS': {
|
||||
nameInput.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
screen.render();
|
||||
nameInput.focus();
|
||||
},
|
||||
editUserPass: ({ username, isOtpEnabled }, callback) => {
|
||||
const editUserPassForm = form();
|
||||
text({ content: 'Edit username and/or password' }, { borderBottom: true }, editUserPassForm);
|
||||
let n = 3;
|
||||
text({ top: n, content: `Current username : ${username}` }, undefined, editUserPassForm);
|
||||
text({ top: n += 2, content: 'New username :' }, undefined, editUserPassForm);
|
||||
const newUsernameInput = textbox({ top: n += 2, name: 'newUsername' }, editUserPassForm);
|
||||
text({ top: n += 4, content: 'Current password :' }, undefined, editUserPassForm);
|
||||
textbox({ top: n += 2, name: 'currentPassword', censor: true }, editUserPassForm);
|
||||
text({ top: n += 4, content: 'New password :' }, undefined, editUserPassForm);
|
||||
textbox({ top: n += 2, name: 'newPassword', censor: true }, editUserPassForm);
|
||||
text({ top: n += 4, content: 'Repeat new password :' }, undefined, editUserPassForm);
|
||||
textbox({ top: n += 2, name: 'repeatNewPassword', censor: true }, editUserPassForm);
|
||||
if(isOtpEnabled){
|
||||
text({ top: n += 4, content: 'Confirm OTP :' }, undefined, editUserPassForm);
|
||||
textbox({ top: n += 2, name: 'otpCode' }, editUserPassForm);
|
||||
}
|
||||
const actions = flexContainer({ top: n += 4 }, editUserPassForm);
|
||||
button({ content: 'Cancel' }, { color: 'gray' }, actions)
|
||||
.on('press', () => callback({ isCancelled: true }));
|
||||
button({ content: 'Submit' }, undefined, actions)
|
||||
.on('press', () => editUserPassForm.submit());
|
||||
const errorText = text(
|
||||
{ top: n + 4 },
|
||||
{ color: 'red' },
|
||||
editUserPassForm
|
||||
);
|
||||
editUserPassForm.on('submit', formData => callback({
|
||||
...formData,
|
||||
error: error => {
|
||||
errorText.setContent(({
|
||||
INVALID_USERNAME: 'The new username is invalid.',
|
||||
ALREADY_EXISTS: 'The new username already exists.',
|
||||
PASSWORD_MISMATCH: `The new password confirmation doesn't match.`,
|
||||
PWNED_PASSWORD: 'The new password has been breached.',
|
||||
WRONG_PASSWORD: 'The current password is incorrect.',
|
||||
OTP_REQUIRED: 'The OTP code is required.',
|
||||
WRONG_OTP: 'The OTP code is incorrect.'
|
||||
})[error]);
|
||||
screen.render();
|
||||
}
|
||||
}));
|
||||
screen.render();
|
||||
newUsernameInput.focus();
|
||||
},
|
||||
deleteAccount: ({ isOtpEnabled }, callback) => {
|
||||
const deleteAccountForm = form();
|
||||
text({ content: 'Account deletion' }, { borderBottom: true }, deleteAccountForm);
|
||||
let n = 3;
|
||||
text({ top: n, content: 'Password :' }, undefined, deleteAccountForm);
|
||||
const passwordInput = textbox({ top: n += 2, name: 'password', censor: true }, deleteAccountForm);
|
||||
if(isOtpEnabled){
|
||||
text({ top: n += 4, content: 'OTP :' }, undefined, deleteAccountForm);
|
||||
textbox({ top: n += 2, name: 'otp' }, deleteAccountForm);
|
||||
}
|
||||
checkbox({ top: n += 4, name: 'confirm', content: 'I understand this cannot be undone.' }, deleteAccountForm);
|
||||
const actions = flexContainer({ top: n += 2 }, deleteAccountForm);
|
||||
button({ content: 'Cancel' }, { color: 'gray' }, actions)
|
||||
.on('press', () => callback({ isCancelled: true }));
|
||||
const submitButton = button({ content: 'Submit' }, { color: 'red' }, actions)
|
||||
submitButton.on('press', () => deleteAccountForm.submit());
|
||||
const errorText = text(
|
||||
{ top: n + 4 },
|
||||
{ color: 'red' },
|
||||
deleteAccountForm
|
||||
);
|
||||
deleteAccountForm.on('submit', formData => {
|
||||
if(!formData.confirm) callback({ isCancelled: true });
|
||||
if(submitButton.content === 'Submit'){
|
||||
submitButton.setContent('Confirm');
|
||||
screen.render();
|
||||
}
|
||||
else if(submitButton.content === 'Confirm'){
|
||||
submitButton.setContent('Submit');
|
||||
screen.render();
|
||||
callback({
|
||||
...formData,
|
||||
error: error => {
|
||||
errorText.setContent(({
|
||||
WRONG_PASSWORD: 'The current password is incorrect.',
|
||||
OTP_REQUIRED: 'The OTP code is required.',
|
||||
WRONG_OTP: 'The OTP code is incorrect.'
|
||||
})[error]);
|
||||
screen.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
screen.render();
|
||||
passwordInput.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getControllers = stream => {
|
||||
let screen, views;
|
||||
const init = () => {
|
||||
screen = getScreen(stream);
|
||||
views = getViews(screen);
|
||||
};
|
||||
init();
|
||||
const _controllers = {
|
||||
auth: (username, password) => new Promise(resolve => {
|
||||
const account = require('../account');
|
||||
(async () => {
|
||||
try {
|
||||
const user = await account.login(username, password);
|
||||
if(user.isOtpLocked()){
|
||||
views.loginOtp(false, ({ code, codeError }) => {
|
||||
if(user.unlockOtp(code)){
|
||||
resolve(user);
|
||||
}
|
||||
else
|
||||
codeError();
|
||||
});
|
||||
}
|
||||
else
|
||||
resolve(user);
|
||||
}
|
||||
catch(error){
|
||||
if(error.message === 'WRONG_USERNAME'){
|
||||
try {
|
||||
let isConfirmed = false;
|
||||
await views.disclaimer();
|
||||
const user = await account.register(username, password);
|
||||
views.register({ username }, async ({ repeatPassword, passwordMismatchError, enableOtp }) => {
|
||||
if(repeatPassword !== password) return passwordMismatchError();
|
||||
isConfirmed = true;
|
||||
if(enableOtp){
|
||||
const { url, qr, verify } = await user.enableOtp();
|
||||
views.enableOtp({ url, qr }, ({ isCancelled, code, codeError }) => {
|
||||
if(isCancelled || verify(code))
|
||||
resolve(user);
|
||||
else
|
||||
codeError();
|
||||
});
|
||||
}
|
||||
else
|
||||
resolve(user);
|
||||
});
|
||||
sessionEnd.listen(() => {
|
||||
if(!isConfirmed)
|
||||
user.unregister(password);
|
||||
});
|
||||
}
|
||||
catch(error){
|
||||
if(error.message === 'PWNED_PASSWORD')
|
||||
views.pwnedPassword();
|
||||
else if(error.message === 'INVALID_USERNAME')
|
||||
views.invalidUsername();
|
||||
}
|
||||
}
|
||||
else if(error.message === 'WRONG_PASSWORD')
|
||||
views.wrongPassword();
|
||||
}
|
||||
})();
|
||||
}),
|
||||
main: user => {
|
||||
views.home({ hosts: user.getHosts(), isOtpEnabled: user.isOtpEnabled() }, ({ action, host }) => {
|
||||
switch(action){
|
||||
case 'add':
|
||||
case 'edit': {
|
||||
return views.setHost(host, ({ action: action2, host: host2, res }) => {
|
||||
switch(action2){
|
||||
case 'cancel': return _controllers.main(user);
|
||||
case 'test': {
|
||||
const { address, port, user, password } = host2;
|
||||
ssh.test({ address, port, user, password }).then(() => res(true)).catch(res);
|
||||
return;
|
||||
}
|
||||
case 'submit': {
|
||||
try {
|
||||
if(action === 'add')
|
||||
user.addHost(host2);
|
||||
else if(action === 'edit')
|
||||
user.editHost(host.name, host2);
|
||||
_controllers.main(user);
|
||||
}
|
||||
catch(error){
|
||||
res(error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'remove': {
|
||||
user.removeHost(host.name);
|
||||
return _controllers.main(user);
|
||||
}
|
||||
case 'connect': {
|
||||
return ssh.session(host, clientStream => {
|
||||
screen.destroy();
|
||||
clientStream.stdout.pipe(stream.stdin);
|
||||
stream.stdin.pipe(clientStream.stdout);
|
||||
}, clientStream => {
|
||||
clientStream.stdout.unpipe(stream.stdin);
|
||||
stream.stdin.unpipe(clientStream.stdout);
|
||||
// FIXME input/focus
|
||||
init();
|
||||
_controllers.main(user);
|
||||
}, () => {
|
||||
_controllers.main(user);
|
||||
});
|
||||
}
|
||||
case 'toggle-otp': {
|
||||
if(user.isOtpEnabled()){
|
||||
views.loginOtp(true, ({ isCancelled, code, codeError }) => {
|
||||
if(isCancelled) return _controllers.main(user);
|
||||
try {
|
||||
user.disableOtp(code);
|
||||
_controllers.main(user);
|
||||
}
|
||||
catch(_){
|
||||
codeError();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
user.enableOtp().then(({ url, qr, verify }) => {
|
||||
views.enableOtp({ url, qr }, ({ isCancelled, code, codeError }) => {
|
||||
if(isCancelled || verify(code))
|
||||
_controllers.main(user);
|
||||
else
|
||||
codeError();
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'edit-userpass': {
|
||||
return views.editUserPass({ username: user.getUsername(), isOtpEnabled: user.isOtpEnabled() }, async ({
|
||||
isCancelled,
|
||||
newUsername, currentPassword, newPassword, repeatNewPassword,
|
||||
otpCode,
|
||||
error: sendError
|
||||
}) => {
|
||||
if(isCancelled) return _controllers.main(user);
|
||||
try {
|
||||
if(newUsername)
|
||||
await user.setUsername(currentPassword, newUsername, otpCode);
|
||||
if(newPassword){
|
||||
if(newPassword !== repeatNewPassword)
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('PASSWORD_MISMATCH');
|
||||
else
|
||||
await user.setPassword(currentPassword, newPassword, otpCode);
|
||||
}
|
||||
return _controllers.main(user);
|
||||
}
|
||||
catch(error){
|
||||
sendError(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'delete-account': {
|
||||
return views.deleteAccount({ isOtpEnabled: user.isOtpEnabled() }, async ({ isCancelled, password, otp, error: sendError }) => {
|
||||
if(isCancelled) return _controllers.main(user);
|
||||
try {
|
||||
await user.unregister(password, otp);
|
||||
stream.end();
|
||||
}
|
||||
catch(error){
|
||||
sendError(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return _controllers;
|
||||
};
|
||||
|
||||
module.exports = async (session, username, password) => {
|
||||
const stream = await getStream(session);
|
||||
const controllers = getControllers(stream);
|
||||
const user = await controllers.auth(username, password);
|
||||
controllers.main(user);
|
||||
};
|
||||
|
||||
module.exports.sessionEnd = sessionEnd.trigger;
|
36
package.json
36
package.json
|
@ -1,9 +1,35 @@
|
|||
{
|
||||
"name": "SyncedTerm",
|
||||
"name": "synced-over-ssh",
|
||||
"version": "0.1.0",
|
||||
"description": "Synced SSH terminal client",
|
||||
"description": "Synced over SSH client",
|
||||
"main": "index.js",
|
||||
"repository": "ssh://kaki@git.kaki87.net:3021/KaKi87/SyncedTerm.git",
|
||||
"author": "KaKi87 <tiana.lemesle@live.fr>",
|
||||
"license": "MIT"
|
||||
"homepage": "https://git.kaki87.net/KaKi87/synced-over-ssh",
|
||||
"repository": "ssh://kaki@git.kaki87.net:3021/KaKi87/synced-over-ssh.git",
|
||||
"author": {
|
||||
"name": "KaKi87",
|
||||
"email": "tiana.lemesle@live.fr"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"2fa": "^0.1.2",
|
||||
"argon2": "^0.26.2",
|
||||
"blessed": "^0.1.81",
|
||||
"blessed-contrib": "^4.8.20",
|
||||
"console-stamp": "^0.2.9",
|
||||
"cryptr": "^6.0.2",
|
||||
"hibp": "^9.0.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ssh2": "^0.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.2.0",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"deathmoon-totp-generator": "^1.0.1",
|
||||
"mocha": "^7.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"keygen": "ssh-keygen -f host -N '' && mv host host.key",
|
||||
"start": "node index.js",
|
||||
"test": "./node_modules/.bin/mocha"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
const
|
||||
chai = require('chai'),
|
||||
{ expect } = chai,
|
||||
path = require('path'),
|
||||
fs = require('fs'),
|
||||
totp = require('deathmoon-totp-generator');
|
||||
|
||||
chai.use(require('chai-as-promised'));
|
||||
|
||||
const account = require('../lib/account');
|
||||
|
||||
const password = 't43he78sgf36er14fr';
|
||||
|
||||
describe('account', () => {
|
||||
|
||||
const
|
||||
dataPath = path.join(__dirname, '../data'),
|
||||
dataBackupPath = path.join(__dirname, '../data.bak');
|
||||
|
||||
before(() => {
|
||||
fs.renameSync(dataPath, dataBackupPath);
|
||||
fs.mkdirSync(dataPath);
|
||||
});
|
||||
|
||||
describe('login/register', () => {
|
||||
|
||||
it('should not login to non-existing account', () => expect(account.login('username', 'password')).to.be.eventually.rejectedWith('WRONG_USERNAME'));
|
||||
|
||||
it('should not register with insecure password', () => expect(account.register('username', 'password')).to.be.eventually.rejectedWith('PWNED_PASSWORD'));
|
||||
|
||||
it('should register new account', () => expect(account.register('username', password)).to.be.fulfilled);
|
||||
|
||||
it('should not register with already existing username', () => expect(account.register('username', password)).to.be.eventually.rejectedWith('ALREADY_EXISTS'));
|
||||
|
||||
it('should not login with wrong password', () => expect(account.login('username', 'wrongpassword')).to.be.eventually.rejectedWith('WRONG_PASSWORD'));
|
||||
|
||||
it('should login', () => expect(account.login('username', password)).to.be.fulfilled);
|
||||
|
||||
describe('otp', () => {
|
||||
|
||||
let _account, _getValidOtp;
|
||||
|
||||
describe('enable otp', () => {
|
||||
|
||||
let _account, otp;
|
||||
|
||||
before(async () => {
|
||||
_account = await account.login('username', password);
|
||||
otp = await _account.enableOtp();
|
||||
_getValidOtp = () => totp(otp.url.split('=')[1]);
|
||||
});
|
||||
|
||||
it('should not be enabled', () => expect(_account.isOtpEnabled()).to.be.false);
|
||||
|
||||
it('should not be locked', () => expect(_account.isOtpLocked()).to.be.false);
|
||||
|
||||
it('should return an url', () => expect(otp.url).to.be.a('string'));
|
||||
|
||||
it('should return a qr code', () => expect(otp.qr).to.be.a('string'));
|
||||
|
||||
it('should return a callback', () => expect(otp.verify).to.be.a('function'));
|
||||
|
||||
it('should not validate', () => expect(otp.verify(1234)).to.be.false);
|
||||
|
||||
it('should validate', () => expect(otp.verify(_getValidOtp())).to.be.true);
|
||||
|
||||
it('should not enable again', () => expect(_account.enableOtp()).to.be.eventually.rejectedWith('ALREADY_ENABLED'));
|
||||
|
||||
it('should not be enabled', () => expect(_account.isOtpEnabled()).to.be.true);
|
||||
|
||||
});
|
||||
|
||||
describe('login otp', () => {
|
||||
|
||||
before(async () => _account = await account.login('username', password));
|
||||
|
||||
it('should be locked', () => expect(_account.isOtpLocked()).to.be.true);
|
||||
|
||||
it('should not unlock', () => expect(_account.unlockOtp(1234)).to.be.false);
|
||||
|
||||
it('should still be locked', () => expect(_account.isOtpLocked()).to.be.true);
|
||||
|
||||
it('should validate', () => expect(_account.unlockOtp(_getValidOtp())).to.be.true);
|
||||
|
||||
it('should be unlocked', () => expect(_account.isOtpLocked()).to.be.false);
|
||||
|
||||
});
|
||||
|
||||
describe('disable otp', () => {
|
||||
|
||||
it('should not disable without otp code', () => expect(() => _account.disableOtp()).to.throw('OTP_REQUIRED'));
|
||||
|
||||
it('should not disable with wrong otp code', () => expect(() => _account.disableOtp(1234)).to.throw('WRONG_OTP'));
|
||||
|
||||
it('should disable', () => expect(_account.disableOtp(_getValidOtp())).to.be.true);
|
||||
|
||||
it('should be disabled', () => expect(_account.isOtpEnabled()).to.be.false);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('hosts', () => {
|
||||
|
||||
let _account;
|
||||
|
||||
const host1v1 = {
|
||||
name: 'host',
|
||||
address: 'host',
|
||||
port: 22,
|
||||
user: 'user',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const host1v2 = {
|
||||
...host1v1,
|
||||
address: 'host2',
|
||||
port: 1234
|
||||
};
|
||||
|
||||
const host1v3 = {
|
||||
...host1v2,
|
||||
name: 'renamedHost'
|
||||
};
|
||||
|
||||
const host2 = {
|
||||
...host1v1,
|
||||
name: 'host2'
|
||||
};
|
||||
|
||||
before(async () => _account = await account.login('username', password));
|
||||
|
||||
it('should return an empty array', () => expect(_account.getHosts()).to.deep.equal([]));
|
||||
|
||||
it('should add host', () => expect(_account.addHost(host1v1)).to.be.true);
|
||||
|
||||
it('should have added host', () => expect(_account.getHosts()).to.deep.include(host1v1));
|
||||
|
||||
it('should add another host', () => expect(_account.addHost(host2)).to.be.true);
|
||||
|
||||
it('should have added another host', () => expect(_account.getHosts()).to.deep.include(host2));
|
||||
|
||||
it('should not add host with already existing name', () => expect(() => _account.addHost(host1v1)).to.throw('ALREADY_EXISTS'));
|
||||
|
||||
it('should edit host', () => expect(_account.editHost(host1v1.name, host1v2)));
|
||||
|
||||
it('should have edited host', () => expect(_account.getHosts()).to.deep.include(host1v2));
|
||||
|
||||
it('should have edited host (2)', () => expect(_account.getHosts()).to.not.deep.include(host1v1));
|
||||
|
||||
it('should rename host', () => expect(_account.editHost(host1v2.name, host1v3)).to.be.true);
|
||||
|
||||
it('should have renamed host', () => expect(_account.getHosts()).to.deep.include(host1v3));
|
||||
|
||||
it('should have renamed host (2)', () => expect(_account.getHosts()).to.not.deep.include(host1v2));
|
||||
|
||||
it('should delete host', () => expect(_account.removeHost(host1v3.name)).to.be.true);
|
||||
|
||||
it('should have deleted host', () => expect(_account.getHosts()).to.not.deep.include(host1v3));
|
||||
|
||||
it('should not delete already deleted host', () => expect(() => _account.removeHost(host1v3.name)).to.throw('WRONG_NAME'));
|
||||
|
||||
describe('alphabetical order', () => {
|
||||
|
||||
before('remove secondary example host & add hosts in random order', () => {
|
||||
|
||||
_account.removeHost(host2.name);
|
||||
|
||||
_account.addHost({ ...host1v1, name: 'Charlie' });
|
||||
|
||||
_account.addHost({ ...host1v1, name: 'Tango' });
|
||||
|
||||
_account.addHost({ ...host1v1, name: 'Alpha' });
|
||||
|
||||
_account.addHost({ ...host1v1, name: 'Romeo' });
|
||||
|
||||
});
|
||||
|
||||
it('should store hosts in alphabetical order', () => expect(_account.getHosts().map(host => host.name)).to.deep.equal(['Alpha', 'Charlie', 'Romeo', 'Tango']));
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('change username & password', () => {
|
||||
|
||||
const password2 = 'erg4ret3g87rsfe';
|
||||
|
||||
it('should change username', async () => expect((await account.login('username', password)).setUsername(password, 'username2')).to.be.fulfilled);
|
||||
|
||||
it('should have changed username', () => expect(account.login('username2', password)).to.be.fulfilled);
|
||||
|
||||
it('should change password', async () => expect((await account.login('username2', password)).setPassword(password, password2)).to.be.fulfilled);
|
||||
|
||||
it('should have changed password', () => expect(account.login('username2', password2)).to.be.fulfilled);
|
||||
|
||||
after('reset username & password', async () => {
|
||||
const _account = await account.login('username2', password2);
|
||||
await _account.setUsername(password2, 'username');
|
||||
await _account.setPassword(password2, password);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('unregister', () => {
|
||||
|
||||
let _account;
|
||||
|
||||
before(async () => _account = await account.login('username', password));
|
||||
|
||||
it('should unregister', () => expect(_account.unregister(password)).to.be.fulfilled);
|
||||
|
||||
it('should be unregistered', () => expect(account.login('username', password)).to.eventually.be.rejectedWith('WRONG_USERNAME'));
|
||||
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmdirSync(dataPath);
|
||||
fs.renameSync(dataBackupPath, dataPath);
|
||||
});
|
||||
|
||||
});
|