170 lines
4.9 KiB
JavaScript
170 lines
4.9 KiB
JavaScript
"use strict";
|
|
|
|
import crypto from 'crypto';
|
|
import net from 'net';
|
|
import { EventEmitter, once } from 'events';
|
|
|
|
import log from 'log';
|
|
const l = log.get("connection");
|
|
|
|
import settings from '../../settings.mjs';
|
|
import rekey from './rekey.mjs';
|
|
import FramedTransport from './FramedTransport.mjs';
|
|
import { write_safe } from '../io/StreamHelpers.mjs';
|
|
import { encrypt_bytes, decrypt_bytes } from '../crypto/secretbox.mjs';
|
|
|
|
/**
|
|
* Represents a connection to a single endpoint.
|
|
* @param {string} secret_join The shared join secret, encoded as base64
|
|
* @param {net.Socket?} socket Optional. A pre-existing socket to take over and manage.
|
|
*/
|
|
class Connection extends EventEmitter {
|
|
/**
|
|
* Whether this socket is actually connected or not.
|
|
* @return {bool}
|
|
*/
|
|
get connected() {
|
|
return this.socket === null ? true : this.socket.destroyed;
|
|
}
|
|
|
|
constructor(secret_join, socket = null) {
|
|
super();
|
|
|
|
if(typeof secret_join !== "string")
|
|
throw new Error(`Error: Expected secret_join to be of type string, but received variable of type ${typeof secret_join}`);
|
|
|
|
this.socket = socket;
|
|
|
|
this.rekey_last = null;
|
|
this.rekey_interval_base = 30 * 60 * 1000; // 30 minutes
|
|
this.rekey_interval = this.rekey_interval_base + crypto.randomInt(0, 15 * 60 * 1000);
|
|
this.rekey_in_progress = false;
|
|
|
|
this.session_key = Buffer.from(secret_join, "base64");
|
|
}
|
|
|
|
/**
|
|
* Connects to a peer and initialises a secure TCP connection thereto.
|
|
* @param {string} address The address to connect to.
|
|
* @param {string} port The TCP port to connect to.
|
|
* @return {net.Socket} A socket setup for secure communication.
|
|
*/
|
|
async connect(address, port) {
|
|
this.address = address; this.port = port;
|
|
this.socket = new net.Socket();
|
|
this.socket.connect({
|
|
address, port
|
|
});
|
|
this.socket.once("end", () => {
|
|
l.notice(`${this.address}:${this.port} disconnected`);
|
|
});
|
|
await once(this.socket, "connect");
|
|
|
|
await this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.address = this.socket.remoteAddress;
|
|
this.port = this.socket.remotePort;
|
|
this.socket.setKeepAlive(true);
|
|
|
|
this.framer = new FramedTransport(this.socket);
|
|
this.framer.on("frame", this.handle_frame.bind(this));
|
|
|
|
await this.rekey();
|
|
}
|
|
|
|
async rekey() {
|
|
try {
|
|
this.rekey_in_progress = true;
|
|
this.session_key = await rekey(this, this.session_key);
|
|
this.rekey_interval = this.rekey_interval_base + crypto.randomInt(0, 15 * 60 * 1000);
|
|
this.rekey_last = new Date();
|
|
this.emit("rekey");
|
|
}
|
|
catch(error) {
|
|
l.warn(`Error when rekeying connection ${this.address}:${this.port}, killing connection`, settings.cli.verbose ? error : error.message);
|
|
await this.destroy();
|
|
}
|
|
finally {
|
|
this.rekey_in_progress = false;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
l.info(`Killing connection to ${this.address}:${this.port}`, new Error().stack);
|
|
if(this.framer instanceof FramedTransport)
|
|
await this.framer.destroy();
|
|
else {
|
|
await this.socket.end();
|
|
await this.socket.destroy();
|
|
}
|
|
this.emit("destroy");
|
|
}
|
|
|
|
async handle_frame(bytes) {
|
|
try {
|
|
l.info(`FRAME length`, bytes.length, `frame`, bytes);
|
|
let decrypted = decrypt_bytes(this.session_key, bytes);
|
|
if(decrypted === null) {
|
|
l.warn(`Decryption of message failed`);
|
|
return;
|
|
}
|
|
await this.handle_message(decrypted.toString("utf-8"));
|
|
}
|
|
catch(error) {
|
|
l.warn(`Warning: Killing connection to ${this.address}:${this.port} after error:`, settings.cli.verbose ? error : error.message);
|
|
this.destroy();
|
|
}
|
|
}
|
|
|
|
async handle_message(msg_text) {
|
|
const msg = JSON.parse(msg_text);
|
|
|
|
l.info(`RECEIVE:${msg.event}`, msg.message);
|
|
|
|
if(msg.event == "rekey" && !this.rekey_in_progress) {
|
|
// Set and forget here
|
|
this.rekey();
|
|
}
|
|
this.emit("message", msg.event, msg.message);
|
|
this.emit(`message-${msg.event}`, msg.message);
|
|
}
|
|
|
|
async send(event, message) {
|
|
if(typeof event !== "string") throw new Error(`Error: Expected string for event name, but got value of type ${typeof event}.`);
|
|
|
|
l.info(`SEND event`, event, `message`, message);
|
|
|
|
// Rekey at semi-regular intervals, but only if we're not already in the process of doing so
|
|
if(new Date() - this.rekey_last > this.rekey_interval && !this.rekey_in_progress)
|
|
await this.rekey();
|
|
|
|
// TODO: Consider anonymous TLS, with jpake for mututal authentication
|
|
// TODO: Consider https://devdocs.io/node/crypto#crypto.createCipheriv() - which lets us use any openssl ciphers we like - e.g. ChaCha20-Poly1305
|
|
let payload = JSON.stringify({ event, message });
|
|
payload = encrypt_bytes(
|
|
this.session_key,
|
|
Buffer.from(payload, "utf-8")
|
|
);
|
|
|
|
return await this.framer.write(payload);
|
|
}
|
|
}
|
|
|
|
Connection.Wrap = async function(secret_join, socket) {
|
|
const socket_wrap = new Connection(secret_join, socket);
|
|
await socket_wrap.init();
|
|
|
|
return socket_wrap;
|
|
}
|
|
|
|
Connection.Create = async function(secret_join, address, port) {
|
|
const socket = new Connection(secret_join);
|
|
await socket.connect(address, port);
|
|
|
|
return socket;
|
|
}
|
|
|
|
export default Connection;
|