diff --git a/package-lock.json b/package-lock.json index 249a96b1..3e6ffe02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11704,6 +11704,10 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keygen": { + "resolved": "tools/keygen", + "link": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -16622,6 +16626,7 @@ "string-length": "^6.0.0", "svgo": "^3.0.2", "tiktoken": "^1.0.11", + "tweetnacl": "^1.0.3", "ua-parser-js": "^1.0.38", "uglify-js": "^3.17.4", "uuid": "^9.0.0", @@ -16640,6 +16645,12 @@ "typescript": "^5.1.6" } }, + "src/backend/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "src/contextlink": { "version": "0.0.0", "license": "AGPL-3.0-only", @@ -16914,6 +16925,10 @@ "handlebars": "^4.7.8" } }, + "tools/keygen": { + "version": "1.0.0", + "license": "AGPL-3.0-only" + }, "tools/license-headers": { "version": "1.0.0", "license": "AGPL-3.0-only", diff --git a/src/backend/package.json b/src/backend/package.json index 2c3c4d19..632b93e6 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -68,6 +68,7 @@ "string-length": "^6.0.0", "svgo": "^3.0.2", "tiktoken": "^1.0.11", + "tweetnacl": "^1.0.3", "ua-parser-js": "^1.0.38", "uglify-js": "^3.17.4", "uuid": "^9.0.0", diff --git a/src/backend/src/services/BroadcastService.js b/src/backend/src/services/BroadcastService.js index 45a468a5..597228d1 100644 --- a/src/backend/src/services/BroadcastService.js +++ b/src/backend/src/services/BroadcastService.js @@ -21,12 +21,81 @@ const { Endpoint } = require("../util/expressutil"); const { UserActorType } = require("./auth/Actor"); const BaseService = require("./BaseService"); +class KeyPairHelper extends AdvancedBase { + static MODULES = { + tweetnacl: require('tweetnacl'), + }; + + constructor ({ + kpublic, + ksecret, + }) { + super(); + this.kpublic = kpublic; + this.ksecret = ksecret; + this.nonce_ = 0; + } + + to_nacl_key_ (key) { + console.log('WUT', key); + const full_buffer = Buffer.from(key, 'base64'); + + // Remove version byte (assumed to be 0x31 and ignored for now) + const buffer = full_buffer.slice(1); + + return new Uint8Array(buffer); + } + + get naclSecret () { + return this.naclSecret_ ?? ( + this.naclSecret_ = this.to_nacl_key_(this.ksecret)); + } + get naclPublic () { + return this.naclPublic_ ?? ( + this.naclPublic_ = this.to_nacl_key_(this.kpublic)); + } + + write (text) { + const require = this.require; + const nacl = require('tweetnacl'); + + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const message = {}; + + const textUint8 = new Uint8Array(Buffer.from(text, 'utf-8')); + const encryptedText = nacl.box( + textUint8, nonce, + this.naclPublic, this.naclSecret + ); + message.text = Buffer.from(encryptedText); + message.nonce = Buffer.from(nonce); + + return message; + } + + read (message) { + const require = this.require; + const nacl = require('tweetnacl'); + + const arr = nacl.box.open( + new Uint8Array(message.text), + new Uint8Array(message.nonce), + this.naclPublic, + this.naclSecret, + ); + + return Buffer.from(arr).toString('utf-8'); + } +} + class Peer extends AdvancedBase { + static AUTHENTICATING = Symbol('AUTHENTICATING'); static ONLINE = Symbol('ONLINE'); static OFFLINE = Symbol('OFFLINE'); static MODULES = { sioclient: require('socket.io-client'), + crypto: require('crypto'), }; constructor (svc_broadcast, config) { @@ -38,7 +107,23 @@ class Peer extends AdvancedBase { send (data) { if ( ! this.socket ) return; - this.socket.send(data) + const require = this.require; + const crypto = require('crypto'); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + this.aesKey, + iv, + ); + const jsonified = JSON.stringify(data); + let buffers = []; + buffers.push(cipher.update(Buffer.from(jsonified, 'utf-8'))); + buffers.push(cipher.final()); + const buffer = Buffer.concat(buffers); + this.socket.send({ + iv, + message: buffer, + }); } get state () { @@ -66,6 +151,22 @@ class Peer extends AdvancedBase { this.log.info(`connected`, { address: this.config.address }); + + const require = this.require; + const crypto = require('crypto'); + this.aesKey = crypto.randomBytes(32); + + const kp_helper = new KeyPairHelper({ + kpublic: this.config.key, + ksecret: this.svc_broadcast.config.keys.secret, + }); + socket.send({ + $: 'take-my-key', + key: this.svc_broadcast.config.keys.public, + message: kp_helper.write( + this.aesKey.toString('base64') + ), + }); }); socket.on('disconnect', () => { this.log.info(`disconnected`, { @@ -88,6 +189,89 @@ class Peer extends AdvancedBase { } } +class Connection extends AdvancedBase { + static MODULES = { + crypto: require('crypto'), + } + + static AUTHENTICATING = { + on_message (data) { + if ( data.$ !== 'take-my-key' ) { + this.disconnect(); + return; + } + + const hasKey = this.svc_broadcast.trustedPublicKeys_[data.key]; + if ( ! hasKey ) { + this.disconnect(); + return; + } + + const is_trusted = + this.svc_broadcast.trustedPublicKeys_ + .hasOwnProperty(data.key) + if ( ! is_trusted ) { + this.disconnect(); + return; + } + + const kp_helper = new KeyPairHelper({ + kpublic: data.key, + ksecret: this.svc_broadcast.config.keys.secret, + }); + + const message = kp_helper.read(data.message); + this.aesKey = Buffer.from(message, 'base64'); + + this.state = this.constructor.ONLINE; + } + } + static ONLINE = { + on_message (data) { + if ( ! this.on_message ) return; + + const require = this.require; + const crypto = require('crypto'); + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + this.aesKey, + data.iv, + ) + const buffers = []; + buffers.push(decipher.update(data.message)); + buffers.push(decipher.final()); + + const rawjson = Buffer.concat(buffers).toString('utf-8'); + + const output = JSON.parse(rawjson); + + this.on_message(output); + } + } + static OFFLINE = { + on_message () { + throw new Error('unexpected message'); + } + } + + constructor (svc_broadcast, socket) { + super(); + this.state = this.constructor.AUTHENTICATING; + this.svc_broadcast = svc_broadcast; + this.log = this.svc_broadcast.log; + this.socket = socket; + + socket.on('message', data => { + this.state.on_message.call(this, data); + }); + } + + disconnect () { + this.socket.disconnect(true); + this.state = this.constructor.OFFLINE; + } +} + class BroadcastService extends BaseService { static MODULES = { express: require('express'), @@ -96,16 +280,21 @@ class BroadcastService extends BaseService { _construct () { this.peers_ = []; + this.connections_ = []; + this.trustedPublicKeys_ = {}; } async _init () { const peers = this.config.peers ?? []; for ( const peer_config of peers ) { + this.trustedPublicKeys_[peer_config.key] = true; const peer = new Peer(this, peer_config); this.peers_.push(peer); peer.connect(); } + this._register_commands(this.services.get('commands')); + const svc_event = this.services.get('event'); svc_event.on('outer.*', this.on_event.bind(this)); } @@ -131,15 +320,24 @@ class BroadcastService extends BaseService { }); io.on('connection', async socket => { - socket.on('message', ({ key, data, meta }) => { + const conn = new Connection(this, socket); + this.connections_.push(conn); + + conn.on_message = ({ key, data, meta }) => { if ( meta.from_outside ) { this.log.noticeme('possible over-sending'); return; } + if ( key === 'test' ) { + this.log.noticeme(`test message: ` + + JSON.stringify(data) + ); + } + meta.from_outside = true; svc_event.emit(key, data, meta); - }); + }; }); @@ -147,6 +345,20 @@ class BroadcastService extends BaseService { require('node:util').inspect(this.config) ); } + + _register_commands (commands) { + commands.registerCommands('broadcast', [ + { + id: 'test', + description: 'send a test message', + handler: async (args, ctx) => { + this.on_event('test', { + contents: 'I am a test message', + }, {}) + } + } + ]) + } } module.exports = { BroadcastService }; diff --git a/src/backend/src/services/runtime-analysis/AlarmService.js b/src/backend/src/services/runtime-analysis/AlarmService.js index 177ca2ce..718314b7 100644 --- a/src/backend/src/services/runtime-analysis/AlarmService.js +++ b/src/backend/src/services/runtime-analysis/AlarmService.js @@ -252,7 +252,7 @@ class AlarmService extends BaseService { svc_devConsole.add_widget(this.alarm_widget); } - const args = Context.get('args'); + const args = Context.get('args') ?? {}; if ( args['quit-on-alarm'] ) { const svc_shutdown = this.services.get('shutdown'); svc_shutdown.shutdown({ diff --git a/tools/keygen/gen-peer-keys.js b/tools/keygen/gen-peer-keys.js new file mode 100644 index 00000000..78ba9ed3 --- /dev/null +++ b/tools/keygen/gen-peer-keys.js @@ -0,0 +1,19 @@ +const nacl = require('tweetnacl'); + +const pair = nacl.box.keyPair(); + +const format_key = key => { + const version = new Uint8Array([0x31]); + const buffer = Buffer.concat([ + Buffer.from(version), + Buffer.from(key), + ]); + return buffer.toString('base64'); +}; + +console.log(JSON.stringify({ + keys: { + public: format_key(pair.publicKey), + secret: format_key(pair.secretKey), + }, +}, undefined, ' ')); diff --git a/tools/keygen/package.json b/tools/keygen/package.json new file mode 100644 index 00000000..d44fccaa --- /dev/null +++ b/tools/keygen/package.json @@ -0,0 +1,12 @@ +{ + "name": "keygen", + "version": "1.0.0", + "main": "gen-peer-keys.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0-only", + "description": "" +} diff --git a/tools/run-selfhosted.js b/tools/run-selfhosted.js index e5cdb57b..8c4cb178 100644 --- a/tools/run-selfhosted.js +++ b/tools/run-selfhosted.js @@ -96,7 +96,7 @@ const main = async () => { k.add_module(new LocalDiskStorageModule()); k.add_module(new SelfHostedModule()); k.add_module(new TestDriversModule()); - k.add_module(new PuterAIModule()); + // k.add_module(new PuterAIModule()); k.boot(); };