├── LICENSE ├── README.md ├── components └── index.jsx ├── gulpfile.js ├── package.json ├── public └── index.html ├── server.js └── styles └── main.less /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 jysperm 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rollup 2 | Rollup 希望提供一个去中心化的「抽奖」系统 —— 在抽奖的过程中没有人有特权,一旦有人作弊会被所有人发现。 3 | 4 | 传统的抽奖系统都是运行在单机上,那么这台设备就可能被做过手脚,抽奖的结果也不能令所有人信服。因此 Rollup 会以分布式的方式运行在所有人的设备上,会有一个后端服务器帮助客户端进行广播,但没有任何特权,每个客户端都会对抽奖过程进行验算,一旦有人作弊就会被发现。 5 | 6 | Rollup 只保证在成功选出中奖者时无人作弊,如果有人作弊那么抽奖过程会被中断,如果有人执意捣乱的话会导致抽奖一直无法完成。同时 Rollup 也不会对参与抽奖的资格进行验证,需要在下文提到的「确认参与者」的步骤中自行进行验证。 7 | 8 | ## 算法 9 | 10 | Rollup 基于两阶段提交来实现,在第一阶段: 11 | 12 | - 每个人本地生成一个随机数(记作 `secretNumber`)和一个 `hashSalt`(用于防范彩虹表),将 `secretNumber` 和 `hashSalt` 散列之后(记作 `numberHash`)连同自己的名字一起广播给其他人。 13 | - 在第一阶段每个人都会收到他人发来的广播,大家需要把收到的广播记录下来,并检查是否所有人都正确地进行了广播,确认所有参与者都进行了广播(否则等待剩余参与者广播 `numberHash`),确认没有人用不同的名字进行多次广播、没有不应参加的名字(否则抽奖中断)。 14 | - 每个人在进行确认后开始广播 `hashSalt`(广播 `hashSalt` 表示参与者名单确认完成),同时也会收到其他人广播的 `hashSalt` 15 | 16 | 对于每个人而言,当收到所有参与者的 `hashSalt` 之后,进入第二阶段: 17 | 18 | - 每个人开始广播自己的 `secretNumber`,并接受了记录其他人的 `secretNumber`(也需要使用之前的 `hashSalt` 和 `numberHash` 进行验算)。 19 | - 在收到第一阶段所有参与者的 `secretNumber` 之后,将 `secretNumber` 排序、组合并进行散列(记作 `luckyNumber`)。 20 | - 对比每个人的 `secretNumber` 与 `luckyNumber` 的差值,按照差值排序,即为抽奖的结果。 21 | 22 | 这个算法的要点就在于,在第一阶段中,每个人都选定了一个随机数;而在第二阶段中,大家广播这个随机数并一起生成一个 luckyNumber,决定中奖者名单。之所以需要在第一阶段广播这个随机数的散列值,就是为了保证不会有人在看到其他人的随机数之后,有意地选择自己的数字,来控制最后的 luckyNumber. 23 | 24 | ## 讨论 25 | 26 | > 如果有人执意捣乱会怎样? 27 | 28 | - 如果攻击者不广播 `numberHash`、多次使用不同的名字广播 `numberHash` 则会被大家在确认参与者名单的环节发现,进而无法进入第二阶段。 29 | - 如果攻击者不广播 `hashSalt` 会导致无法进入第二阶段;不广播 `secretNumber` 则无法得出最后的抽奖结果。 30 | - 如果攻击者广播错误的 `secretNumber`、`hashSalt`、`numberHash` 的组合,则会在第二阶段的验算中失败,进而导致最后的排名无法完成。 31 | 32 | 简而言之,有客户端故意捣乱会导致抽奖无法完成,但绝不会产生一个被操控的抽奖结果。 33 | 34 | > 如果后端没有公正地进行广播会怎样? 35 | 36 | 因为目前是使用单一的后端来实现广播,如果这个后端广播错误的消息的话(或者擅自添加消息),和上述存在攻击者的情况是一样的,会导致抽奖无法完成。 37 | 38 | 如果后端有选择性地不对某个客户端广播特定的消息,会导致客户端的状态出现「分叉」,即有一部分人因为完全感知不到另外一部分人,产生了完全不同的抽奖结果。这的确是目前的实现的一个缺陷,因为在这种情况下被分叉的那一部分人无法证明是后端没有正确地广播、还是自己故意地忽略了广播。 39 | 40 | 更好的设计可能是通过真正 P2P 的方式进行广播,这样除非其他所有参与者联合起来孤立一部分人,否则其他参与者就可以从未参与攻击的人哪里得到正确的广播。然而真正的 P2P 是没办法实现的 —— 你总是需要一个用作服务发现的节点,同时也要考虑通讯信道的安全性。 41 | 42 | > 这个算法能支撑多大规模的抽奖? 43 | 44 | 影响最大参与人数的规模主要有两个: 45 | 46 | - 客户端在进行广播时,任何的错误都会导致这一次抽奖失败,需要重新开始(例如填写名字、确认参与者)。在人数过多的情况下,这种人为失误的频率会非常高。 47 | - 因为每个客户端需要广播消息给其他所有客户端,所以广播的消息量和参与人数的关系是指数级增长的。 48 | 49 | ## 实现 50 | 51 | 该项目基于上述算法实现了一个原型,`components/index.jsx` 是客户端的主要逻辑,使用 React 编写,需要使用 gulp 编译后才能使用。`server.js` 是后端的主要逻辑,辅助客户端完成消息的广播。 52 | -------------------------------------------------------------------------------- /components/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Grid, Row, PageHeader, Button, Alert, FormGroup, ControlLabel, HelpBlock, FormControl} from 'react-bootstrap'; 3 | import {InputGroup, Col, Form, Table, Tooltip, OverlayTrigger, Label} from 'react-bootstrap'; 4 | import ReactDOM from 'react-dom'; 5 | import md5 from 'md5'; 6 | import _ from 'lodash'; 7 | 8 | export default class IndexView extends Component { 9 | render() { 10 | if (location.hash) { 11 | return ; 12 | } else { 13 | return 14 | 15 | 16 | Rollup 基于去中心化技术实现完全公平公开的抽奖游戏 17 | 18 | 19 | 20 | 21 | 22 | 23 |

