├── assets └── entangler.jpg ├── example-anon.js ├── package.json ├── example.js ├── LICENSE ├── .gitignore ├── index.js └── README.md /assets/entangler.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draeder/entangler/HEAD/assets/entangler.jpg -------------------------------------------------------------------------------- /example-anon.js: -------------------------------------------------------------------------------- 1 | const Gun = require('gun') 2 | const prompt = require('readline-sync') 3 | require('./index') 4 | 5 | let gun = new Gun() 6 | 7 | // Look up user by alias 8 | gun.entangler('~@A secure username123') 9 | 10 | // Look up user by pub key (no prepending '~') 11 | //gun.entangler(pubkey) 12 | 13 | let passcode = prompt.question('Enter your pin + token: ') 14 | 15 | gun.entangler.verify(passcode) 16 | 17 | gun.entangler.once('authorized', (sea)=>{ 18 | gun.user().auth(sea) 19 | }) 20 | 21 | gun.on('auth', ack => { 22 | console.log('Authenticated!!') 23 | }) 24 | 25 | gun.entangler.on('error', err => { 26 | if(err) console.log(err) 27 | if(err.code === 401){ 28 | let passcode = prompt.question('Pleae try again: ') 29 | gun.entangler.verify(passcode) 30 | } 31 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entangler", 3 | "version": "0.1.8", 4 | "description": "Authenticate Gun DB users with a one-time password", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/draeder/entangler.git" 12 | }, 13 | "keywords": [ 14 | "one time password", 15 | "OTP", 16 | "TOTP", 17 | "gun", 18 | "gun", 19 | "db", 20 | "p2p", 21 | "decentralization" 22 | ], 23 | "author": "Daniel Raeder", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/draeder/entangler/issues" 27 | }, 28 | "homepage": "https://github.com/draeder/entangler#readme", 29 | "dependencies": { 30 | "@otplib/preset-default": "^12.0.1", 31 | "bugoff": "0.0.8", 32 | "hi-base32": "^0.5.1", 33 | "qrcode": "^1.5.0", 34 | "readline-sync": "^1.4.10" 35 | }, 36 | "devDependencies": {} 37 | } 38 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const Gun = require('gun') 2 | require('./') 3 | 4 | let gun = new Gun() 5 | let user = gun.user() 6 | 7 | // create new user or authenticate existing one 8 | let username = 'A secure username123' // A secure username 9 | let password = 'A secure password123' // A secure password 10 | 11 | user.create(username, password, cb => { 12 | user.auth(username, password) 13 | }) 14 | 15 | gun.on('auth', async ack => { 16 | console.log('Authenticated') 17 | 18 | // Create an Entanglement instance 19 | gun.entangler(ack.sea, {user: username, secret: password}) 20 | 21 | // Return the whole Entanglement object 22 | console.log(await gun.entangler) 23 | 24 | // Return the OTP auth URI QR code image 25 | //console.log(await gun.entangler.QR.image()) 26 | 27 | // Print the OTP auth URI QR code to the terminal in ASCII 28 | console.log(await gun.entangler.QR.terminal()) 29 | 30 | // Get the current token 31 | console.log(await gun.entangler.token()) 32 | 33 | // Get tokens as they are generated 34 | gun.entangler.tokens(token => { 35 | console.log(token) 36 | }) 37 | 38 | }) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Daniel Raeder 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gun 2 | radata 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | var EventEmitter = require('events').EventEmitter 3 | const base32 = require('hi-base32') 4 | const qrcode = require('qrcode') 5 | const { authenticator } = require('@otplib/preset-default') 6 | const Gun = require('gun') 7 | const { SEA } = require('gun') 8 | const Bugoff = require('bugoff') 9 | 10 | Gun.chain.entangler = async function(sea, opts) { 11 | const emitter = new EventEmitter() 12 | 13 | this.entangler = { 14 | attempts: {}, 15 | on: emitter.on.bind(emitter), 16 | once: emitter.on.bind(emitter), 17 | emit: emitter.emit.bind(emitter) 18 | } 19 | 20 | let anon = true 21 | let instanceSEA 22 | 23 | this.entangler.verify = async (passcode) => { 24 | this.entangler.passcode = base64(passcode) 25 | } 26 | 27 | if(typeof sea === 'string'){ 28 | anon = true 29 | let pubkey = this.get(sea).once((data, key)=>{}).then(data => { 30 | try { 31 | return Object.keys(data)[1] 32 | } 33 | catch (err){ 34 | return new Promise((resolve, reject) => {this.user(sea).once((data, key) => resolve(key))}) 35 | } 36 | }) 37 | pubkey = await pubkey 38 | pubkey = pubkey.split('~')[1] 39 | this.entangler.address = sha(pubkey) 40 | instanceSEA = await SEA.pair() 41 | } else { 42 | anon = false 43 | } 44 | 45 | if(anon === false) { 46 | this.entangler = { 47 | on: emitter.on.bind(emitter), 48 | once: emitter.on.bind(emitter), 49 | emit: emitter.emit.bind(emitter), 50 | address: opts && opts.address || sha(sea.pub), 51 | issuer: opts && opts.issuer || 'Entanglement Authenticator', 52 | user: opts && opts.user ? base32.encode(sha(opts.user)) : authenticator.generateSecret(), 53 | secret: opts && opts.secret ? base32.encode(sha(opts.secret)) : authenticator.generateSecret(), 54 | pin: opts && opts.pin || '', 55 | timeout: opts && opts.timeout || 1000 * 60 * 5, 56 | maxAttempts: opts && opts.maxAttempts || 10, 57 | attempts: {}, 58 | period: opts && opts.period || 30, 59 | digits: opts && opts.digits || 6, 60 | algorithm: opts && opts.algorithm || 'SHA1', 61 | uri: `otpauth://totp/${encodeURI(this.entangler.issuer)}:${this.entangler.user}?secret=${this.entangler.secret}&period=${this.entangler.period||30}&digits=${this.entangler.digits||6}&algorithm=${this.entangler.algorithm||'SHA256'}&issuer=${encodeURI(this.entangler.issuer)}`, 62 | QR: {} 63 | } 64 | 65 | this.entangler.uri = `otpauth://totp/${encodeURI(this.entangler.issuer)}:${this.entangler.user}?secret=${this.entangler.secret}&period=${this.entangler.period||30}&digits=${this.entangler.digits||6}&algorithm=${this.entangler.algorithm||'SHA256'}&issuer=${encodeURI(this.entangler.issuer)}` 66 | 67 | this.entangler.QR.terminal = async () => { 68 | try { 69 | return await qrcode.toString(this.entangler.uri,{type:'terminal', small: true}) 70 | } 71 | catch (err){ 72 | throw new Error(err) 73 | } 74 | } 75 | this.entangler.QR.image = async () => { 76 | try { 77 | return await qrcode.toDataURL(this.entangler.uri) 78 | } catch (err) { 79 | throw new Error(err) 80 | } 81 | } 82 | instanceSEA = sea 83 | } 84 | 85 | let bugoff = new Bugoff(this.entangler.address) 86 | bugoff.SEA(instanceSEA) 87 | 88 | bugoff.register('accepted', async (address, sea, cb) =>{ 89 | this.entangler.emit('authorized', sea) 90 | }) 91 | 92 | bugoff.register('rejected', async (address, err, cb) =>{ 93 | this.entangler.emit('error', err) 94 | if(err.code === 401) bugoff.rpc(address, 'challenge', this.entangler.passcode) 95 | else { 96 | cb(bugoff.destroy()) 97 | if(process) process.exit() 98 | else debugger 99 | } 100 | }) 101 | 102 | bugoff.register('challenge', async (address, verify, cb) =>{ 103 | if(!this.entangler.attempts[address]) { 104 | this.entangler.attempts[address] = {count: 1, first: new Date().getTime()} 105 | } 106 | 107 | let t = new Date().getTime() - this.entangler.attempts[address].first 108 | 109 | if(t >= this.entangler.timeout){ 110 | bugoff.rpc(address, 'rejected', {code: 408, text: 'Attempts timed out'}) 111 | } else 112 | if(this.entangler.attempts[address].count >= this.entangler.maxAttempts){ 113 | bugoff.rpc(address, 'rejected', {code: 403, text: 'Maximum number of attempts reached'}) 114 | } else 115 | if(anon === false){ 116 | let check = base64(verify) 117 | let token = await this.entangler.token() 118 | let pin = this.entangler.pin.toString() 119 | if(check.lastIndexOf(token.length + pin.length === check.length && await this.entangler.token()) >= 0 && check.includes(pin)){ 120 | bugoff.rpc(address, 'accepted', sea) 121 | this.entangler.passcode = undefined 122 | } else { 123 | bugoff.rpc(address, 'rejected', {code: 401, text: 'Incorrect passcode'}) 124 | this.entangler.attempts[address].count++ 125 | this.entangler.passcode = undefined 126 | } 127 | } 128 | }) 129 | 130 | bugoff.on('seen', address => { 131 | if(this.entangler.passcode) bugoff.rpc(address, 'challenge', this.entangler.passcode) 132 | }) 133 | 134 | this.entangler.token = async () => authenticator.generate(this.entangler.secret) 135 | 136 | let secret = this.entangler.secret 137 | this.entangler.tokens = async function(cb) { 138 | const interval = setInterval(() => { 139 | let sec = new Date().getSeconds() 140 | if (sec === 0 || sec === 30) { 141 | cb(authenticator.generate(secret)) 142 | } 143 | }, 1000) 144 | } 145 | 146 | function sha(input){ 147 | return crypto.createHash('sha256').update(JSON.stringify(input)).digest('hex') 148 | } 149 | 150 | function base64(string){ 151 | let regx = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; 152 | if(regx.test(string) === false){ 153 | return new Buffer.from(string).toString('base64') 154 | } else { 155 | let data = new Buffer.from(string, 'base64') 156 | return new Buffer.from(data).toString() 157 | } 158 | } 159 | 160 | return this 161 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Entangler 2 | A time-based one-time password (TOTP) generator and authenticator for Gun DB 3 | 4 | Entangler generates a 6 digit passcode every 30 seconds. It generates an `otpauth://` URI and QR codes (both console and image) that can be linked with popular authenticator apps like Microsoft Authenticator, Google Authenticator, LastPass--and many others. But it is not limited to big tech authenticator apps. 5 | 6 | ![Entangler](assets/entangler.jpg) 7 | 8 | ## About 9 | ### How it works 10 | Entangler generates a new token every 0 and 30 seconds of of every passing minute. When a peer passes in the correct token for that 30 second window, entangler will respond with the source instance's Gun SEA pair. The returned SEA pair may be used to sync Gun user accounts, reset passwords, or other purposes that might depend on passing SEA data over the network to another peer. 11 | 12 | Entangler uses [Bugoff](https://github.com/draeder/bugoff) (an extension built on [Bugout](https://github.com/chr15m/bugout)) which also uses Gun's SEA suite to securely exchange ephemeral messages between peers without the need to store data in the Gun DB graph. 13 | 14 | ## Usage 15 | ### Install 16 | ```js 17 | > npm i entangler 18 | ``` 19 | 20 | ## Examples 21 | ### Initiator Peer Instance 22 | This is an example of creating and authenticating a Gun user, then creating an Entangler instance. The insance does not necessarily need to be an existing user. Engangler will accept any SEA pair, for example one created with `Gun.SEA.pair()` 23 | 24 | ```js 25 | const Gun = require('gun') 26 | require('entangler') 27 | 28 | let gun = new Gun() 29 | let user = gun.user() 30 | 31 | // Create new Gun user or authenticate existing one 32 | let username = 'A secure username123' // A secure username 33 | let password = 'A secure password123' // A secure password 34 | 35 | user.create(username, password, cb => { 36 | user.auth(username, password) 37 | }) 38 | 39 | gun.on('auth', async ack => { 40 | console.log('Authenticated') 41 | 42 | // Create an entangler instance with an SEA pair 43 | // The username and password here does not need, and probably shouldn't, match a Gun user's username and password! 44 | gun.entangler(ack.sea, {user: username, secret: password}) 45 | 46 | // Return the OTP auth URI QR code image 47 | console.log(await gun.entangler.QR.image()) 48 | 49 | // Print the OTP auth URI QR code to the terminal in ASCII 50 | console.log(await gun.entangler.QR.terminal()) 51 | 52 | // Get the current token 53 | console.log(await gun.entangler.token()) 54 | 55 | // Get tokens as they are generated 56 | gun.entangler.tokens(token => { 57 | console.log(token) 58 | }) 59 | 60 | }) 61 | ``` 62 | 63 | ### Anonymous Peer 64 | This is a peer that will be attempting to authenticate to the initiating peer's Entangler instance with the TOTP passcode. 65 | 66 | ```js 67 | const Gun = require('gun') 68 | const prompt = require('readline-sync') 69 | require('entangler') 70 | 71 | let gun = new Gun() 72 | 73 | // Look up user by alias 74 | gun.entangler('~@A secure username123') 75 | 76 | // Look up user by pub key (no prepending '~') 77 | gun.entangler(pubkey) 78 | 79 | // Prompt for a passcode 80 | let passcode = prompt.question('Enter your pin + token: ') 81 | 82 | // Verify the passed passcode 83 | gun.entangler.verify(passcode) 84 | 85 | // If the passcode is accepted, the initiator's SEA is returned and can be used to log the user in 86 | gun.entangler.once('authorized', (sea)=>{ 87 | gun.user().auth(sea) 88 | }) 89 | 90 | // The user has been logged in successfully 91 | gun.on('auth', ack => { 92 | console.log('Authenticated!!') 93 | }) 94 | 95 | // If the passcode is rejected, handle the error events 96 | gun.entangler.on('error', err => { 97 | if(err) console.log(err) 98 | if(err.code === 401){ 99 | let passcode = prompt.question('Pleae try again: ') 100 | gun.entangler.verify(passcode) 101 | } 102 | }) 103 | ``` 104 | 105 | ## API 106 | ### Events 107 | #### `authorized` 108 | The peer successfully authenticated the TOTP passcode, so the initiating peer's SEA is passed as a callback to this event. 109 | 110 | #### `error` 111 | There was an error authenticating the TOTP passcode. 112 | 113 | **Error codes** 114 | - Incorrect passcode: `{code: 401, text: 'Incorrect passcode'}` 115 | - Maximum number of attempts reached: `{code: 403, text: 'Maximum number of attempts reached'}` 116 | - Attempts timed out: `{code: 408, text: 'Attempts timed out'}` 117 | 118 | ### Methods 119 | #### `gun.entangler((sea, [opts]) || (alias || pubkey))` 120 | For an Entangler initiator, creates an Entangler instance for the passed in `Gun.SEA.pair` and optional `opts`. 121 | 122 | **Example:** `gun.entangler(ack.sea, {user: username, secret: password})` 123 | 124 | For an Entangler peer, connects to an Engangler instance and attempts authorization with that instance and the TOTP passoce. 125 | 126 | **Example (by alias):** `gun.entangler(~@alias)` 127 | **Example (by pubkey):** `gun.entangler(pubkey)` 128 | > The pubkey should not start with a preceding `~` 129 | 130 | #### `gun.entangler.QR.image()` 131 | Return the OTP auth URI QR code image. This is an asynchronous call and must be used with `await`. 132 | 133 | **Example:** `console.log(await gun.entangler.QR.image())` 134 | 135 | #### `gun.entangler.QR.terminal()` 136 | Print the OTP auth URI QR code to the console/terminal using ascii output. This is an asynchronous call and must be used with `await`. 137 | 138 | **Example:** `console.log(await gun.entangler.QR.terminal())` 139 | 140 | #### `gun.entangler.token()` 141 | Return the current authenticator token. This may be called at any time and will return the token for the current time window. This is an asynchronous call and must be used with `await`. 142 | 143 | **Example:** `console.log(await gun.entangler.token())` 144 | 145 | #### `gun.entangler.tokens(callback)` 146 | Return tokens as they are generated. This method will return a new token every 0 and 30 seconds of every minute. 147 | 148 | **Example:** 149 | ```js 150 | gun.entangler.tokens(token => { 151 | console.log(token) 152 | }) 153 | ``` 154 | 155 | ### Optional parameters `opts` 156 | Entangler's optional `opts` object can be tailored to aid in securing Entangler further. 157 | 158 | #### `opts.address = [string]` default = Gun.SEA.pair().pub 159 | `opts.address` is an optional string that may be passed in as an identifier for peers to swarm around and connect to each other. It is converted to a SHA256 hash and announced to the Webtorrent network via Bugoff, which further hashes that hash to SHA256. A SHA256 hash of a SHA256 hash! 160 | 161 | #### `opts.issuer = [string]` default = 'Entangler Authenticator' 162 | A TOTP issuer is used to describe the TOTP instance to authenticator apps. 163 | 164 | #### `opts.user = [string]` default = randomly generated Base32 string 165 | You may pass in your own string for `opts.user`. This is the TOTP user ID, which gets converted to a Base32 encoded SHA256 hash of the passed in string. 166 | 167 | #### `opts.secret = [string]` default = randomly generated Base32 string 168 | You may pass in your own string for `opts.password`. This is the TOTP secret, which gets converted to a Base32 encoded SHA256 hash of the passed in string. 169 | 170 | #### `opts.pin = [string || number]` default = '' 171 | You may supply a pin, which can be either a string or a number, as an optional additional security measure to protect the Entangler instance. 172 | 173 | #### `opts.timeout = [msec]` default = 5 minutes (1000 * 60 * 5 msec ) 174 | The amount of time in milliseconds since this peer's first passcode entry attempt. Once this timeout has been met or exceeded, this peer can no longer make attempts. 175 | 176 | > Note: A peeer may try again by establishing a new connection. 177 | 178 | #### `opts.maxAttempts = [number]` default = 10 179 | The maximum attempts for a peer to enter incorrect passcodes. 180 | --------------------------------------------------------------------------------