Browse Source

🎉 Initial commit

feature/docker
KaKi87 2 years ago
parent
commit
1d59208f2a
  1. 5
      .gitignore
  2. 7
      LICENSE.md
  3. 59
      README.md
  4. 3
      config.example.json
  5. 24
      index.js
  6. 184
      lib/account.js
  7. 30
      lib/enc.js
  8. 43
      lib/ssh.js
  9. 148
      lib/tui/helper.js
  10. 606
      lib/tui/index.js
  11. 36
      package.json
  12. 224
      test/account.js
  13. 1868
      yarn.lock

5
.gitignore

@ -0,0 +1,5 @@
.idea
node_modules
data
host.*
config.json

7
LICENSE.md

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

59
README.md

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

3
config.example.json

@ -0,0 +1,3 @@
{
"sshPort": 1234
}

24
index.js

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

184
lib/account.js

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

30
lib/enc.js

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

43
lib/ssh.js

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

148
lib/tui/helper.js

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

606
lib/tui/index.js

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

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

224
test/account.js

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

1868
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save