Commit c6b085cd authored by Bernat Canal's avatar Bernat Canal
Browse files

RPC wallet first version, tests ~90%

parent 205827c7
module.exports = {
RPC: require('./lib/RPC.js'),
util: require('./lib/util.js'),
Wallet: require('./lib/Wallet.js')
Wallet: require('./lib/Wallet.js'),
RPCWallet: require('./lib/RPCWallet.js')
}
......@@ -49,6 +49,7 @@ class RPC {
})
.then(httpResponse => {
if (httpResponse.body.error) {
console.error(httpResponse.body.error)
return reject(new Error(httpResponse.body.error.data))
}
resolve(httpResponse.body.result)
......
const RPC = require('./RPC')
const EventEmitter = require('events').EventEmitter
const util = require('util')
const bip39 = require('bip39')
class RPCWallet {
assign (api, method) {
let self = this
self[method] = (...args) => {
return self.rpc[api][method].apply(self, args)
}
}
constructor (options) {
let self = this
let provider = options.provider
let v3 = options.v3
let mnemonic = options.mnemonic
let password = options.password
self.rpc = new RPC(provider)
console.log(options)
let rpcMethods = [
['personal', 'newAccount'],
['personal', 'sendTransaction'],
['personal', 'sign'],
['personal', 'ecRecover'],
['parity', 'allAccountsInfo'],
['parity', 'changePassword'],
['parity', 'deriveAddressHash'],
['parity', 'deriveAddressIndex'],
['parity', 'exportAccount'],
['parity', 'killAccount'],
['parity', 'newAccountFromPhrase'],
['parity', 'newAccountFromSecret'],
['parity', 'newAccountFromWallet'],
['parity', 'removeAddress'],
['parity', 'testPassword']
]
rpcMethods.forEach(method => {
self.assign(method[0], method[1])
})
if (v3 && password) {
this.newAccountFromWallet(v3, password)
.then(address => {
self._address = address
})
.catch(console.error)
} else if (mnemonic && password) {
this.fromMnemonic(mnemonic, password)
}
}
/**
* Retrieve address once wallet is initialized.
* @param {string} format - Formatting : can be buffer or string.
* @returns {string} - Address for the given wallet instance.
*/
address () {
return this._address
}
/**
* Initializes privKey, pubKey and address from mnemonic.
* @param {string} mnemonic - Mnemonic.
*/
fromMnemonic (mnemonic, password) {
let self = this
if (!bip39.validateMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic provided to Wallet.fromMnemonic :' + mnemonic)
}
delete this.mnemonic
delete this.instance
this.rpc.parity.newAccountFromPhrase(mnemonic, password)
.then(address => {
self._address = address
self.emit('ready', this)
})
}
/**
* Initializes the current wallet with the given hdkey
* @param {string} masterMnemonicSeed - Mnemonic.
*/
fromHDKey (hdkey) {
delete this.mnemonic
delete this.instance
this.instance = hdkey
}
wasSigned (hex, signed) {
let self = this
return new Promise((resolve, reject) => {
self.ecRecover(hex, signed)
.then(address => {
resolve(address === self.address())
})
.catch(reject)
})
}
}
util.inherits(RPCWallet, EventEmitter)
module.exports = RPCWallet
......@@ -89,6 +89,10 @@ const util = {
*/
ecsign: (hash, privateKey) => {
return EthereumjsUtil.ecsign(hash, privateKey)
},
keccak256: (input) => {
return '0x' + EthereumjsUtil.keccak256(input).toString('hex')
}
}
......
......@@ -132,5 +132,29 @@
"personal_signTransaction",
"personal_unlockAccount",
"personal_sign",
"personal_ecRecover"
"personal_ecRecover",
"parity_allAccountsInfo",
"parity_changePassword",
"parity_deriveAddressHash",
"parity_deriveAddressIndex",
"parity_exportAccount",
"parity_getDappAddresses",
"parity_getDappDefaultAddress",
"parity_getNewDappsAddresses",
"parity_getNewDappsDefaultAddress",
"parity_importGethAccounts",
"parity_killAccount",
"parity_listGethAccounts",
"parity_listRecentDapps",
"parity_newAccountFromPhrase",
"parity_newAccountFromSecret",
"parity_newAccountFromWallet",
"parity_removeAddress",
"parity_setAccountMeta",
"parity_setAccountName",
"parity_setDappAddresses",
"parity_setDappDefaultAddress",
"parity_setNewDappsAddresses",
"parity_setNewDappsDefaultAddress",
"parity_testPassword"
]
......@@ -11,7 +11,7 @@ const poeWallet = require('../index.js')
// We assign each lib to a single var
const util = poeWallet.util
const Wallet = poeWallet.Wallet
const rpc = new poeWallet.RPC('http://localhost:8545')
const rpc = new poeWallet.RPC('http://localhost:8540')
// const path = require('path')
......
/* eslint-env mocha */
// const mocha = require('mocha')
const chai = require('chai')
// const should = chai.should()
const expect = chai.expect
const poeWallet = require('../index.js')
// We assign each lib to a single var
const util = poeWallet.util
const Wallet = poeWallet.RPCWallet
// const path = require('path')
let defaultWallet
let defaultMnemonic = 'home october plastic room chief proof raise battle churn hint practice drama'
let defaultNamespace = 'Test name space :D'
let defaultMessage = 'This is a message :D'
let v3WalletJSON
let defaultProvider = 'http://localhost:8540'
let defaultPassword = 'passwordxd'
let defaultAddress
let defaultPrivateKey = '0xcafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe'
describe('RPC Wallet tests', () => {
describe('HD Wallet generation', () => {
it('Should throw an error on invalid mnemonic provided', () => {
try {
Wallet({
provider: defaultProvider,
mnemonic: defaultMnemonic + 'invalid',
password: defaultPassword
})
} catch (e) {
expect(e).to.be.a('error')
}
})
it('Should create a new wallet instance from a given mnemonic', (done) => {
defaultWallet = new Wallet({
provider: defaultProvider,
mnemonic: defaultMnemonic,
password: defaultPassword
})
defaultWallet.on('ready', () => {
defaultAddress = defaultWallet.address()
expect(defaultWallet).to.include.keys('_address')
done()
})
})
it('Should call properly exportAccount', (done) => {
defaultWallet.exportAccount(
defaultAddress,
defaultPassword
).then(res => {
v3WalletJSON = JSON.stringify(res)
expect(res).to.include.keys('address')
expect(res.address).to.equal('0018b4f1f7947ce83cc2cb22152442e468d40a59')
done()
}).catch(done)
})
it('Should create a new wallet instance from a given v3', (done) => {
defaultWallet = new Wallet({
provider: defaultProvider,
v3: v3WalletJSON,
password: defaultPassword
})
done()
})
})
describe('RPC Parity Accounts', () => {
it('Should call properly newAccountFromPhrase', (done) => {
defaultWallet.newAccountFromPhrase(
defaultMnemonic,
defaultPassword
).then(res => {
defaultAddress = res
expect(res).to.equal('0x0018b4f1f7947ce83cc2cb22152442e468d40a59')
done()
}).catch(done)
})
it('Should call properly allAccountsInfo', (done) => {
defaultWallet.allAccountsInfo()
.then(res => {
expect(res).to.include.keys(defaultAddress)
done()
}).catch(done)
})
it('Should call properly changePassword', (done) => {
defaultWallet.changePassword(
defaultAddress,
defaultPassword,
'newpassword'
).then(res => {
defaultPassword = 'newpassword'
expect(res).to.equal(true)
done()
}).catch(done)
})
it('Should call properly deriveAddressHash', (done) => {
defaultWallet.deriveAddressHash(
defaultAddress,
defaultPassword,
{
hash: util.keccak256(defaultNamespace),
type: 'hard'
},
true
).then(res => {
expect(res).to.equal('0x74a18cadc10ec2550e4209b2bd6bfdfb0b2ef5c8')
done()
}).catch(done)
})
it('Should call properly deriveAddressIndex', (done) => {
defaultWallet.deriveAddressIndex(
defaultAddress,
defaultPassword,
[
{
index: 99,
type: 'hard'
}
],
true
).then(res => {
expect(res).to.equal('0x9880af058e1025391bbde80c77b4c2c106f9d3ac')
done()
}).catch(done)
})
it('Should call properly newAccountFromSecret', (done) => {
defaultWallet.newAccountFromSecret(
defaultPrivateKey,
defaultPassword
).then(res => {
expect(res).to.equal('0xb0b0aa0d17c9b2b527bbc9d69bd093ab47febeb0')
done()
}).catch(done)
})
it('Should call properly newAccountFromWallet', (done) => {
defaultWallet.newAccountFromWallet(
v3WalletJSON,
defaultPassword
).then(res => {
expect(res).to.equal('0x0018b4f1f7947ce83cc2cb22152442e468d40a59')
done()
}).catch(done)
})
it('Should call properly testPassword', (done) => {
defaultWallet.testPassword(
defaultAddress,
defaultPassword
).then(res => {
expect(res).to.equal(true)
done()
}).catch(done)
})
it('Should call sign and verify', (done) => {
let hexMessage = '0x' + Buffer.from(defaultMessage).toString('hex')
defaultWallet.sign(
hexMessage,
defaultAddress,
defaultPassword
)
.then(res => {
return defaultWallet.wasSigned(hexMessage, res)
})
.then(isValid => {
expect(isValid).to.equal(true)
done()
}).catch(done)
})
it('Should call properly removeAddress', (done) => {
defaultWallet.removeAddress(
defaultAddress
).then(res => {
expect(res).to.equal(true)
done()
}).catch(done)
})
it('Should call properly killAccount', (done) => {
defaultWallet.killAccount(
defaultAddress,
defaultPassword
).then(res => {
expect(res).to.equal(true)
done()
}).catch(done)
})
})
})
## El servidor
El servidor está desarrollado sobre [Socket.io](https://www.npmjs.com/package/socket.io) para habilitar comunicación bidireccional servidor-cliente.
Para la verificación servidor-cliente y cliente-cliente se usan [JSON Web Tokens(JWT)](https://www.npmjs.com/package/jsonwebtoken)
Para los challenges se usan [uuid versión 1](https://www.npmjs.com/package/uuid)
## Configuración
Debemos instalar los siguientes paquetes desde npm:
```bash
npm install express socket.io uuid jsonwebtoken
```
Para usarlos dentro de nuestra aplicación los incluímos e inicializamos
```javascript
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const io = require('socket.io')(server);
const uuid = require('uuid/v1');
const jwt = require('jsonwebtoken');
```
Necesitaremos, además, una función para extraer las marcas temporales de los uuidV1:
```javascript
function getTimeStampFromUUID (uuid_str){
let get_time_int = function (uuid_str) {
var uuid_arr = uuid_str.split( '-' ),
time_str = [
uuid_arr[ 2 ].substring( 1 ),
uuid_arr[ 1 ],
uuid_arr[ 0 ]
].join( '' );
return parseInt( time_str, 16 );
};
var int_time = get_time_int( uuid_str ) - 122192928000000000,
int_millisec = Math.floor( int_time / 10000 );
return int_millisec ;
}
```
A continuación exponemos nuestro cliente web, almacenado en `www`
```javascript
app.use('/web', express.static('www'));
```
Después hace falta poner el servidor a escuchar peticiones
```javascript
server.listen(port, () => {
console.log('Server listening on port', port)
});
```
## Socket.io - servidor
El servidor identifica a los clientes usando su address vinculada. Para poder declarar que un cliente (socket) es poseedor de su dirección se usa el handshake.
Al recibir cualquier usuario el servidor comprobará si existe el campo `address` en el socket para la connexión. De no ser así, se emitirá el evento `handshakeChallenge`
## Handshake servidor-cliente
```javascript
io.on('connection', (socket) => {
if (!socket.address){
socket.emit('handshakeChallenge', uuid())
} else {
console.log(socket.address, 'connected');
}
// Listeners, los veremos a continuación
socket.on('disconnect', () => {...})
socket.on('handshakeChallengeResult', payload => {...})
socket.on('getChallenge', address => {...})
socket.on('authChallenge', data => {...})
socket.on('directMessage', (targetAddress, payload) => {...})
})
```
El cliente deberá tener un `listener` sobre el método `handshakeChallenge` de tipo `once` que firme el challenge y lo devuelva al servidor a través del método `handshakeChallengeResult`
```javascript
io.once('handshakeChallenge', (challenge) => {
let signed = wallet.sign(challenge);
let payload = {
address : wallet.address('string'),
challenge : challenge,
signed : signed
}
io.emit('handshakeChallengeResult', payload);
})
```
El servidor recibirá el challenge firmado y comprobará la validez de la firma proporcionada
```javascript
socket.on('handshakeChallengeResult', payload => {
let isSignatureValid = walletUtil.verifySignature(
payload.signed,
payload.address,
payload.challenge
)
if (isSignatureValid){
socket.signed = payload.signed;
socket.address = payload.address;
socket.challenge = payload.challenge;
sockets[socket.address] = socket;
console.log(socket.address, 'access granted')
} else {
console.log('Invalid attempt to connect:', payload)
}
})
```
Una vez firmado, el servidor registra el socket y la address del cliente y están autorizadas a llamar al resto de funciones.
## Handshake cliente-cliente
En esta sección hemos podido comprobar que todo `socket` del lado de servidor tiene asociado un `address` y su clave privada.
Ahora vamos a permitir a una webapp y el cliente intercambiar mensajes de forma segura.
La webapp deberá ejectuar
```javascript
io.emit('getChallenge', '0xcafe...')
```
El servidor tiene un listener sobre el método `getChallenge`
```javascript
socket.on('getChallenge', (address) => {
let challenge = uuid();
socket.emit('getChallengeResult', {
challenge : challenge,
address : address,
});
})
```
La webapp deberá mostrar el resultado de `getChallengeResult` para que el teléfono pueda capturarlas, ya sea como texto plano o con un código QR.
Cuando el teléfono capture el QR invocará al método `authChallenge`
```javascript
mobileIO.emit('authChallenge', {
challengedSocketAddress
challengerSocketAddress
challenge
signed
url
})
```
El servidor verificará la firma y el timestamp cuando se llame al evento `authChallenge`. En caso de firma válida y timestamp menor al tiempo de vida del challenge la webapp y el teléfono reciben un JWT firmado.
```javascript
[...]
if (isTimeStampValid && isSignatureValid){
let token = jwt.sign({
challengerSocketAddress : data.challengerSocketAddress,
challengedSocketAddress : data.challengedSocketAddress
}, hs256Secret);
sockets[data.challengerSocketAddress].token = token;
sockets[data.challengedSocketAddress].token = token;
sockets[data.challengerSocketAddress].emit('authChallengeResult', token);
sockets[data.challengedSocketAddress].emit('authChallengeResult', token);
} else {
// ERROR
}
```
A partir de este momento la webapp y el teléfono comparten un JWT único que les permite intercambiar mensajes con el método `directMessage`
```javascript
mobileIO.emit('directMessage', 'hello')
[...]
io.on('directMessage', (msg) => {
// msg = 'hello'
})
```
# La app
## Crear la cartera
Al iniciar la app deberemos crear una cartera. Se proveerá una [clave mnemónica](../examples/01-wallet-creation.js) y se pedirá al usuario una contraseña con la que proteger la cartera.
Tras unos segundos la cartera se creará
![alt text](img/step1.png "Step 1")
## Acceder a la cartera
Al continuar o volver a abrir la app se preguntará al usuario por su contraseña para poder usar la cartera. El proceso de desbloqueo (desencriptar la copia local) puede tardar unos cuantos segundos.
Una vez la cartera esté desbloqueada podremos acceder a las conversaciones. Entrando en "Test QR" se empieza el proceso de login
![alt text](img/step2.png "Step 2")
En el navegador deberemos acceder a [http://178.128.34.142:5050/web/](http://178.128.34.142:5050/web/). Se nos mostrarán la dirección de la cartera usada por el navegador, un *challenge* manual (sólo para testing) y el código QR.
## Login web
El login web se puede efectuar con QR o usando texto plano para tests. Para que el login se complete de forma correcta el teléfono debe firmar y mandar el *challenge* mostrado en el navegador en menos de 5 minutos desde su creación. La firma deberá también ser válida
Cuando el servidor confirme que las firmas del navegador y del teléfono son válidas pondrá en contacto sus sesiones de `socket.io` para que puedan intercambiar mensajes de forma directa.
### Usando texto (test)
Para poder probar la aplicación en dispositivos sin cámara se ha habilitado el *challenge* manual, el código que encontramos en el primer recuadro blanco. Podemos copiarlo y pegarlo en "Test QR" para testear el login.
### Usando código QR
Dentro de la conversación "Test QR" pulsamos el icono de la cámara. Bastará con apuntar al código QR para efectuar el login
![alt text](img/photo.png "Final step")