├── img └── wut-screen.png ├── lib ├── constants.js ├── logger.js ├── config.js ├── network.js ├── keys.spec.js ├── messages.js ├── dm-ui.js ├── keys.js └── main-ui.js ├── package.json ├── LICENSE ├── signal-server ├── README.md ├── demoId.json ├── p2p.js └── server.js ├── README.md ├── .gitignore ├── p2p.js └── main.js /img/wut-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daviddahl/wut/HEAD/img/wut-screen.png -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | const TAB = 'tab'; 2 | const RETURN = 'return'; 3 | const BACKSPACE = 'backspace'; 4 | const SCROLL_UP = '\u001bOA'; 5 | const SCROLL_DOWN = '\u001bOB'; 6 | const UP = 'up'; 7 | const DOWN = 'down'; 8 | 9 | 10 | module.exports = { 11 | TAB: TAB, 12 | RETURN: RETURN, 13 | BACKSPACE: BACKSPACE, 14 | SCROLL_UP: SCROLL_UP, 15 | SCROLL_DOWN: SCROLL_DOWN, 16 | UP: UP, 17 | DOWN: DOWN, 18 | } 19 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | const logger = winston.createLogger({ 4 | level: 'info', 5 | format: winston.format.json(), 6 | defaultMeta: { service: 'wut' }, 7 | transports: [ 8 | // 9 | // - Write all logs with level `error` and below to `error.log` 10 | // - Write all logs with level `info` and below to `combined.log` 11 | // 12 | new winston.transports.File({ filename: '/tmp/error.log', level: 'error' }), 13 | new winston.transports.File({ filename: '/tmp/combined.log' }), 14 | ], 15 | }); 16 | 17 | module.exports = { 18 | logger: logger 19 | }; 20 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | 3 | const APP_TITLE = '¿wut?'; 4 | const DEFAULT_TOPIC = '__wut__chat__'; 5 | const PEER_REFRESH_MS = 7000; 6 | const STRING = 'string'; 7 | const HOME_DIR = os.homedir(); 8 | const WUT_HOME = `${HOME_DIR}/.wut`; 9 | const SK_STORAGE_PATH = `${WUT_HOME}/keypair`; 10 | const PK_STORAGE_PATH = `${WUT_HOME}/key.pub`; 11 | 12 | module.exports = { 13 | APP_TITLE: APP_TITLE, 14 | DEFAULT_TOPIC: DEFAULT_TOPIC, 15 | PEER_REFRESH_MS: PEER_REFRESH_MS, 16 | STRING: STRING, 17 | HOME_DIR: HOME_DIR, 18 | WUT_HOME: WUT_HOME, 19 | SK_STORAGE_PATH: SK_STORAGE_PATH, 20 | PK_STORAGE_PATH: PK_STORAGE_PATH, 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wut", 3 | "description": "Chatting via IPFS, TweetNacl and Blessed", 4 | "main": "main.js", 5 | "private": true, 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "author": "David Dahl ", 10 | "license": "MIT", 11 | "dependencies": { 12 | "blessed": "^0.1.81", 13 | "ipfs": "^0.44.0", 14 | "ipfs-pubsub-room": "^2.0.1", 15 | "it-all": "^1.0.1", 16 | "libp2p": "^0.27.8", 17 | "libp2p-pubsub": "^0.4.4", 18 | "libp2p-gossipsub": "^0.3.0", 19 | "libp2p-mplex": "^0.9.5", 20 | "libp2p-secio": "^0.12.5", 21 | "libp2p-tcp": "^0.14.5", 22 | "libp2p-webrtc-star": "^0.17.10", 23 | "node-notifier": "^7.0.1", 24 | "peer-info": "^0.17.5", 25 | "tweetnacl": "^1.0.3", 26 | "tweetnacl-util": "^0.15.1", 27 | "uuid": "^8.1.0", 28 | "winston": "^3.2.1", 29 | "wrtc": "^0.4.5" 30 | }, 31 | "devDependencies": { 32 | "jest": "^26.0.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/network.js: -------------------------------------------------------------------------------- 1 | const PROFILE_MSG = 'profile'; 2 | 3 | const EventEmitter = require('events'); 4 | const { Buffer } = require('buffer'); 5 | 6 | const { logger } = require('./logger'); 7 | const { DEFAULT_TOPIC } = require('./config'); 8 | 9 | 10 | 11 | class Network { 12 | 13 | constructor (configuration, nodeId, room) { 14 | this.configuration = configuration; 15 | this.nodeId = nodeId; 16 | this.room = room; 17 | } 18 | 19 | broadcastProfile (cid) { 20 | let profile = JSON.stringify({ 21 | messageType: PROFILE_MSG, 22 | handle: this.configuration.handle.trim(), 23 | publicKey: this.configuration.keyPair.publicKey, 24 | bio: this.configuration.bio, 25 | id: this.nodeId.id, 26 | }); 27 | 28 | if (cid) { 29 | this.room.sendTo(cid, profile); 30 | } else { 31 | this.room.broadcast(profile); 32 | } 33 | } 34 | 35 | getPeers () { 36 | return this.room.getPeers(); 37 | } 38 | } 39 | 40 | 41 | module.exports = { 42 | Network: Network, 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Dahl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /signal-server/README.md: -------------------------------------------------------------------------------- 1 | # ¿wut? signaling server 2 | 3 | This is a wrapper around `libp2p-webrtc-star` and `libp2p`. 4 | 5 | ## Configuration 6 | 7 | You must create a `PeerInfo` to run this server with. You'll need a peerInfo object as JSON: 8 | 9 | ``` 10 | // create PeerInfo object 11 | 12 | let peerInfo = await PeerInfo.create() 13 | 14 | let idJSON = peerInfo.id.toJSON() 15 | 16 | ``` 17 | 18 | Save `idJSON` and add this data to your environment as it will be consumed by the server thusly: 19 | 20 | ```js 21 | const idJSON = { 22 | id: process.env.WUT_SIGNAL_SERVER_CID, 23 | privKey: process.env.WUT_SIGNAL_SERVER_PRIV_KEY, 24 | pubKey: process.env.WUT_SIGNAL_SERVER_PUB_KEY, 25 | } 26 | 27 | ``` 28 | 29 | (See `./demoId.json` for an example JSON'd PeerInfo.id) 30 | 31 | ```bash 32 | # Configure your environment like so: 33 | 34 | export WUT_SIGNAL_SERVER_CID='Qmfoo123456...etc' 35 | export WUT_SIGNAL_SERVER_PRIV_KEY='CAASpwkwggSjAgEAAoIBAQDKNKwPX4DJhYdGreAVaJy+efhIfbyczR0...etc' 36 | export WUT_SIGNAL_SERVER_PUB_KEY='CAASpgIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAo...etc' 37 | 38 | ``` 39 | 40 | ## Run the server on a publicly-available server 41 | 42 | `node signaling-server/server.js` 43 | 44 | ## Run the client 45 | 46 | `node main.js` 47 | 48 | The client requires env vars as well: 49 | 50 | ```bash 51 | export SIGNAL_SERVER_CID='Qmfoo123456...etc' 52 | export SIGNAL_SERVER_IP=199.x.x.x # ¿wut? signaling server IP address 53 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ¿wut? 2 | 3 | Serverless, IPFS, Tweetnacl-js-based chat application for terminal in an `ncurses` style 4 | 5 | ![](img/wut-screen.png) 6 | 7 | ## Goals 8 | 9 | ### Things to do, ranked 10 | 11 | * [ ] DM UI is broken and needs UX love 12 | * [ ] Tab-completion of peer names, commands 13 | * [ ] Tab / arrows to focus UI elements 14 | * [ ] Social Proofs, see: https://github.com/IBM/ipfs-social-proof 15 | * [ ] Tests 16 | * [ ] DMs List UI 17 | * [ ] Paste screenshots into chat 18 | * [ ] Keys / keychain persistence 19 | * [ ] Key stretching / BIP-39 password for keychain, configuration data. 20 | * [ ] Keybase-style UI layout 21 | * [ ] Encrypted file sharing via tweetnacl-js & IPFS file storage 22 | * [ ] SES-based plugins, (See: Secure ECMAScript https://github.com/Agoric/SES-shim ) 23 | * [ ] Group encrypted chat 24 | * [ ] Child_process for IPFS, workers for crypto? Discuss. 25 | * [ ] Emojis 26 | * [ ] Encrypted message persistence in IPFS via OrbitDB, js-threads? 27 | 28 | ### Fixed 29 | 30 | * [x] `Major issue`: Make pubsub work outside local networks: Works now via webrtc-star server as bootstrap node 31 | * [x] Serverless 'lobby' chat multiple participants 32 | * [x] Serverless E2E encrypted chat for 2 participants (at first) 33 | * [x] As nerdy as possbile, hence the `ncurses` style 34 | 35 | 36 | ## Install 37 | 38 | Requirements: node 12, yarn 39 | 40 | See signaling server and client configuration [README.md](signal-server/README.md) first: 41 | 42 | ```bash 43 | npm install -g yarn 44 | 45 | git clone git@github.com:daviddahl/wut.git 46 | 47 | cd wut 48 | 49 | yarn install 50 | 51 | node main.js 52 | 53 | ``` 54 | 55 | ## Testing 56 | 57 | `yarn test` 58 | -------------------------------------------------------------------------------- /lib/keys.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | Keys, 3 | convertObjectToUint8, 4 | PUB_KEY, 5 | SEC_KEY, 6 | NONCE, 7 | STRING, 8 | OBJECT 9 | } = require("./keys"); 10 | 11 | describe("keys.js", () => { 12 | describe("convertObjectToUint8", () => { 13 | it("throws an error if obj is falsy", () => { 14 | expect(() => { 15 | convertObjectToUint8(null); 16 | }).toThrow("Arg size required!"); 17 | }); 18 | 19 | it("returns the obj by reference if the obj is a Uint8Array", () => { 20 | const objectInput = new Uint8Array(); 21 | const result = convertObjectToUint8(objectInput); 22 | 23 | expect(result).toBe(objectInput); 24 | }); 25 | 26 | it("throws and error if object length is zero", () => { 27 | expect(() => { 28 | convertObjectToUint8({}); 29 | }).toThrow("Error: variable len must be > 0"); 30 | }); 31 | 32 | it("returns the obj as a Uint8Array of copied object properties with the length of said type", () => { 33 | const mock_nonce = { ...[...Array(24).keys()] }; 34 | 35 | const result = convertObjectToUint8(mock_nonce, NONCE); 36 | 37 | result.forEach((val, index) => { 38 | expect(result[index]).toBe(mock_nonce[index]); 39 | }); 40 | }); 41 | 42 | it("returns the obj as a Uint8Array of copied object properties with the length of said type, with 0 being set in the empty buffer", () => { 43 | const mock_nonce_not_full = { ...[...Array(12).keys()] }; 44 | 45 | const result = convertObjectToUint8(mock_nonce_not_full, NONCE); 46 | 47 | result.forEach((val, index) => { 48 | expect(result[index]).toBe(mock_nonce_not_full[index] || 0); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /signal-server/demoId.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "QmWjz6xb8v9K4KnYEwP5Yk75k5mMBCehzWFLCvvQpYxF3d", 3 | "privKey": "CAASpwkwggSjAgEAAoIBAQDKNKwPX4DJhYdGreAVaJy+efhIfbyczR0Mfyi/JfzszY9INH83Veo2s/yOKv+YOP4y7OWpkXL5G6K8fLgxwq5gtTc78W07uz5ZUrxfOT0R4QJuiiQHjQSxYKw08yLIP9JaR2ztL46DOO/Nvzl9gCWHGsAb+w+RLWa0R0SRyvaDiw8aZW9G70yYTGF/SPkEoYN26sioVDwppmKxZ9mTuKsujG0AGAMVPnmjhDI5WmBD3gnOiqCECqlgxl29Qlc1fCIbojcUVE9eWFWassFLicGdo/iMacsVvoTav9JvHZsMvg1HXeK0khQWluCUfdcR6coijDMDWBa77dTI6+b2ybZXAgMBAAECggEALk4hmOOl+oA5mlX3Gu/59SS5VuB0cPQH0vTLv/pTEWeBiGd9Oo7SM/TDwUrXfWSP0dmuPkawrZtGiSOGit6qUDsviuqeuS8H+CyaNrRE5/M/O1EnLxN8H6KjzPxg2rrC0SnKKAbb+/Dt+Y/w+mx+K5JUrBOyXOyouGAZs8lm6nhlL4nelNh2hez0Rp9RFlCokk8aldHCJVUbUP3AwOtVqYJNttSofq4jvnXvUX8Kgb9WjGaZANoQNH+zn6rM2OvmDcxQvnxbKtAgBEu7O60kAdGtpw+JGvj1E5f+iuNlK+GYvYbpDhSt1bRfTMsHxRFxJ2V7vDSDqTLdxUfahI+WAQKBgQD6PUBSYOY151h3iBHqqJJ4sYQ/rUbs3/9QDZRKsxj/gC0vZFQHTVfLHiY2qUikjoMnTy+t1L/ot919ZM5XqOwjZ3oodtjRa3orWKUwGQElORxZQPCPz268GIU+DKSyE5ieBqGMdB3uOZ7oHKODc1a9HiDApux8C3Vwde0oMZcp3wKBgQDO3Feipt7dZ8AoZ1MJE/pRrhJBZDBhc9TpmQccRfG1JpgocA7GgRnzFQgM5yi6DrSIx3SCqPZm5K5VKqEW9PEsHbyNEPo8U0oOnhmVcBIrJe8Rf+wg5R3WvIwlh454ROchNl7iuJPgXTQzZjWtaKbeMm4fXTweRr0Mk9q5GaFyiQKBgC6tuE7llmvdsMnzTuxH77Kl4naCWyWajySes9fPWs1mWodpnqcSDVttT1GI+G0BzINLqSgy9G1zxtQ6NqdxckMUbVwY907xToPBcGbtcyI/agNYMseQuSZLKKevchVpxGFN+Vqa2m5yvyqrFPFTVY3HjfKB8MEe3hRRWyDRR1JfAoGAJV/4UYH26GfzdxlcDlrWsmVSFRCGEUV9ZYtplnkot8M2YLAGa2UuDBZzsukdGajIg6IN8gGXK3YL7YVbP6uX25Gv3IkBvV6LFeMI2lA6aCNdc3r6beMXphHA/JLmceJ5JC4PrMUOqs4MPXEtJ5yt8Z2I+g+9afb790bLkQAJhIkCgYEAzyYCF47U+csbRzGb/lszRwg1QvGtTvzQWuNAcAKclCuN7xplJJ+DUyvVA00WCz/z8MMa/PK8nB0KoUDfuFvo8bbNEAPcGK0l/He7+hF4wdm4S8fX22up5GgJUdV/dv8KZdE2U7yIU/i8BKw6Z3vJB7RB900yfjt56VlgsKspAB0=", 4 | "pubKey": "CAASpgIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDKNKwPX4DJhYdGreAVaJy+efhIfbyczR0Mfyi/JfzszY9INH83Veo2s/yOKv+YOP4y7OWpkXL5G6K8fLgxwq5gtTc78W07uz5ZUrxfOT0R4QJuiiQHjQSxYKw08yLIP9JaR2ztL46DOO/Nvzl9gCWHGsAb+w+RLWa0R0SRyvaDiw8aZW9G70yYTGF/SPkEoYN26sioVDwppmKxZ9mTuKsujG0AGAMVPnmjhDI5WmBD3gnOiqCECqlgxl29Qlc1fCIbojcUVE9eWFWassFLicGdo/iMacsVvoTav9JvHZsMvg1HXeK0khQWluCUfdcR6coijDMDWBa77dTI6+b2ybZXAgMBAAE=" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | *~ 107 | 108 | #VS Code editor configs 109 | .vscode -------------------------------------------------------------------------------- /lib/messages.js: -------------------------------------------------------------------------------- 1 | const { logger } = require('./logger'); 2 | 3 | const { 4 | box, 5 | secretbox, 6 | randomBytes 7 | } = require('tweetnacl'); 8 | 9 | const { 10 | decodeUTF8, 11 | encodeUTF8, 12 | encodeBase64, 13 | decodeBase64 14 | } = require('tweetnacl-util'); 15 | 16 | const { Buffer } = require('buffer'); 17 | 18 | const { 19 | PUB_KEY, 20 | SEC_KEY, 21 | NONCE, 22 | OBJECT, 23 | STRING, 24 | convertObjectToUint8 25 | } = require('./keys'); 26 | 27 | 28 | const coerceMessage = (message) => { 29 | if (typeof message === OBJECT && !Array.isArray(message)) { 30 | return Buffer.from(JSON.stringify(message)); 31 | } else if (typeof message === STRING || Array.isArray(message)) { 32 | return message; 33 | } 34 | 35 | throw new Error(`Message format is ${typeof message}`); 36 | }; 37 | 38 | const openDirectMessage = (msg, configuration) => { 39 | logger.info('openDirectMessage()'); 40 | 41 | if (!msg.content) { 42 | throw new Error('Message content is null'); 43 | } 44 | 45 | let content = convertObjectToUint8(msg.content); 46 | let nonce = convertObjectToUint8(msg.nonce, NONCE); 47 | let pk = convertObjectToUint8(msg.authorPubKey, PUB_KEY); 48 | let sk = convertObjectToUint8(configuration.keyPair.secretKey, SEC_KEY); 49 | 50 | const openedMsg = box.open(content, nonce, pk, sk); 51 | 52 | let plaintext = null; 53 | 54 | if (openedMsg) { 55 | plaintext = encodeUTF8(openedMsg); 56 | } 57 | 58 | return plaintext; 59 | }; 60 | 61 | const createDirectMessage = (profile, plaintext, storage) => { 62 | const DIRECT_MSG = 'dm'; 63 | // a DM will look like: 64 | // { 65 | // handle: , 66 | // toCID: , 67 | // fromCID: , 68 | // fromHandle: , 69 | // nonce: , 70 | // content: , 71 | // authorPubKey: , 72 | // } 73 | 74 | const nonce = randomBytes(box.nonceLength); 75 | let msg = decodeUTF8(plaintext); 76 | let boxed = box(msg, nonce, profile.publicKey, storage.configuration.keyPair.secretKey); 77 | 78 | let dm = { 79 | handle: profile.handle, 80 | toCID: profile.id, 81 | fromCID: storage.configuration.id, 82 | fromHandle: storage.configuration.handle, 83 | nonce: nonce, 84 | content: boxed, 85 | authorPubKey: storage.configuration.keyPair.publicKey, 86 | messageType: DIRECT_MSG, 87 | }; 88 | 89 | return coerceMessage(dm); 90 | 91 | }; 92 | 93 | module.exports = { 94 | openDirectMessage: openDirectMessage, 95 | createDirectMessage: createDirectMessage, 96 | }; 97 | -------------------------------------------------------------------------------- /p2p.js: -------------------------------------------------------------------------------- 1 | const TCP = require('libp2p-tcp'); 2 | const Websockets = require('libp2p-websockets'); 3 | const PeerInfo = require('peer-info') 4 | const Libp2p = require('libp2p') 5 | const WebRTCStar = require('libp2p-webrtc-star') 6 | const MPLEX = require('libp2p-mplex') 7 | const SECIO = require('libp2p-secio') 8 | const wrtc = require('wrtc') 9 | const GossipSub = require('libp2p-gossipsub') 10 | const MulticastDNS = require('libp2p-mdns') 11 | const Bootstrap = require('libp2p-bootstrap') 12 | const DHT = require('libp2p-kad-dht') 13 | 14 | const transportKey = WebRTCStar.prototype[Symbol.toStringTag] 15 | debugger; 16 | 17 | // FINE GRAIN CONFIG OPTIONS 18 | const MDNS_INTERVAL_MS = 5000 // TODO: make this configurable via env vars or db records 19 | const CONNECTION_MGR_POLL_MS = 5000 20 | const RELAY_ENABLED = true 21 | const HOP_ENABLED = true 22 | const DHT_ENABLED = false 23 | const RANDOM_WALK_ENABLED = false 24 | const PUBSUB_ENABLED = true 25 | const METRICS_ENABLED = true 26 | 27 | const signalServerIP = () => { 28 | if (!process.env.SIGNAL_SERVER_IP) { 29 | throw new Error('process.env.SIGNAL_SERVER_IP required: without a signaling server, p2p peer discovery will not work'); 30 | } 31 | return process.env.SIGNAL_SERVER_IP 32 | }; 33 | 34 | const signalServerCID = () => { 35 | if (!process.env.SIGNAL_SERVER_CID) { 36 | throw new Error('process.env.SIGNAL_SERVER_CID required: without the signaling server CID, peer discovery will not work'); 37 | } 38 | return process.env.SIGNAL_SERVER_CID 39 | }; 40 | 41 | const signalServerPort = '15555' 42 | 43 | const ssAddr = `/ip4/${signalServerIP()}/tcp/${signalServerPort}/ws/p2p-webrtc-star`; 44 | 45 | const bootstrapSignalingServerMultiAddr = 46 | `/ip4/${signalServerIP()}/tcp/63785/ipfs/${signalServerCID()}`; 47 | 48 | const getPeerInfo = async () => { 49 | return PeerInfo.create() 50 | } 51 | 52 | const libp2pBundle = async (opts) => { 53 | // TODO: use opts to make things more configurable 54 | const peerInfo = await getPeerInfo() 55 | 56 | return new Libp2p({ 57 | peerInfo, 58 | modules: { 59 | transport: [ WebRTCStar, TCP, Websockets ], 60 | streamMuxer: [MPLEX], 61 | connEncryption: [SECIO], 62 | pubsub: GossipSub, 63 | peerDiscovery: [ 64 | MulticastDNS, 65 | Bootstrap, 66 | ] 67 | }, 68 | config: { 69 | relay: { // Circuit Relay options (this config is part of libp2p core configurations) 70 | enabled: true, // Allows you to dial and accept relayed connections. Does not make you a relay. 71 | hop: { 72 | enabled: true, // Allows you to be a relay for other peers 73 | active: true // You will attempt to dial destination peers if you are not connected to them 74 | } 75 | }, 76 | EXPERIMENTAL: { 77 | pubsub: true 78 | }, 79 | Addresses: { 80 | swarm: [ 81 | `/ip4/${signalServerIP()}/tcp/63785/ipfs/QmczHEW3Pc2a4ZkFBUVLVv5QbucCFbc1ALwo8d5uzgmsio`, 82 | ], 83 | }, 84 | autoDial: true, // auto dial to peers we find when we have less peers than `connectionManager.minPeers` 85 | mdns: { 86 | interval: MDNS_INTERVAL_MS, 87 | enabled: true 88 | }, 89 | peerDiscovery: { 90 | webRTCStar: { 91 | enabled: true 92 | }, 93 | bootstrap: { 94 | interval: 60e3, 95 | enabled: true, 96 | list: [ 97 | `/ip4/${signalServerIP()}/tcp/63785/ipfs/QmczHEW3Pc2a4ZkFBUVLVv5QbucCFbc1ALwo8d5uzgmsio`, 98 | `/ip4/127.0.0.1/tcp/63785/ipfs/QmczHEW3Pc2a4ZkFBUVLVv5QbucCFbc1ALwo8d5uzgmsio`, 99 | ] 100 | }, 101 | }, 102 | transport: { 103 | [transportKey]: { 104 | wrtc 105 | }, 106 | }, 107 | transportManager: { 108 | addresses: [ 109 | '/ip4/0.0.0.0/tcp/9090/ws', 110 | '/ip4/0.0.0.0/tcp/0/ws', 111 | '/ip4/0.0.0.0/tcp/0/', 112 | ] 113 | }, 114 | pubsub: { 115 | enabled: true, 116 | emitSelf: true, 117 | signMessages: true, 118 | strictSigning: true, 119 | }, 120 | dht: { 121 | enabled: false, 122 | randomWalk: { 123 | enabled: false 124 | } 125 | }, 126 | } 127 | }) 128 | } 129 | 130 | module.exports = { 131 | libp2pBundle: libp2pBundle, 132 | signalServerCID: signalServerCID, 133 | signalServerIP: signalServerIP, 134 | signalServerPort: signalServerPort, 135 | ssAddr: ssAddr, 136 | } 137 | -------------------------------------------------------------------------------- /signal-server/p2p.js: -------------------------------------------------------------------------------- 1 | const TCP = require('libp2p-tcp'); 2 | const Websockets = require('libp2p-websockets'); 3 | const PeerInfo = require('peer-info') 4 | const Libp2p = require('libp2p') 5 | const WebRTCStar = require('libp2p-webrtc-star') 6 | const MPLEX = require('libp2p-mplex') 7 | const SECIO = require('libp2p-secio') 8 | const wrtc = require('wrtc') 9 | const GossipSub = require('libp2p-gossipsub') 10 | const MulticastDNS = require('libp2p-mdns') 11 | const Bootstrap = require('libp2p-bootstrap') 12 | const DHT = require('libp2p-kad-dht') 13 | const SignalingServer = require('libp2p-webrtc-star/src/sig-server') 14 | const demoIdJson = require('./demoId.json') 15 | 16 | const transportKey = WebRTCStar.prototype[Symbol.toStringTag] 17 | 18 | let idJSON; 19 | 20 | try { 21 | idJSON = { 22 | id: process.env.WUT_SIGNAL_SERVER_CID, 23 | privKey: process.env.WUT_SIGNAL_SERVER_PRIV_KEY, 24 | pubKey: process.env.WUT_SIGNAL_SERVER_PUB_KEY 25 | } 26 | } catch (ex) { 27 | console.error(ex) 28 | idJSON = demoIdJson 29 | } 30 | 31 | 32 | let ssAddr; 33 | 34 | // FINE GRAIN CONFIG OPTIONS 35 | const MDNS_INTERVAL_MS = 5000 // TODO: make this configurable via env vars or db records 36 | const CONNECTION_MGR_POLL_MS = 5000 37 | const RELAY_ENABLED = true 38 | const HOP_ENABLED = true 39 | const DHT_ENABLED = true 40 | const RANDOM_WALK_ENABLED = true 41 | const PUBSUB_ENABLED = true 42 | const METRICS_ENABLED = true 43 | 44 | const signalServerIP = () => { 45 | if (!process.env.SIGNAL_SERVER_IP) { 46 | throw new Error('process.env.SIGNAL_SERVER_IP required: without a signaling server, p2p peer discovery will not work'); 47 | } 48 | return process.env.SIGNAL_SERVER_IP 49 | }; 50 | 51 | const signalServerCID = () => { 52 | if (!process.env.SIGNAL_SERVER_CID) { 53 | throw new Error('process.env.SIGNAL_SERVER_CID required: without the signaling server CID, peer discovery will not work'); 54 | } 55 | return process.env.SIGNAL_SERVER_CID 56 | }; 57 | 58 | const signalServerPort = '15555' 59 | 60 | const getPeerInfo = async () => { 61 | return PeerInfo.create(idJSON) 62 | } 63 | 64 | const libp2pBundle = async (opts) => { 65 | // TODO: use opts to make things more configurable 66 | const peerInfo = await getPeerInfo() 67 | 68 | // Wildcard listen on TCP and Websocket 69 | peerInfo.multiaddrs.add('/ip4/0.0.0.0/tcp/63785') 70 | peerInfo.multiaddrs.add('/ip4/0.0.0.0/tcp/63786/ws') 71 | 72 | const signalingServer = await SignalingServer.start({ 73 | port: 15555 74 | }) 75 | ssAddr = `/ip4/${signalingServer.info.host}/tcp/${signalingServer.info.port}/ws/p2p-webrtc-star` 76 | console.info(`Signaling server running at ${ssAddr}`) 77 | peerInfo.multiaddrs.add(`${ssAddr}/p2p/${peerInfo.id.toB58String()}`) 78 | 79 | debugger; 80 | 81 | return new Libp2p({ 82 | peerInfo, 83 | modules: { 84 | transport: [ WebRTCStar, TCP, Websockets ], 85 | streamMuxer: [ MPLEX ], 86 | connEncryption: [ SECIO ], 87 | pubsub: GossipSub, 88 | peerDiscovery: [ 89 | MulticastDNS, 90 | Bootstrap, 91 | ] 92 | }, 93 | config: { 94 | relay: { // Circuit Relay options (this config is part of libp2p core configurations) 95 | enabled: true, // Allows you to dial and accept relayed connections. Does not make you a relay. 96 | hop: { 97 | enabled: true, // Allows you to be a relay for other peers 98 | active: true // You will attempt to dial destination peers if you are not connected to them 99 | } 100 | }, 101 | EXPERIMENTAL: { 102 | pubsub: true 103 | }, 104 | autoDial: true, // auto dial to peers we find when we have less peers than `connectionManager.minPeers` 105 | mdns: { 106 | interval: MDNS_INTERVAL_MS, 107 | enabled: true 108 | }, 109 | peerDiscovery: { 110 | // webRTCStar: { 111 | // enabled: true 112 | // }, 113 | bootstrap: { 114 | interval: 60e3, 115 | enabled: true, 116 | list: [ 117 | `/ip4/127.0.0.1/tcp/63785/ipfs/${peerInfo.id.toB58String()}`, 118 | ] 119 | }, 120 | }, 121 | transport: { 122 | [transportKey]: { 123 | wrtc 124 | }, 125 | }, 126 | pubsub: { 127 | enabled: true, 128 | emitSelf: true, 129 | signMessages: true, 130 | strictSigning: true, 131 | }, 132 | dht: { 133 | enabled: false, 134 | randomWalk: { 135 | enabled: false 136 | } 137 | }, 138 | } 139 | }) 140 | } 141 | 142 | module.exports = { 143 | libp2pBundle: libp2pBundle, 144 | signalServerCID: signalServerCID, 145 | signalServerIP: signalServerIP, 146 | signalServerPort: signalServerPort, 147 | ssAddr: ssAddr, 148 | } 149 | -------------------------------------------------------------------------------- /lib/dm-ui.js: -------------------------------------------------------------------------------- 1 | const blessed = require('blessed') 2 | 3 | const { logger } = require('./logger') 4 | 5 | const { openDirectMessage, createDirectMessage } = require('./messages') 6 | 7 | const { 8 | TAB, 9 | RETURN, 10 | BACKSPACE, 11 | SCROLL_UP, 12 | SCROLL_DOWN, 13 | UP, 14 | DOWN 15 | } = require('./constants') 16 | 17 | const dmUI = (parent, profile, storage, network) => { 18 | // Note: parent === screen in mainUI obj 19 | if (!parent && !profile && !storage && !network) { 20 | throw new Error('Parent node, profile, config and network required'); 21 | } 22 | 23 | // TODO: Since the terminal styles are the same/similar between this and main-ui.js, do we want to consolidate some of the blessed Box, logs, textboxs, etc 24 | // to a constructor that takes common options that can be overriden? Would likely reduce the lines of code in main-ui and here which might help with readability 25 | 26 | const wrapper = blessed.Box({ 27 | label: ' ...DM... ', 28 | border: { 29 | type: 'line' 30 | }, 31 | name: profile.id, 32 | parent: parent, 33 | top: '25%', 34 | left: '25%', 35 | width: '50%', 36 | height: '60%', 37 | style: { 38 | fg: 'blue', 39 | bg: null, 40 | border: { 41 | fg: '#f0f0f0' 42 | }, 43 | focus: { 44 | border: { 45 | fg: 'blue', 46 | } 47 | } 48 | } 49 | }); 50 | 51 | let output = blessed.Log({ 52 | name: profile.id, 53 | parent: wrapper, 54 | scrollable: true, 55 | label: ` Chatting with ${profile.handle} `, 56 | top: 0, 57 | left: 0, 58 | width: '98%', 59 | height: '80%', 60 | tags: true, 61 | border: { 62 | type: 'line' 63 | }, 64 | style: { 65 | fg: 'blue', 66 | bg: null, 67 | border: { 68 | fg: 'red' 69 | } 70 | } 71 | }); 72 | 73 | const input = blessed.Textbox({ 74 | label: ' Enter a message: ', 75 | parent: wrapper, 76 | top: '80%', 77 | left: 0, 78 | width: '98%', 79 | height: 4, 80 | tags: true, 81 | border: { 82 | type: 'line' 83 | }, 84 | scrollbar: false, 85 | style: { 86 | fg: 'blue', 87 | bg: null, 88 | border: { 89 | fg: '#f0f0f0', 90 | bg: null, 91 | }, 92 | focus: { 93 | border: { 94 | fg: 'blue', 95 | bg: null, 96 | } 97 | }, 98 | } 99 | }); 100 | 101 | input.focus(); 102 | 103 | input.on('keypress', (value, key) => { 104 | 105 | // TODO: might be good to make these an enum like class? 106 | // Looks to be reused in main-ui.js 107 | 108 | // TODO: handle control-a, -x, etc 109 | 110 | // TODO: might be good to have some type of mixinable behavior or default behavior on 111 | // input that can be overriden. Only reason I think it might be worth exploring 112 | // is the TAB, BACKSPACE, UP, DOWN, and default cases are shared here and in main-ui.js. 113 | 114 | // However, we want to make sure we do it gracefully because this might be overengineering, and if we ulimately decide 115 | // against it I totally understand. If we go down that route, we probs want to make it as simple as possible to understand 116 | switch (key.name) { 117 | case TAB: 118 | input.focus(); 119 | return; 120 | case RETURN: 121 | let plaintext = input.getValue(); 122 | let boxedDM = createDirectMessage(profile, plaintext, storage); 123 | output.log(`${storage.configuration.handle}: ${plaintext}`); 124 | network.room.sendTo(profile.id, boxedDM); 125 | input.clearValue(); 126 | break; 127 | case BACKSPACE: 128 | let val = input.getValue(); 129 | if (val.length) { 130 | input.setValue(val.substring(0, (val.length -1))); 131 | } 132 | break; 133 | case UP: 134 | case DOWN: 135 | if ([SCROLL_UP, SCROLL_DOWN].includes(key.sequence)) { 136 | break; 137 | } 138 | default: 139 | input.setValue(`${input.value}${value}`); 140 | break; 141 | } 142 | 143 | parent.render(); 144 | }); 145 | 146 | input.key(['escape'], function(ch, key) { 147 | wrapper.label = '*** Closing DM ***'; 148 | // TODO: REMOVE THE REFERENCE TO THIS UI IN e2eMessages! 149 | for (let idx in parent.children) { 150 | if (parent.children[idx].name == 'main-input') { 151 | parent.children[idx].focus(); 152 | break; 153 | } 154 | } 155 | 156 | // TODO: broadcast a message that the DM was closed? 157 | // storage.e2eMessages[profile.id] = null; 158 | wrapper.destroy(); 159 | storage.e2eMessages[profile.id] = null; 160 | }); 161 | 162 | return { 163 | wrapper: wrapper, 164 | input: input, 165 | output: output, 166 | name: profile.id 167 | }; 168 | }; 169 | 170 | module.exports = { 171 | dmUI: dmUI, 172 | }; 173 | -------------------------------------------------------------------------------- /signal-server/server.js: -------------------------------------------------------------------------------- 1 | /* 2 | ¿wut? : a very simple terminal-based chat application runing on top of IPFS, tweetnacl-js & blessed 3 | 4 | Author: David Dahl 5 | */ 6 | 7 | 'use strict'; 8 | const PeerInfo = require('peer-info') 9 | const IPFS = require('ipfs'); 10 | const Room = require('ipfs-pubsub-room'); 11 | const wrtc = require('wrtc'); 12 | const WStar = require('libp2p-webrtc-star'); 13 | const TCP = require('libp2p-tcp'); 14 | 15 | const all = require('it-all'); 16 | const { v4: uuidv4 } = require('uuid'); 17 | const { box } = require('tweetnacl'); 18 | 19 | const { convertObjectToUint8 } = require('../lib/keys'); 20 | const { Network } = require('../lib/network'); 21 | const { logger } = require('../lib/logger'); 22 | const { 23 | DEFAULT_TOPIC, 24 | APP_TITLE, 25 | PEER_REFRESH_MS, 26 | HOME_DIR, 27 | } = require('../lib/config'); 28 | 29 | const { 30 | libp2pBundle, 31 | signalServerIP, 32 | signalServerPort, 33 | signalServerCID, 34 | ssAddr, 35 | } = require('./p2p'); 36 | 37 | var configuration = { 38 | handle: null, 39 | bio: 'Web 3.0 Enthusiast', 40 | homepage: 'https://github.com/daviddahl/wut', 41 | myTopic: uuidv4(), 42 | sharedKey: null, 43 | keyPair: null, 44 | peerProfiles: {}, 45 | }; 46 | 47 | const e2eMessages = {}; 48 | 49 | const storage = { 50 | e2eMessages: e2eMessages, 51 | configuration: configuration, 52 | topic: DEFAULT_TOPIC, 53 | }; 54 | 55 | async function main () { 56 | 57 | let ipfsRepoPath = `${HOME_DIR}/`; 58 | // create and expose main UI 59 | let _keyPair = box.keyPair(); 60 | let pk = convertObjectToUint8(_keyPair.publicKey); 61 | let sk = convertObjectToUint8(_keyPair.secretKey); 62 | 63 | configuration.keyPair = { publicKey: pk, secretKey: sk }; 64 | 65 | const p2p = await libp2pBundle() 66 | const nodeId = p2p.peerInfo.id._idB58String 67 | 68 | await p2p.start() 69 | 70 | const addrs = [ 71 | '/ip4/0.0.0.0/tcp/0', 72 | '/ip4/0.0.0.0/tcp/0/ws', 73 | '/ip4/0.0.0.0/tcp/9090/ws', 74 | '/ip4/127.0.0.1/tcp/0/ws', 75 | '/ip4/127.0.0.1/tcp/0' 76 | ] 77 | 78 | addrs.forEach((addr) => { 79 | p2p.peerInfo.multiaddrs.add(addr) 80 | }) 81 | 82 | const room = new Room(p2p, DEFAULT_TOPIC); 83 | const network = new Network(configuration, nodeId, room) 84 | 85 | // TODO: Display public key as QR CODE 86 | console.log(`Your NaCl public key is: \n ${configuration.keyPair.publicKey}\n`); 87 | 88 | console.log(p2p.peerInfo.multiaddrs) 89 | p2p.peerInfo.multiaddrs.forEach(ma => console.log(ma.toString())) 90 | 91 | console.log('P2P node is initialized!'); 92 | console.log('P2P Node Id:', nodeId); 93 | 94 | configuration.handle = nodeId; 95 | 96 | console.log('\n...........................................'); 97 | console.log('................... Welcome ...............'); 98 | console.log('................... To ....................'); 99 | console.log(`.................. ${APP_TITLE} ..................`); 100 | console.log('...........................................\n'); 101 | console.log('\n\n*** This is the LOBBY. It is *plaintext* group chat ***'); 102 | console.log('\n*** Type "/help" for help ***\n'); 103 | 104 | console.log(`\nP2P signal server: ${signalServerIP()}`); 105 | 106 | p2p.on('peer:connect', (peer) => { 107 | console.log('Connection established to:', peer.id.toB58String()) // Emitted when a peer has been found 108 | }) 109 | 110 | // Emitted when a peer has been found 111 | p2p.on('peer:discovery', (peer) => { 112 | console.log('Discovered:', peer.id.toB58String()) 113 | }) 114 | 115 | room.on('subscribed', () => { 116 | console.log(`Now connected to room: ${DEFAULT_TOPIC}`); 117 | }); 118 | 119 | room.on('peer joined', (peer) => { 120 | console.log(`Peer joined the room: ${peer}`); 121 | if (peer == nodeId) { 122 | if (!configuration.handle) { 123 | // set default for now 124 | configuration.handle = peer; 125 | } 126 | // Its YOU! 127 | room.broadcastProfile(); 128 | } 129 | }); 130 | 131 | room.on('peer left', (peer) => { 132 | console.log(`Peer left: ${peer}`); 133 | }); 134 | 135 | const DIRECT_MSG = 'dm'; 136 | const PROFILE_MSG = 'profile'; 137 | const BROADCAST_MSG = 'brodcast'; 138 | 139 | room.on('message', (message) => { 140 | let msg; 141 | 142 | try { 143 | msg = JSON.parse(message.data); 144 | } catch (ex) { 145 | return console.log(`Error: Cannot parse badly-formed command.`); 146 | } 147 | 148 | if (msg.messageType) { 149 | if (msg.messageType == PROFILE_MSG) { 150 | // update peerprofile: 151 | configuration.peerProfiles[message.from] = { 152 | id: message.from, 153 | handle: msg.handle.trim(), 154 | bio: msg.bio, 155 | publicKey: convertObjectToUint8(msg.publicKey), 156 | }; 157 | return console.log(`*** Profile broadcast: ${message.from} is now ${msg.handle}`); 158 | } else if (msg.messageType == DIRECT_MSG) { 159 | console.log('Direct Message? wut?'); 160 | console.log(message.from, msg); 161 | } else if (msg.messageType == BROADCAST_MSG) { 162 | return console.log(`*** Broadcast: ${message.from}: ${msg.content}`); 163 | } 164 | } 165 | 166 | // Handle peer refresh request 167 | if (message.data == 'peer-refresh') { 168 | network.broadcastProfile(message.from); 169 | } 170 | 171 | return console.log(`${message.from}: ${message.data}`); 172 | }); 173 | 174 | let peers = room.getPeers(); 175 | configuration.peers = [peers]; 176 | if (peers.length) { 177 | console.log('Peers:', configuration.peers) 178 | } 179 | 180 | let interval = setInterval(() => { 181 | let peers = room.getPeers(); 182 | configuration.peers = [peers]; 183 | if (peers.length) { 184 | console.log('Peers: ', configuration.peers); 185 | } 186 | }, PEER_REFRESH_MS); 187 | 188 | } 189 | 190 | // process.on('uncaughtException', (error) => { 191 | // logger.error(error); 192 | // }); 193 | 194 | main(); 195 | -------------------------------------------------------------------------------- /lib/keys.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const { 5 | box, 6 | secretbox, 7 | randomBytes 8 | } = require('tweetnacl'); 9 | 10 | const { 11 | decodeUTF8, 12 | encodeUTF8, 13 | encodeBase64, 14 | decodeBase64 15 | } = require('tweetnacl-util'); 16 | 17 | const { 18 | SK_STORAGE_PATH, 19 | PK_STORAGE_PATH, 20 | HOME_DIR, 21 | WUT_HOME 22 | } = require('./config'); 23 | 24 | const { logger } = require('./logger'); 25 | 26 | const DATA_TYPES = Object.freeze({ 27 | PUB_KEY:'pub', 28 | SEC_KEY: 'sec', 29 | NONCE: 'non', 30 | STRING: 'string', 31 | OBJECT: 'object' 32 | }) 33 | 34 | const PUB_KEY = DATA_TYPES.PUB_KEY; 35 | const SEC_KEY = DATA_TYPES.PUB_KEY; 36 | const NONCE = DATA_TYPES.NONCE; 37 | const STRING = DATA_TYPES.STRING; 38 | const OBJECT = DATA_TYPES.OBJECT; 39 | 40 | const convertObjectToUint8 = (obj, objType=null) => { 41 | if (!obj) { 42 | throw new Error('Arg size required!'); 43 | } 44 | if (obj instanceof Uint8Array) { 45 | return obj; 46 | } 47 | 48 | let len; 49 | 50 | switch(objType) { 51 | case PUB_KEY: 52 | len = box.publicKeyLength; 53 | break; 54 | case SEC_KEY: 55 | len = box.secretKeyLength; 56 | break; 57 | case NONCE: 58 | len = box.nonceLength; 59 | break; 60 | default: 61 | len = Object.keys(obj).length; 62 | } 63 | 64 | if (!len || len < 1) { 65 | throw new Error(`Error: variable len must be > 0`); 66 | } 67 | 68 | let result = new Uint8Array(len); 69 | for (let idx in obj) { 70 | result[idx] = obj[idx]; 71 | } 72 | 73 | return result; 74 | }; 75 | 76 | class Keys { 77 | constructor (init=false) { 78 | if (init) { 79 | const kp = this.generateKeypair(); 80 | const pubKey = kp.publicKey; 81 | 82 | try { 83 | this.keypairToDisk(kp); 84 | this.pubKeyToDisk(pubKey); 85 | } catch (ex) { 86 | console.error(ex); 87 | console.error(ex.stack); 88 | logger.error(ex); 89 | logger.error(ex.stack); 90 | } 91 | } 92 | } 93 | 94 | reverseKeyOps (b64Str, keyType) { 95 | const str = decodeBase64(b64Str); 96 | const utf8 = encodeUTF8(str); 97 | const json = JSON.parse(utf8); 98 | const result = convertObjectToUint8(json, keyType); 99 | if (!result) { 100 | throw new Error('reverseKeyOps failed, null key'); 101 | } 102 | 103 | return result; 104 | } 105 | 106 | // NOTE / TODO: USE DATA_TYPES.PUB_KEY / SEC_KEY instead of hard-coded strings 107 | 108 | get secretKey () { 109 | const skB64 = this.readFile('sec'); 110 | console.log(skB64); 111 | return this.reverseKeyOps(skB64, 'sec'); 112 | } 113 | 114 | get publicKey () { 115 | const pkB64 = this.readFile('pub'); 116 | 117 | return this.reverseKeyOps(pkB64, 'pub'); 118 | } 119 | 120 | readFile (fileName) { 121 | if (!fileName) { 122 | throw new Error('fileName arg required'); 123 | } 124 | let keyPath; 125 | if (fileName == 'pub') { 126 | keyPath = PK_STORAGE_PATH; 127 | } else if (fileName == 'sec') { 128 | keyPath = SK_STORAGE_PATH; 129 | } else { 130 | throw new Error('readfile(): Unknown file'); 131 | } 132 | console.log('keyPath: ', keyPath); 133 | try { 134 | return fs.readFileSync(keyPath, 'utf-8'); 135 | } catch (ex) { 136 | logger.error(ex); 137 | logger.error(ex.stack); 138 | throw new Error(`Cannot get key at path: ${keyPath}`); 139 | } 140 | } 141 | 142 | generateKeypair () { 143 | return box.keyPair(); 144 | } 145 | 146 | pubKeyToString (pubKey) { 147 | if (!pubKey) { 148 | throw new Error('Error: pubKey arg required'); 149 | } 150 | 151 | return JSON.stringify(pubKey); 152 | } 153 | 154 | pubKeyStringToBase64 (pubKeyStr) { 155 | if (!pubKeyStr && !typeof pubKeyStr == STRING) { 156 | throw new Error('Error: pubKeyStr arg required'); 157 | } 158 | 159 | return encodeBase64(pubKeyStr); 160 | } 161 | 162 | pubKeyToDisk (pubKey) { 163 | const pubStr = this.pubKeyToString(pubKey); 164 | const pubB64 = this.pubKeyStringToBase64(pubStr); 165 | const rv = this.persistPubKey(pubB64); 166 | 167 | if (!rv) { 168 | throw new Error('Could not persist pubKey'); 169 | } 170 | 171 | return rv; 172 | } 173 | 174 | persistPubKey (pubB64) { 175 | if (!pubB64 && !typeof pubB64 == STRING) { 176 | throw new Error('Error: pubB64 arg required'); 177 | } 178 | // Check for existence first 179 | if (!fs.existsSync(PK_STORAGE_PATH)) { 180 | fs.writeFile(PK_STORAGE_PATH, pubB64, (err) => { 181 | if (err) throw err; 182 | }); 183 | } 184 | 185 | return true; 186 | } 187 | 188 | keypairToString (kp) { 189 | if (!kp && !kp.publicKey && !kp.privateKey) { 190 | throw new Error('Error: keyPair arg required'); 191 | } 192 | 193 | return JSON.stringify(kp); 194 | } 195 | 196 | keypairStringToBase64 (kpStr) { 197 | if (!kpStr && !typeof kpStr == STRING) { 198 | throw new Error('Error: keyPair arg required'); 199 | } 200 | 201 | return encodeBase64(kpStr); 202 | } 203 | 204 | keypairToDisk (kp) { 205 | const kpStr = this.keypairToString(kp); 206 | const kpB64 = this.keypairStringToBase64(kpStr); 207 | const rv = this.persistKeypair(kpB64); 208 | 209 | if (!rv) { 210 | throw new Error('Could not persist keypair'); 211 | } 212 | 213 | return rv; 214 | } 215 | 216 | persistKeypair (kpB64) { 217 | if (!kpB64 && !typeof kpB64 == STRING) { 218 | throw new Error('Error: kpB64 arg required'); 219 | } 220 | // Check for existence first 221 | if (!fs.existsSync(WUT_HOME)) { 222 | this.createAppHome(); 223 | } 224 | 225 | fs.writeFile(SK_STORAGE_PATH, kpB64, (err) => { 226 | if (err) throw err; 227 | }); 228 | 229 | return true; 230 | } 231 | 232 | createAppHome () { 233 | fs.mkdirSync(WUT_HOME); 234 | } 235 | } 236 | 237 | module.exports = { 238 | Keys: Keys, 239 | convertObjectToUint8: convertObjectToUint8, 240 | PUB_KEY: DATA_TYPES.PUB_KEY, 241 | SEC_KEY: DATA_TYPES.SEC_KEY, 242 | NONCE: DATA_TYPES.NONCE, 243 | STRING: DATA_TYPES.STRING, 244 | OBJECT: DATA_TYPES.OBJECT, 245 | }; 246 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | /* 2 | ¿wut? : a very simple terminal-based chat application runing on top of IPFS, tweetnacl-js & blessed 3 | 4 | Author: David Dahl 5 | */ 6 | 7 | 'use strict'; 8 | const GossipSub = require('libp2p-gossipsub') 9 | const PeerInfo = require('peer-info') 10 | const IPFS = require('ipfs'); 11 | const Room = require('ipfs-pubsub-room'); 12 | 13 | const wrtc = require('wrtc'); // or require('electron-webrtc')() 14 | const WStar = require('libp2p-webrtc-star'); 15 | const TCP = require('libp2p-tcp'); 16 | 17 | const all = require('it-all'); 18 | 19 | const { v4: uuidv4 } = require('uuid'); 20 | 21 | const { box } = require('tweetnacl'); 22 | const notifier = require('node-notifier') 23 | const { dmUI } = require('./lib/dm-ui'); 24 | const { MainUI } = require('./lib/main-ui'); 25 | const { Network } = require('./lib/network'); 26 | const { openDirectMessage } = require('./lib/messages'); 27 | const { convertObjectToUint8 } = require('./lib/keys'); 28 | const { logger } = require('./lib/logger'); 29 | const { 30 | DEFAULT_TOPIC, 31 | APP_TITLE, 32 | PEER_REFRESH_MS, 33 | HOME_DIR, 34 | } = require('./lib/config'); 35 | 36 | const { 37 | libp2pBundle, 38 | signalServerIP, 39 | signalServerPort, 40 | signalServerCID, 41 | ssAddr 42 | } = require('./p2p'); 43 | 44 | var configuration = { 45 | handle: null, 46 | bio: 'Web 3.0 Enthusiast', 47 | homepage: 'https://github.com/daviddahl/wut', 48 | myTopic: uuidv4(), 49 | sharedKey: null, 50 | keyPair: null, 51 | peerProfiles: {}, 52 | }; 53 | 54 | const e2eMessages = {}; 55 | 56 | const uiConfiguration = { 57 | style: { 58 | fg: 'blue', 59 | bg: null, 60 | border: { 61 | fg: '#f0f0f0' 62 | } 63 | } 64 | }; 65 | 66 | const storage = { 67 | e2eMessages: e2eMessages, 68 | uiConfiguration: uiConfiguration, 69 | configuration: configuration, 70 | topic: DEFAULT_TOPIC, 71 | }; 72 | 73 | async function main () { 74 | 75 | let ipfsRepoPath = `${HOME_DIR}/`; 76 | // create and expose main UI 77 | let _keyPair = box.keyPair(); 78 | let pk = convertObjectToUint8(_keyPair.publicKey); 79 | let sk = convertObjectToUint8(_keyPair.secretKey); 80 | 81 | configuration.keyPair = { publicKey: pk, secretKey: sk }; 82 | 83 | const p2p = await libp2pBundle() 84 | 85 | const nodeId = p2p.peerInfo.id._idB58String 86 | 87 | await p2p.start() 88 | 89 | const bootstrapSignalingServerMultiAddr = 90 | `/ip4/${signalServerIP()}/tcp/15555/ws/p2p-webrtc-star/p2p/${p2p.peerInfo.id.toB58String()}` 91 | 92 | 93 | const addrs = [ 94 | '/ip4/0.0.0.0/tcp/0', 95 | '/ip4/0.0.0.0/tcp/0/ws', 96 | '/ip4/0.0.0.0/tcp/9090/ws', 97 | '/ip4/127.0.0.1/tcp/0/ws', 98 | '/ip4/127.0.0.1/tcp/0', 99 | bootstrapSignalingServerMultiAddr, 100 | ] 101 | 102 | addrs.forEach((addr) => { 103 | p2p.peerInfo.multiaddrs.add(addr) 104 | }) 105 | 106 | const room = new Room(p2p, DEFAULT_TOPIC); 107 | 108 | const network = new Network(configuration, nodeId, room); 109 | 110 | const mainUI = MainUI(configuration, storage, network); 111 | 112 | const output = mainUI.output; 113 | const input = mainUI.input; 114 | const peersList = mainUI.peersList; 115 | const screen = mainUI.screen; 116 | 117 | // TODO: Display public key as QR CODE 118 | output.log(`Your NaCl public key is: \n ${configuration.keyPair.publicKey}\n`); 119 | 120 | output.log(p2p.peerInfo.multiaddrs) 121 | p2p.peerInfo.multiaddrs.forEach(ma => output.log(ma.toString())) 122 | 123 | input.focus(); 124 | 125 | output.log('P2P node is initialized!'); 126 | output.log('P2P Node Id:', nodeId); 127 | 128 | configuration.handle = nodeId; 129 | 130 | output.log('\n...........................................'); 131 | output.log('................... Welcome ...............'); 132 | output.log('................... To ....................'); 133 | output.log(`.................. ${APP_TITLE} ..................`); 134 | output.log('...........................................\n'); 135 | output.log('\n\n*** This is the LOBBY. It is *plaintext* group chat ***'); 136 | output.log('\n*** Type "/help" for help ***\n'); 137 | 138 | output.log(`\nP2P signal server: ${signalServerIP()}`); 139 | 140 | p2p.on('peer:connect', (peer) => { 141 | output.log('Connection established to:', peer.id.toB58String()) // Emitted when a peer has been found 142 | }) 143 | 144 | // Emitted when a peer has been found 145 | p2p.on('peer:discovery', (peer) => { 146 | output.log('Discovered:', peer.id.toB58String()) 147 | }) 148 | 149 | room.on('subscribed', () => { 150 | output.log(`Now connected to room: ${DEFAULT_TOPIC}`); 151 | }); 152 | 153 | room.on('peer joined', (peer) => { 154 | output.log(`Peer joined the room: ${peer}`); 155 | if (peer == nodeId) { 156 | if (!configuration.handle) { 157 | // set default for now 158 | configuration.handle = nodeId; 159 | } 160 | // Its YOU! 161 | network.broadcastProfile(); 162 | } 163 | }); 164 | 165 | room.on('peer left', (peer) => { 166 | output.log(`Peer left: ${peer}`); 167 | }); 168 | 169 | const DIRECT_MSG = 'dm'; 170 | const PROFILE_MSG = 'profile'; 171 | const BROADCAST_MSG = 'brodcast'; 172 | 173 | room.on('message', (message) => { 174 | let msg; 175 | 176 | let from = message.from.substring(0, 9) 177 | 178 | try { 179 | msg = JSON.parse(message.data) 180 | } catch (ex) { 181 | return output.log(`Error: Cannot parse badly-formed command.`); 182 | } 183 | 184 | if (msg.messageType) { 185 | if (msg.messageType == PROFILE_MSG) { 186 | // update peerprofile: 187 | configuration.peerProfiles[message.from] = { 188 | id: message.from, 189 | handle: msg.handle.trim(), 190 | bio: msg.bio, 191 | publicKey: convertObjectToUint8(msg.publicKey), 192 | }; 193 | return output.log(`*** Profile: ${from} is now ${msg.handle}`); 194 | } else if (msg.messageType == DIRECT_MSG) { 195 | return handleDirectMessage(message.from, msg); 196 | } else if (msg.messageType == BROADCAST_MSG && message.from !== nodeId) { 197 | notifier.notify({ 198 | title: APP_TITLE, 199 | message: `${from}: ${msg.content}` // TODO: replace Qm CID with handle if it exists 200 | }); 201 | return output.log(`${from}: ${msg.content}`); 202 | } 203 | } 204 | 205 | // Handle peer refresh request 206 | if (message.data == 'peer-refresh') { 207 | network.broadcastProfile(message.from); 208 | } 209 | 210 | if (message.from === nodeId) { 211 | return output.log(`Me: ${msg.content}`); 212 | } 213 | return output.log(`${from}: ${msg.content}`); 214 | }); 215 | 216 | const handleDirectMessage = (fromCID, msg) => { 217 | let ui; 218 | 219 | // Check for existing dmUI 220 | try { 221 | ui = e2eMessages[fromCID].ui; 222 | } catch (ex) { 223 | // establish the UI, accept first message 224 | // TODO: whitelisting of publicKeys 225 | let profile = configuration.peerProfiles[fromCID]; 226 | ui = dmUI(screen, profile, storage, network); 227 | 228 | e2eMessages[fromCID] = {ui: ui}; 229 | } 230 | 231 | try { 232 | let plaintext = openDirectMessage(msg, configuration); 233 | if (plaintext == null) { 234 | ui.output.log(`*** ${APP_TITLE}: Error: Message is null.`); 235 | } else { 236 | ui.output.log(`${msg.fromHandle}: ${plaintext}`); 237 | } 238 | } catch (ex) { 239 | ui.output.log(`***`); 240 | ui.output.log(`*** ${APP_TITLE}: Cannot decrypt messages from ${msg.handle}`); 241 | logger.error(`${ex} ... \n ${ex.stack}`); 242 | ui.output.log(`***`); 243 | return; 244 | } 245 | }; 246 | 247 | let peers = room.getPeers(); 248 | configuration.peers = [peers]; 249 | if (peers.length) { 250 | peersList.setData(configuration.peers); 251 | screen.render(); 252 | } 253 | 254 | let interval = setInterval(() => { 255 | let peers = room.getPeers(); 256 | configuration.peers = [peers]; 257 | if (peers.length) { 258 | peersList.setData(configuration.peers); 259 | screen.render(); 260 | } 261 | }, PEER_REFRESH_MS); 262 | 263 | } 264 | 265 | // process.on('uncaughtException', (error) => { 266 | // logger.error(error); 267 | // }); 268 | 269 | main(); 270 | -------------------------------------------------------------------------------- /lib/main-ui.js: -------------------------------------------------------------------------------- 1 | const blessed = require('blessed'); 2 | 3 | const { APP_TITLE } = require('./config'); 4 | const { dmUI } = require('./dm-ui'); 5 | 6 | const { 7 | TAB, 8 | RETURN, 9 | BACKSPACE, 10 | SCROLL_UP, 11 | SCROLL_DOWN, 12 | UP, 13 | DOWN 14 | } = require('./constants') 15 | 16 | 17 | const MainUI = (configuration, storage, network) => { 18 | 19 | let output; 20 | let input; 21 | let e2eMessages = storage.e2eMessages; 22 | 23 | const screen = blessed.screen({ 24 | smartCSR: true, 25 | dockBorders: true, 26 | height: '100%', 27 | }); 28 | 29 | screen.title = APP_TITLE; 30 | 31 | // Quit on Escape, q, or Control-C. 32 | screen.key(['C-c'], (ch, key) => { 33 | // TODO: gracefully shutdown IPFS node 34 | return process.exit(0); 35 | }); 36 | 37 | output = blessed.Log({ 38 | name: 'main-output', 39 | parent: screen, 40 | scrollable: true, 41 | label: ` ${APP_TITLE}: Libp2p + Tweetnacl Chat `, 42 | top: 0, 43 | left: 0, 44 | width: '60%', 45 | height: '90%', 46 | tags: true, 47 | border: { 48 | type: 'line' 49 | }, 50 | style: { 51 | fg: 'blue', 52 | bg: null, 53 | border: { 54 | fg: '#f0f0f0' 55 | } 56 | } 57 | }); 58 | 59 | const peersWrapper = blessed.Box({ 60 | name: 'peers-wrapper', 61 | label: ' Peers ', 62 | border: { 63 | type: 'line' 64 | }, 65 | parent: screen, 66 | top: 0, 67 | left: '60%', 68 | width: '40%', 69 | height: '90%', 70 | style: { 71 | fg: 'blue', 72 | bg: null, 73 | border: { 74 | fg: '#f0f0f0' 75 | }, 76 | focus: { 77 | border: { 78 | fg: 'blue', 79 | } 80 | } 81 | } 82 | }); 83 | 84 | const peersList = blessed.Table({ 85 | parent: peersWrapper, 86 | scrollable: true, 87 | top: 0, 88 | left: 0, 89 | width: '98%', 90 | height: '100%', 91 | tags: true, 92 | }); 93 | 94 | peersWrapper.on('keypress', (value, key) => { 95 | // TODO: handle arrows up / down, return 96 | 97 | switch (key.name) { 98 | case TAB: 99 | input.focus(); 100 | return; 101 | default: 102 | break; 103 | } 104 | }); 105 | 106 | input = blessed.Textbox({ 107 | label: ' Command / Broadcast: ', 108 | parent: screen, 109 | name: 'main-input', 110 | top: '90%', 111 | left: 0, 112 | width: '99.9%', 113 | height: 4, 114 | tags: true, 115 | border: { 116 | type: 'line' 117 | }, 118 | scrollbar: false, 119 | style: { 120 | fg: 'blue', 121 | bg: null, 122 | border: { 123 | fg: '#f0f0f0', 124 | bg: null, 125 | }, 126 | focus: { 127 | border: { 128 | fg: 'blue', 129 | bg: null, 130 | } 131 | }, 132 | } 133 | }); 134 | 135 | const DIRECT_MSG = 'dm'; 136 | const PROFILE_MSG = 'profile'; 137 | const BROADCAST_MSG = 'brodcast'; 138 | 139 | input.on('keypress', (value, key) => { 140 | // TODO: handle control-a, -x, etc 141 | 142 | switch (key.name) { 143 | case TAB: 144 | peersWrapper.focus(); 145 | return; 146 | case RETURN: 147 | let msg = input.getValue(); 148 | if (msg) { 149 | msg = msg.trim() // TRIM all input here instead of everywhere else :) 150 | } 151 | let comm = whichCommand(msg); 152 | if (comm) { 153 | commands[comm](msg, output); 154 | screen.render(); 155 | return; 156 | } 157 | if (msg.indexOf('/') === 0) { 158 | // badly formed command here 159 | output.log(`${APP_TITLE}: badly formed command: ${msg}`); 160 | break; 161 | } 162 | let obj = { content: msg, messageType: BROADCAST_MSG }; 163 | network.room.broadcast(JSON.stringify(obj)); 164 | screen.render(); 165 | input.clearValue(); 166 | break; 167 | case BACKSPACE: 168 | let val = input.getValue(); 169 | if (val.length) { 170 | input.setValue(val.substring(0, (val.length -1))); 171 | } 172 | break; 173 | case UP: 174 | case DOWN: 175 | if ([SCROLL_UP, SCROLL_DOWN].includes(key.sequence)) { 176 | break; 177 | } 178 | default: 179 | input.setValue(`${input.value}${value}`); 180 | break; 181 | } 182 | 183 | screen.render(); 184 | }); 185 | 186 | screen.append(input); 187 | screen.append(output); 188 | screen.render(); 189 | 190 | const whichCommand = (command) => { 191 | // output.log(`**** INPUT: ${command}`); 192 | if (!command.startsWith('/')) { 193 | return null; 194 | } 195 | 196 | let firstSpace = command.indexOf(' '); 197 | let endChar; 198 | 199 | if (firstSpace == -1) { 200 | endChar = command.length - 1; 201 | } else { 202 | endChar = firstSpace; 203 | } 204 | 205 | let comm = command.substring(0, endChar); 206 | 207 | switch (comm) { 208 | case '/handle': 209 | return 'handle'; 210 | case '/peer': 211 | return 'peer'; 212 | case '/dm': 213 | return 'dm'; 214 | case '/help': 215 | return 'help'; 216 | default: 217 | return null; 218 | } 219 | }; 220 | 221 | // nit: 'description' is spelt wrong ;) 222 | const HELP_COMMANDS = [ 223 | { name: '/help', descrption: 'Displays this help data.' }, 224 | { name: '/handle', descrption: 'Change your handle: "/handle my-new-name"' }, 225 | { name: '/dm', descrption: 'Start a DM "/dm another-handle"' }, 226 | ]; 227 | 228 | const help = (data) => { 229 | output.log(`\n................ HELP ................`); 230 | HELP_COMMANDS.forEach((cmd) => { 231 | output.log(`${cmd.name}: ${cmd.descrption}`); 232 | }); 233 | output.log(`......................................\n`); 234 | input.clearValue(); 235 | }; 236 | 237 | const handle = (data, output) => { 238 | data = data.split(" "); 239 | if (data.length != 2) { 240 | output.log(`*** ERR: invalid input for /handle: ${data}`); 241 | input.clearValue(); 242 | return; 243 | } 244 | 245 | // TODO: Do not allow duplicate handles! 246 | configuration.handle = data[1].trim(); 247 | // output.log(`*** your /handle is now ${data[1]}`); 248 | input.clearValue(); 249 | network.broadcastProfile(); 250 | }; 251 | 252 | const peerRefresh = () => { 253 | // get all peer profiles 254 | for (let idx in configuration.peerProfiles) { 255 | network.room.sendTo(configuration.peerProfiles[idx].id, 'get-profile'); 256 | } 257 | }; 258 | 259 | const peer = (data) => { 260 | // peer commands 261 | data = data.split(" "); 262 | if (data.length != 2) { 263 | output.log(`*** ERR: invalid input for /peer: ${data}`); 264 | input.clearValue(); 265 | return; 266 | } 267 | 268 | switch (data[1]) { 269 | case 'refresh': 270 | peerRefresh(); 271 | break; 272 | default: 273 | break; 274 | } 275 | 276 | input.clearValue(); 277 | }; 278 | 279 | const dm = (data) => { 280 | // get the peer 281 | // use room.sendTo(cid, msg) to communicate 282 | // use peer CID as chatSession property in order to route DMs to correct dmUI 283 | // `esc` closes the chat window 284 | if (configuration.handle === configuration.id) { 285 | return output.log(`*** Please set your handle with "/handle myhandle"`); 286 | } 287 | data = data.split(" "); 288 | if (data.length != 2) { 289 | output.log(`*** ERR: invalid input for /dm: ${data}`); 290 | input.clearValue(); 291 | return; 292 | } 293 | 294 | const getProfile = (id) => { 295 | // output.log(JSON.stringify(configuration.peerProfiles)); 296 | for (let idx in configuration.peerProfiles) { 297 | output.log(idx); 298 | if (id == idx) { 299 | return configuration.peerProfiles[idx]; 300 | } 301 | if (id == configuration.peerProfiles[idx].handle) { 302 | return configuration.peerProfiles[idx]; 303 | } 304 | } 305 | return null; 306 | }; 307 | 308 | const profile = getProfile(data[1]); 309 | if (!profile) { 310 | return output.log(`*** Error: cannot get profile for ${data[1]}`); 311 | } 312 | const ui = dmUI(screen, profile, storage, network); 313 | e2eMessages[profile.id] = {ui: ui}; 314 | }; 315 | 316 | const commands = { 317 | handle: handle, 318 | peer: peer, 319 | dm: dm, 320 | help: help, 321 | }; 322 | 323 | return { 324 | output: output, 325 | input: input, 326 | peersWrapper: peersWrapper, 327 | peersList: peersList, 328 | screen: screen, 329 | }; 330 | 331 | }; 332 | 333 | module.exports = { 334 | MainUI: MainUI, 335 | }; 336 | --------------------------------------------------------------------------------