diff --git a/README.md b/README.md index 8e5d5ec..e14eb9d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ options: --seed SEED : seed (command-line) --compress : enable chunk compression --private : make the proxy private (do not leak the access capability to the DHT) +--key-file : load the key pair from the secure-key file at the specified location (https://github.com/holepunchto/secure-key) +--key-file-password : pass in the secure-key password (the default is to read it from stdin) + ``` ```sh @@ -106,6 +109,8 @@ options: -i keypair.json : keypair file --compress : enable chunk compression --private : access a private hypertele server (expects -s to contain the server's seed instead of the public key) +--key-file : load the key pair from the secure-key file at the specified location (https://github.com/holepunchto/secure-key). In private mode, this should be the same file as used to launch the server. +--key-file-password : pass in the secure-key password (the default is to read it from stdin) ``` Read more about using identities here: https://github.com/prdn/hyper-cmd-docs/blob/main/identity.md diff --git a/client.js b/client.js index cad54fa..3f6c995 100644 --- a/client.js +++ b/client.js @@ -8,112 +8,147 @@ const libUtils = require('@hyper-cmd/lib-utils') const libKeys = require('@hyper-cmd/lib-keys') const goodbye = require('graceful-goodbye') const connPiper = libNet.connPiper +const SecureKey = require('secure-key') -const helpMsg = 'Usage:\nhypertele -p port_listen -u unix_socket ?--address service_address ?-c conf.json ?-i identity.json ?-s peer_key ?--private' +async function main () { + const helpMsg = 'Usage:\nhypertele -p port_listen -u unix_socket ?--address service_address ?-c conf.json ?-i identity.json ?-s peer_key ?--private ?--key-file ?--key-file-password' -if (argv.help) { - console.log(helpMsg) - process.exit(-1) -} + if (argv.help) { + console.log(helpMsg) + process.exit(-1) + } -if (!argv.u && argv.p == null) { - console.error('Error: proxy port invalid') - process.exit(-1) -} + if (!argv.u && argv.p == null) { + console.error('Error: proxy port invalid') + process.exit(-1) + } -if (argv.u && argv.p) { - console.error('Error: cannot listen to both a port and a Unix domain socket') - process.exit(-1) -} -const conf = {} + if (argv.u && argv.p) { + console.error('Error: cannot listen to both a port and a Unix domain socket') + process.exit(-1) + } + const conf = {} + conf.private = argv.private != null + + const target = argv.u ? argv.u : +argv.p + + const keyPair = await getKeyPair(argv, conf) -const target = argv.u ? argv.u : +argv.p + // Unofficial opt, only used for tests + let bootstrap = null + if (argv.bootstrap) { + bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] + } + + if (conf.private) { + conf.peer = keyPair.publicKey + } else if (argv.s) { + conf.peer = libUtils.resolveHostToKey([], argv.s) + } -let keyPair = null -if (argv.i) { - keyPair = libUtils.resolveIdentity([], argv.i) + if (argv.c) { + libUtils.readConf(conf, argv.c) + } + + if (!conf.keepAlive) { + conf.keepAlive = 5000 + } - if (!keyPair) { - console.error('Error: identity file invalid') + if (argv.compress) { + conf.compress = true + } + + const peer = conf.peer + if (!peer) { + console.error('Error: peer is invalid') process.exit(-1) } - keyPair = libKeys.parseKeyPair(keyPair) -} + const debug = argv.debug -conf.private = argv.private != null -if (conf.private) { - if (keyPair != null) throw new Error('The --private flag is not compatible with the -i(dentity) flag, since the identity is derived from the peer key') - const seed = argv.s - keyPair = HyperDHT.keyPair(b4a.from(seed, 'hex')) -} + const stats = {} -// Unofficial opt, only used for tests -let bootstrap = null -if (argv.bootstrap) { - bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] -} + const dht = new HyperDHT({ + bootstrap, + keyPair + }) -if (argv.s) { - conf.peer = conf.private - ? keyPair.publicKey - : libUtils.resolveHostToKey([], argv.s) -} + const proxy = net.createServer({ allowHalfOpen: true }, c => { + return connPiper(c, () => { + const stream = dht.connect(Buffer.from(peer, 'hex'), { reusableSocket: true }) + stream.setKeepAlive(conf.keepAlive) -if (argv.c) { - libUtils.readConf(conf, argv.c) -} + return stream + }, { compress: conf.comgetKeyPairpress }, stats) + }) -if (!conf.keepAlive) { - conf.keepAlive = 5000 -} + if (debug) { + setInterval(() => { + console.log('connection stats', stats) + }, 5000) + } -if (argv.compress) { - conf.compress = true -} + if (argv.u) { + proxy.listen(target, () => { + console.log(`Server ready @${target}`) + }) + } else { + const targetHost = argv.address || '127.0.0.1' + proxy.listen(target, targetHost, () => { + const { address, port } = proxy.address() + console.log(`Server ready @${address}:${port}`) + }) + } -const peer = conf.peer -if (!peer) { - console.error('Error: peer is invalid') - process.exit(-1) + goodbye(async () => { + await dht.destroy() + }) } -const debug = argv.debug +async function getKeyPair (argv, conf) { + if (argv['key-file']) { + if (argv.s && conf.private) throw new Error('key-file is not compatible with -s(eed) in private mode, since it uses the keys in the key-file instead of the seed') -const stats = {} + const password = argv['key-file-password'] + ? b4a.from(argv['key-file-password']) + : null // read from stdin if not specified -const dht = new HyperDHT({ - bootstrap, - keyPair -}) + const secureKeyPair = await SecureKey.open(argv['key-file'], { password }) -const proxy = net.createServer({ allowHalfOpen: true }, c => { - return connPiper(c, () => { - const stream = dht.connect(Buffer.from(peer, 'hex'), { reusableSocket: true }) - stream.setKeepAlive(conf.keepAlive) + secureKeyPair.unlock() + const keyPair = { + publicKey: b4a.from(secureKeyPair.publicKey), + secretKey: b4a.from(secureKeyPair.secretKey) + } + secureKeyPair.lock() + secureKeyPair.clear() - return stream - }, { compress: conf.compress }, stats) -}) + return keyPair + } -if (debug) { - setInterval(() => { - console.log('connection stats', stats) - }, 5000) -} + if (argv.i && conf.private) { + throw new Error('The --private flag is not compatible with the -i(dentity) flag, since the identity is derived from the peer key') + } -if (argv.u) { - proxy.listen(target, () => { - console.log(`Server ready @${target}`) - }) -} else { - const targetHost = argv.address || '127.0.0.1' - proxy.listen(target, targetHost, () => { - const { address, port } = proxy.address() - console.log(`Server ready @${address}:${port}`) - }) + let keyPair = null + + if (argv.i) { + keyPair = libUtils.resolveIdentity([], argv.i) + + if (!keyPair) { + console.error('Error: identity file invalid') + process.exit(-1) + } + + keyPair = libKeys.parseKeyPair(keyPair) + } + + if (conf.private) { + const seed = argv.s + keyPair = HyperDHT.keyPair(b4a.from(seed, 'hex')) + } + + return keyPair } -goodbye(async () => { - await dht.destroy() -}) +main().catch(console.error) diff --git a/package.json b/package.json index 865eea2..38f297e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@hyper-cmd/lib-keys": "https://github.com/holepunchto/hyper-cmd-lib-keys#v0.0.2", "@hyper-cmd/lib-net": "https://github.com/holepunchto/hyper-cmd-lib-net#v0.0.8", "@hyper-cmd/lib-utils": "https://github.com/holepunchto/hyper-cmd-lib-utils#v0.0.2", + "secure-key": "^1.0.0", "b4a": "^1.6.4", "graceful-goodbye": "^1.3.0", "hyperdht": "^6.11.0", @@ -28,7 +29,8 @@ "homepage": "https://github.com/bitfinexcom/hypertele", "devDependencies": { "brittle": "^3.3.2", - "standard": "^17.1.0" + "standard": "^17.1.0", + "test-tmp": "^1.2.0" }, "scripts": { "test": "standard && brittle test/end-to-end-tests.js" diff --git a/server.js b/server.js index fe6914b..7de31c4 100644 --- a/server.js +++ b/server.js @@ -8,115 +8,153 @@ const libUtils = require('@hyper-cmd/lib-utils') const libKeys = require('@hyper-cmd/lib-keys') const goodbye = require('graceful-goodbye') const connPiper = libNet.connPiper +const SecureKey = require('secure-key') -const helpMsg = 'Usage:\nhypertele-server -l service_port -u unix_socket ?--address service_address ?-c conf.json ?--seed seed ?--cert-skip ?--private' +async function main () { + const helpMsg = 'Usage:\nhypertele-server -l service_port -u unix_socket ?--address service_address ?-c conf.json ?--seed seed ?--cert-skip ?--private ?--key-file ?--key-file-password' -if (argv.help) { - console.log(helpMsg) - process.exit(-1) -} + if (argv.help) { + console.log(helpMsg) + process.exit(-1) + } -if (!argv.u && !+argv.l) { - console.error('Error: proxy port invalid') - process.exit(-1) -} + if (!argv.u && !+argv.l) { + console.error('Error: proxy port invalid') + process.exit(-1) + } -if (argv.u && argv.l) { - console.error('Error: cannot listen to both a port and a Unix domain socket') - process.exit(-1) -} + if (argv.u && argv.l) { + console.error('Error: cannot listen to both a port and a Unix domain socket') + process.exit(-1) + } -const conf = {} + const conf = {} -if (argv.seed) { - conf.seed = argv.seed -} + if (argv.seed) { + conf.seed = argv.seed + } -if (argv.c) { - libUtils.readConf(conf, argv.c) -} + if (argv.c) { + libUtils.readConf(conf, argv.c) + } -if (argv.compress) { - conf.compress = true -} + if (argv.compress) { + conf.compress = true + } -if (argv['cert-skip']) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0 -} + if (argv['cert-skip']) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0 + } -if (!conf.seed) { - console.error('Error: conf.seed invalid') - process.exit(-1) -} + if (!conf.seed && !argv['key-file']) { + console.error('Error: conf.seed invalid') + process.exit(-1) + } -if (conf.allow) { - conf.allow = libKeys.prepKeyList(conf.allow) -} + if (conf.allow) { + conf.allow = libKeys.prepKeyList(conf.allow) + } -conf.private = false -if (argv.private) { - if (conf.allow) throw new Error('--private flag is not compatible with an allow list, as the private key derived from the seed is the capability') - conf.private = true -} + conf.private = false + if (argv.private) { + if (conf.allow) throw new Error('--private flag is not compatible with an allow list, as the private key derived from the seed is the capability') + conf.private = true + } -// Unofficial opt, only used for tests -let bootstrap = null -if (argv.bootstrap) { - bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] + await setupServer(argv, conf) } -const debug = argv.debug +async function setupServer (argv, conf) { + // Unofficial opt, only used for tests + let bootstrap = null + if (argv.bootstrap) { + bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] + } -const seed = Buffer.from(conf.seed, 'hex') + const debug = argv.debug -const dht = new HyperDHT({ bootstrap }) -const keyPair = HyperDHT.keyPair(seed) + const keyPair = await getKeyPair(conf, argv) -const stats = {} + const dht = new HyperDHT({ bootstrap }) -const destIp = argv.address || '127.0.0.1' + const stats = {} -const privateFirewall = (remotePublicKey) => { - return !b4a.equals(remotePublicKey, keyPair.publicKey) -} + const destIp = argv.address || '127.0.0.1' + + const privateFirewall = (remotePublicKey) => { + return !b4a.equals(remotePublicKey, keyPair.publicKey) + } + + const allowListFirewall = (remotePublicKey, remoteHandshakePayload) => { + if (conf.allow && !libKeys.checkAllowList(conf.allow, remotePublicKey)) { + return true + } + + return false + } -const allowListFirewall = (remotePublicKey, remoteHandshakePayload) => { - if (conf.allow && !libKeys.checkAllowList(conf.allow, remotePublicKey)) { - return true + const firewall = conf.private ? privateFirewall : allowListFirewall + + const server = dht.createServer({ + firewall, + reusableSocket: true + }, c => { + connPiper(c, () => { + return net.connect( + argv.u + ? { path: argv.u } + : { port: +argv.l, host: destIp, allowHalfOpen: true } + ) + }, { debug, isServer: true, compress: conf.compress }, stats) + }) + + server.listen(keyPair).then(() => { + if (conf.private) { + if (argv['key-file']) { + console.log(`hypertele (private, encrypted keypair): use the --key-file option with the same keypair to connect (listening on ${b4a.toString(keyPair.publicKey, 'hex')})`) + } else { + console.log(`hypertele (private): connect with seed ${conf.seed} (listening on ${b4a.toString(keyPair.publicKey, 'hex')})`) + } + } else { + console.log('hypertele:', keyPair.publicKey.toString('hex')) + } + }) + + if (debug) { + setInterval(() => { + console.log('connection stats', stats) + }, 5000) } - return false + goodbye(async () => { + await dht.destroy() + }) } -const firewall = conf.private ? privateFirewall : allowListFirewall - -const server = dht.createServer({ - firewall, - reusableSocket: true -}, c => { - connPiper(c, () => { - return net.connect( - argv.u - ? { path: argv.u } - : { port: +argv.l, host: destIp, allowHalfOpen: true } - ) - }, { debug, isServer: true, compress: conf.compress }, stats) -}) - -server.listen(keyPair).then(() => { - if (conf.private) { - console.log(`hypertele (private): connect with seed ${b4a.toString(seed, 'hex')} (listening on ${b4a.toString(keyPair.publicKey, 'hex')})`) - } else { - console.log('hypertele:', keyPair.publicKey.toString('hex')) - } -}) - -if (debug) { - setInterval(() => { - console.log('connection stats', stats) - }, 5000) +async function getKeyPair (conf, argv) { + if (conf.seed) { + return HyperDHT.keyPair(Buffer.from(conf.seed, 'hex')) + } + + if (!argv['key-file']) { + throw new Error('--seed or --key-file must be specified') + } + + const password = argv['key-file-password'] + ? b4a.from(argv['key-file-password']) + : null // read from stdin if not specified + + const secureKeyPair = await SecureKey.open(argv['key-file'], { password }) + + secureKeyPair.unlock() + const keyPair = { + publicKey: b4a.from(secureKeyPair.publicKey), + secretKey: b4a.from(secureKeyPair.secretKey) + } + secureKeyPair.lock() + secureKeyPair.clear() + + return keyPair } -goodbye(async () => { - await dht.destroy() -}) +main().catch(console.error) diff --git a/test/end-to-end-tests.js b/test/end-to-end-tests.js index 9e79f99..cbd88fd 100644 --- a/test/end-to-end-tests.js +++ b/test/end-to-end-tests.js @@ -6,11 +6,16 @@ const createTestnet = require('hyperdht/testnet') const test = require('brittle') const HyperDHT = require('hyperdht') const b4a = require('b4a') +const tmp = require('test-tmp') +const SecureKey = require('secure-key') +const fsProm = require('fs/promises') const MAIN_DIR = path.dirname(__dirname) const SERVER_EXECUTABLE = path.join(MAIN_DIR, 'server.js') const CLIENT_EXECUTABLE = path.join(MAIN_DIR, 'client.js') +const DEBUG_LOG = false + test('Can proxy in private mode', async t => { const { bootstrap } = await createTestnet(3, t.teardown) const portToProxy = await setupDummyServer(t.teardown) @@ -51,6 +56,61 @@ test('Can proxy in non-private mode', async t => { t.is(res.data, 'You got served', 'Proxy works') }) +test('Can proxy with key-file (private mode)', async t => { + const { bootstrap } = await createTestnet(3, t.teardown) + const portToProxy = await setupDummyServer(t.teardown) + const { password, keyFile } = await setupKeyPairFile(t) + + await setupHyperteleServer( + portToProxy, + null, + bootstrap, + t, + { isPrivate: true, keyFile, password } + ) + + const clientPort = await setupHyperteleClient( + null, + bootstrap, + t, + { isPrivate: true, keyFile, password } + ) + + const res = await request(clientPort) + t.is(res.data, 'You got served', 'Proxy works') +}) + +test('Can proxy with key-file (non-private server)', async t => { + const { bootstrap } = await createTestnet(3, t.teardown) + const portToProxy = await setupDummyServer(t.teardown) + const keyFileInfos = await Promise.all([ + setupKeyPairFile(t), + setupKeyPairFile(t) + ]) + const { password: passwordServer, keyFile: keyFileServer } = keyFileInfos[0] + const { password: passwordClient, keyFile: keyFileClient } = keyFileInfos[1] + + const pubKey = await fsProm.readFile(`${keyFileServer}.public`, 'hex') + + await setupHyperteleServer( + portToProxy, + null, + bootstrap, + t, + { isPrivate: false, keyFile: keyFileServer, password: passwordServer } + ) + + const clientPort = await setupHyperteleClient( + pubKey, + bootstrap, + t, + { isPrivate: false, keyFile: keyFileClient, password: passwordClient } + ) + + const res = await request(clientPort) + t.is(res.data, 'You got served', 'Proxy works') +}) + async function setupDummyServer (teardown) { const server = http.createServer(async (req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8') @@ -63,16 +123,24 @@ async function setupDummyServer (teardown) { return server.address().port } -async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivate = false } = {}) { +async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivate = false, keyFile, password } = {}) { const args = [ SERVER_EXECUTABLE, '-l', portToProxy, - '--seed', - seed, '--bootstrap', bootstrap[0].port ] + if (seed) { + args.push('--seed') + args.push(seed) + } + if (keyFile) { + args.push('--key-file') + args.push(keyFile) + args.push('--key-file-password') + args.push(password) + } if (isPrivate) args.push('--private') const setupServer = spawn('node', args) @@ -83,6 +151,10 @@ async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivat t.fail('Failed to setup hypertele server') }) + setupServer.stdout.on('data', (data) => { + if (DEBUG_LOG) console.debug(console.debug(data.toString())) + }) + await new Promise(resolve => { setupServer.stdout.on('data', (data) => { if (data.includes('hypertele')) { @@ -92,16 +164,24 @@ async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivat }) } -async function setupHyperteleClient (seed, bootstrap, t, { isPrivate = false } = {}) { +async function setupHyperteleClient (seed, bootstrap, t, { isPrivate = false, keyFile, password } = {}) { const args = [ CLIENT_EXECUTABLE, '-p', 0, // random - '-s', - seed, '--bootstrap', bootstrap[0].port ] + if (seed) { + args.push('-s') + args.push(seed) + } + if (keyFile) { + args.push('--key-file') + args.push(keyFile) + args.push('--key-file-password') + args.push(password) + } if (isPrivate) args.push('--private') const setupClient = spawn('node', args) @@ -112,6 +192,10 @@ async function setupHyperteleClient (seed, bootstrap, t, { isPrivate = false } = t.fail('Failed to setup hypertele client') }) + setupClient.stdout.on('data', (data) => { + if (DEBUG_LOG) console.debug(data.toString()) + }) + const clientPort = await new Promise(resolve => { setupClient.stdout.on('data', (data) => { const msg = data.toString() @@ -158,3 +242,15 @@ async function request (port, { msTimeout = 500 } = {}) { }) }) } + +async function setupKeyPairFile (t) { + const password = `dummy-pass-${Math.random().toString().slice(2)}` + + const keysDir = await tmp(t) + const keyFile = path.join(keysDir, 'test-key') + await SecureKey.generate(keyFile, { password: b4a.from(password) }) + return { + keyFile, + password + } +}