24 | 源代码和原理解释见 GitHub 25 |

26 |
27 |
; 28 | } 29 | } 30 | 31 | onCreate() { 32 | location.href = `/#${randomString(16)}`; 33 | location.reload(); 34 | } 35 | } 36 | 37 | class GameView extends Component { 38 | constructor(props) { 39 | super(props); 40 | 41 | const [__, gameId] = location.hash.match(/#(\w+)/); 42 | 43 | this.state = { 44 | name: '', 45 | // initially, submited, confirmed, frozen, finished 46 | state: 'initially', 47 | gameId: gameId, 48 | submitedHashs: [] 49 | } 50 | } 51 | 52 | componentDidMount() { 53 | const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 54 | const socket = this.socket = new WebSocket(`${protocol}//${location.host}`); 55 | 56 | socket.addEventListener('open', () => { 57 | socket.send(JSON.stringify({ 58 | event: 'joinGame', 59 | gameId: this.state.gameId 60 | })); 61 | }); 62 | 63 | socket.addEventListener('message', ({data}) => { 64 | const message = JSON.parse(data); 65 | 66 | console.log('message', message); 67 | 68 | if (message.event === 'hashSubmited') { 69 | const exists = _.find(this.state.submitedHashs, {name: message.name}); 70 | 71 | if (!_.includes(['initially', 'submited', 'confirmed'], this.state.state)) { 72 | return console.error(`confirm(${message.name}): already frozen`); 73 | } 74 | 75 | if (exists) { 76 | if (exists.hash !== message.hash) { 77 | console.error(`hashSubmited(${message.name}): hash mismatch`); 78 | } 79 | } else { 80 | this.setState({ 81 | submitedHashs: this.state.submitedHashs.concat(message) 82 | }); 83 | } 84 | } else if (message.event === 'confirmed') { 85 | const exists = _.find(this.state.submitedHashs, {name: message.name}); 86 | 87 | if (!_.includes(['initially', 'submited', 'confirmed'], this.state.state)) { 88 | return console.error(`confirm(${message.name}): already frozen`); 89 | } 90 | 91 | if (exists) { 92 | if (exists.salt && exists.salt !== message.salt) { 93 | console.error(`confirm(${message.name}): salt mismatch`); 94 | } else { 95 | _.extend(exists, {salt: message.salt}); 96 | 97 | this.setState({ 98 | submitedHashs: this.state.submitedHashs 99 | }); 100 | 101 | if (this.state.state === 'confirmed' && this.state.submitedHashs.every( submitHash => { 102 | return submitHash.salt; 103 | })) { 104 | this.socket.send(JSON.stringify({ 105 | event: 'secretNumber', 106 | gameId: this.state.gameId, 107 | name: this.state.name, 108 | secretNumber: this.state.secretNumber 109 | })); 110 | 111 | this.setState({ 112 | state: 'frozen' 113 | }); 114 | } 115 | } 116 | } else { 117 | console.error(`confirm(${message.name}): ignored non exists`); 118 | } 119 | } else if (message.event === 'secretNumber') { 120 | const exists = _.find(this.state.submitedHashs, {name: message.name}); 121 | 122 | if (exists) { 123 | if (exists.hash === md5(exists.salt + message.secretNumber.toString())) { 124 | _.extend(exists, {secretNumber: message.secretNumber}); 125 | 126 | this.setState({ 127 | submitedHashs: this.state.submitedHashs 128 | }); 129 | 130 | if (this.state.submitedHashs.every( submitHash => { 131 | return submitHash.secretNumber; 132 | })) { 133 | const secretNumbers = _.map(this.state.submitedHashs, 'secretNumber'); 134 | const luckyHash = md5(_.sortBy(secretNumbers).join()); 135 | const luckyNumber = parseInt(luckyHash.slice(-8), 16); 136 | 137 | console.log(`luckyNumber is ${luckyNumber}`); 138 | 139 | this.setState({ 140 | state: 'finished', 141 | luckyNumber: luckyNumber 142 | }); 143 | } 144 | } else { 145 | console.error(`secretNumber(${message.name}): hash mismatch`); 146 | } 147 | } else { 148 | console.error(`secretNumber(${message.name}): ignored non exists`); 149 | } 150 | } else if (message.event === 'newGamerJoined' && this.state.state !== 'initially') { 151 | this.socket.send(JSON.stringify({ 152 | event: 'submitHash', 153 | gameId: this.state.gameId, 154 | name: this.state.name, 155 | hash: md5(this.state.salt + this.state.secretNumber.toString()) 156 | })); 157 | } 158 | }); 159 | } 160 | 161 | render() { 162 | const rankings = _.orderBy(_.map(this.state.submitedHashs, 'secretNumber'), secretNumber => { 163 | return Math.abs(secretNumber - this.state.luckyNumber); 164 | }); 165 | 166 | return 167 | 168 | 169 | 房间创建完成,请将当前网页的地址发给所有参与者进行抽奖。 170 | 171 | 172 | 173 |
174 | 175 | 176 | 我的名字 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 这个名字将用于在游戏过程中唯一地标识你自己,请选用一个被所有参与者都熟知的名字。 186 | 187 | 188 |
189 |
190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | {this.state.submitedHashs.map( submitedHash => { 202 | const secretNumberTips = 203 | abs({submitedHash.secretNumber} - {this.state.luckyNumber}) = {Math.abs(submitedHash.secretNumber - this.state.luckyNumber)} 204 | ; 205 | 206 | const secretNumberText = 207 | {submitedHash.secretNumber} 208 | ; 209 | 210 | const statusTips = 211 | salt: {submitedHash.salt || 'N/A'} 212 | ; 213 | 214 | const statusBlock = 215 | 218 | ; 219 | 220 | return 221 | 222 | 223 | 224 | 225 | ; 226 | })} 227 | 228 |
名字选定的数字状态排名
{submitedHash.name} (hash: {submitedHash.hash}){secretNumberText}{statusBlock}{this.state.luckyNumber && rankings.indexOf(submitedHash.secretNumber) + 1}
229 |
230 | 231 | 232 | 请等待所有参与者都提交了名字,并确保没有人用不同的名字参与多次、没有不应参加的名字,然后再点击「确认参与者」。可开启开发人员工具查看日志,更多原理解释和源代码见 GitHub 233 | 234 |
; 235 | } 236 | 237 | onNameChange({target: {value}}) { 238 | this.setState({ 239 | name: value 240 | }); 241 | } 242 | 243 | onSubmit() { 244 | if (!this.state.name) { 245 | alert('Name can not be empty'); 246 | } 247 | 248 | const secretNumber = randomNumber(0, Math.pow(2, 32)); 249 | const salt = randomString(16); 250 | 251 | console.log(`My secretNumber is ${secretNumber}, salt is ${salt}`); 252 | 253 | this.socket.send(JSON.stringify({ 254 | event: 'submitHash', 255 | gameId: this.state.gameId, 256 | name: this.state.name, 257 | hash: md5(salt + secretNumber.toString()) 258 | })); 259 | 260 | this.setState({ 261 | state: 'submited', 262 | secretNumber: secretNumber, 263 | salt: salt 264 | }); 265 | } 266 | 267 | onConfirm() { 268 | this.socket.send(JSON.stringify({ 269 | event: 'confirm', 270 | gameId: this.state.gameId, 271 | name: this.state.name, 272 | salt: this.state.salt 273 | })); 274 | 275 | this.setState({ 276 | state: 'confirmed' 277 | }); 278 | } 279 | } 280 | 281 | function randomString(length) { 282 | var result = ''; 283 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 284 | 285 | for (var i = 0; i < length; i++) { 286 | result += characters.charAt(Math.floor(Math.random() * characters.length)); 287 | } 288 | 289 | return result; 290 | } 291 | 292 | function randomNumber(min, max) { 293 | return Math.ceil(Math.random() * (max - min) + min); 294 | } 295 | 296 | ReactDOM.render(, document.getElementById('react-root')); 297 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const less = require('gulp-less'); 3 | const babel = require('gulp-babel'); 4 | const uglify = require('gulp-uglify'); 5 | const webpack = require('gulp-webpack'); 6 | const cleanCss = require('gulp-clean-css'); 7 | 8 | gulp.task('frontend-styles', () => { 9 | return gulp.src('styles/main.less') 10 | .pipe(less({paths: ['node_modules/bootstrap/less']})) 11 | .pipe(cleanCss()) 12 | .pipe(gulp.dest('public')); 13 | }); 14 | 15 | gulp.task('components', () => { 16 | return gulp.src('components/*.jsx') 17 | .pipe(babel({ 18 | presets: ['es2015'], 19 | plugins: ['transform-react-jsx'] 20 | })) 21 | .pipe(gulp.dest('public')); 22 | }); 23 | 24 | gulp.task('bundled', ['components'], () => { 25 | return gulp.src('public/index.js') 26 | .pipe(webpack({ 27 | output: { 28 | filename: 'index.bundled.js' 29 | } 30 | })) 31 | .pipe(uglify()) 32 | .pipe(gulp.dest('public')); 33 | }); 34 | 35 | gulp.task('watch', ['default'], () => { 36 | gulp.watch(['components/*.jsx'], ['bundled']); 37 | }); 38 | 39 | gulp.task('default', ['bundled', 'frontend-styles']); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": "6.x" 4 | }, 5 | "dependencies": { 6 | "md5": "^2.2.1", 7 | "express": "^4.14.0", 8 | "express-ws": "^2.0.0", 9 | "lodash": "^4.17.4" 10 | }, 11 | "devDependencies": { 12 | "babel-plugin-transform-react-jsx": "^6.8.0", 13 | "babel-preset-es2015": "^6.18.0", 14 | "bootstrap": "^3.3.7", 15 | "gulp": "^3.9.1", 16 | "gulp-babel": "^6.1.2", 17 | "gulp-clean-css": "^2.3.2", 18 | "gulp-less": "^3.3.0", 19 | "gulp-uglify": "^2.0.0", 20 | "gulp-webpack": "^1.5.0", 21 | "react": "^15.4.1", 22 | "react-bootstrap": "^0.30.7", 23 | "react-dom": "^15.4.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rollup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const md5 = require('md5'); 3 | const _ = require('lodash'); 4 | 5 | const app = express(); 6 | const sockets = {}; 7 | 8 | require('express-ws')(app); 9 | 10 | app.use(express.static('public')); 11 | 12 | app.ws('/', function(ws, req) { 13 | ws.on('message', function(messageString) { 14 | const message = JSON.parse(messageString); 15 | 16 | console.log('message', message); 17 | 18 | if (message.event === 'joinGame') { 19 | sockets[message.gameId] = sockets[message.gameId] || []; 20 | 21 | broadcast(message.gameId, { 22 | event: 'newGamerJoined' 23 | }); 24 | 25 | sockets[message.gameId].push(ws); 26 | } else if (message.event === 'submitHash') { 27 | broadcast(message.gameId, { 28 | event: 'hashSubmited', 29 | name: message.name, 30 | hash: message.hash 31 | }); 32 | } else if (message.event === 'confirm') { 33 | broadcast(message.gameId, { 34 | event: 'confirmed', 35 | name: message.name, 36 | salt: message.salt 37 | }); 38 | } else if (message.event === 'secretNumber') { 39 | broadcast(message.gameId, { 40 | event: 'secretNumber', 41 | name: message.name, 42 | secretNumber: message.secretNumber 43 | }); 44 | } 45 | }); 46 | }); 47 | 48 | function broadcast(gameId, message) { 49 | sockets[gameId].forEach( socket => { 50 | try { 51 | socket.send(JSON.stringify(message)); 52 | } catch (err) { 53 | if (err.message === 'not opened') { 54 | _.pull(sockets, socket); 55 | } else { 56 | throw err; 57 | } 58 | } 59 | }); 60 | } 61 | 62 | app.listen(process.env.LEANCLOUD_APP_PORT); 63 | -------------------------------------------------------------------------------- /styles/main.less: -------------------------------------------------------------------------------- 1 | @import 'bootstrap'; 2 | 3 | .row .btn-lg { 4 | margin-bottom: 20px; 5 | } 6 | 7 | .row .alert { 8 | margin-top: 20px; 9 | } 10 | --------------------------------------------------------------------------------