Use asymmetric encryption to replace passphrase exchange

All parties now create public/private key pairs for
encryption/decryption and signing/verification. Public keys are passed
around and stored in server memory while the room is alive. Session
keys, which are used to encrypt cleartext messages, are newly generated
for each message and are encrypted using each participant’s public key.
Messages are signed using the sender’s private signing key.
This commit is contained in:
Alan Friedman 2016-01-23 20:49:29 -05:00
parent 64b12776b8
commit d46b8f24b5
3 changed files with 361 additions and 262 deletions

View File

@ -20,7 +20,6 @@ $(function() {
];
let $window = $(window);
let $usernameInput = $('.usernameInput'); // Input for username
let $messages = $('.messages'); // Messages area
let $inputMessage = $('.inputMessage'); // Input message input box
let $key = $('.key');
@ -33,14 +32,15 @@ $(function() {
// Prompt for setting a username
let username;
let myUserId;
let connected = false;
let typing = false;
let lastTypingTime;
let $currentInput = $usernameInput.focus();
let encryptionKey;
let roomId = window.location.pathname.length ? window.location.pathname : null;
let keys = {};
if (!roomId) return;
if (!window.crypto || (!window.crypto.subtle && !window.crypto.webkitSubtle)) {
@ -53,11 +53,18 @@ $(function() {
return;
}
$('textarea.share-text').val("Let's chat on darkwire.io at https://darkwire.io" + roomId);
$('textarea.share-text').click(function() {
$(this).focus();
$(this).select();
this.setSelectionRange(0, 9999);
});
var crypto = window.crypto;
var cryptoSubtle = window.crypto.subtle || window.crypto.webkitSubtle;
let socket = io(roomId);
$('#roomIdKey').text(roomId.replace('/', ''));
FastClick.attach(document.body);
@ -69,7 +76,7 @@ $(function() {
}
// Sets the client's username
function setUsername () {
function initChat () {
username = window.username;
// warn not incognitor
if (!fs) {
@ -83,19 +90,41 @@ $(function() {
// If the username is valid
if (username) {
$chatPage.show();
$currentInput = $inputMessage.focus();
$inputMessage.focus();
// Tell the server your username
socket.emit('add user', username);
Promise.all([
createPrimaryKeys(),
createSigningKeys()
])
.then(function(data) {
keys.primary = {
public: data[0].publicKey,
private: data[0].privateKey
};
keys.signing = {
public: data[1].publicKey,
private: data[1].privateKey
};
return Promise.all([
exportKey(data[0].publicKey),
exportKey(data[1].publicKey),
]);
})
.then(function(exportedKeys) {
// Tell the server your username and send public keys
socket.emit('add user', {
username: username,
publicPrimaryKey: exportedKeys[0],
publicSigningKey: exportedKeys[1]
});
});
}
}
// Sends a chat message
function sendMessage () {
// Don't allow sending if key is empty
if (!encryptionKey.trim().length) return;
var vector = crypto.getRandomValues(new Uint8Array(16));
// Don't send unless other users exist
if (users.length <= 1) return;
let message = $inputMessage.val();
// Prevent markup from being injected into the message
@ -108,16 +137,63 @@ $(function() {
username: username,
message: message
});
// tell server to execute 'new message' and send along one parameter
createKey(encryptionKey)
let vector = crypto.getRandomValues(new Uint8Array(16));
let secretKey;
let secretKeys;
let messageData;
let signature;
// Generate new secret key and vector for each message
createSecretKey()
.then(function(key) {
return encryptData(message, key, vector);
secretKey = key;
// Generate secretKey and encrypt with each user's public key
let promises = [];
_.each(users, function(user) {
// It not me
if (user.username !== window.username) {
let promise = new Promise(function(resolve, reject) {
let thisUser = user;
let exportedSecretKey;
exportKey(key, "raw")
.then(function(data) {
exportedSecretKey = data;
return encryptSecretKey(data, thisUser.publicPrimaryKey);
})
.then(function(encryptedSecretKey) {
var encData = new Uint8Array(encryptedSecretKey);
var str = convertArrayBufferViewToString(encData);
resolve({
id: thisUser.id,
secretKey: str
});
});
});
promises.push(promise);
}
});
return Promise.all(promises);
})
.then(function(data) {
var encryptedData = new Uint8Array(data);
secretKeys = data;
messageData = convertStringToArrayBufferView(message);
return signKey(messageData, keys.signing.private)
})
.then(function(data) {
signature = data;
return encryptMessage(messageData, secretKey, vector)
})
.then(function(encryptedData) {
let msg = convertArrayBufferViewToString(new Uint8Array(encryptedData));
let vct = convertArrayBufferViewToString(new Uint8Array(vector));
let sig = convertArrayBufferViewToString(new Uint8Array(signature));
socket.emit('new message', {
message: convertArrayBufferViewtoString(encryptedData),
vector: convertArrayBufferViewtoString(vector)
message: msg,
vector: vct,
secretKeys: secretKeys,
signature: sig
});
});
}
@ -264,15 +340,6 @@ $(function() {
typing = false;
}
// If enter is pressed on key input then close key modal
if (event.which === 13 && $('#join-modal input').is(':focus')) {
checkJoinKey();
}
// If enter is pressed on edit key input
if (event.which === 13 && $('#settings-modal .edit-key input.key').is(':focus')) {
saveKey();
}
});
$inputMessage.on('input propertychange paste change', function() {
@ -285,11 +352,6 @@ $(function() {
}
});
$genKey.click(function () {
let key = generatePassword();
updateKeyVal(key);
});
// Select message input when closing modal
$('.modal').on('hidden.bs.modal', function (e) {
$inputMessage.focus();
@ -312,29 +374,56 @@ $(function() {
return text;
}
// Socket events
// Whenever the server emits 'login', log the login message
socket.on('login', function (data) {
socket.on('user joined', function (data) {
connected = true;
addParticipantsMessage(data);
users = data.users;
let importKeysPromises = [];
let key = generatePassword();
// Import all user keys if not already there
_.each(data.users, function(user) {
if (!_.findWhere(users, {id: user.id})) {
let promise = new Promise(function(resolve, reject) {
let currentUser = user;
Promise.all([
importPrimaryKey(currentUser.publicPrimaryKey),
importSigningKey(currentUser.publicSigningKey)
])
.then(function(keys) {
users.push({
id: currentUser.id,
username: currentUser.username,
publicPrimaryKey: keys[0],
publicSigningKey: keys[1]
});
resolve();
});
});
importKeysPromises.push(promise);
}
});
if (data.numUsers > 1) {
$('#join-modal').modal('show');
$('#join-modal').on('shown.bs.modal', function (e) {
$('#join-modal input').focus();
if (!myUserId) {
// Set my id if not already set
let me = _.findWhere(data.users, {username: username});
myUserId = me.id;
}
Promise.all(importKeysPromises)
.then(function() {
// All users' keys have been imported
if (data.numUsers === 1) {
$('#first-modal').modal('show');
}
$('.modal').on('shown.bs.modal', function (e) {
autosize.update($('textarea.share-text'));
});
key = '';
}
updateKeyVal(key);
log(data.username + ' joined');
$('.modal').on('shown.bs.modal', function (e) {
autosize.update($('textarea.share-text'));
renderParticipantsList();
});
});
@ -350,43 +439,63 @@ $(function() {
}
}
var username = data.username;
let message = data.message;
let messageData = convertStringToArrayBufferView(message);
let username = data.username;
let senderId = data.id
let vector = data.vector;
let vectorData = convertStringToArrayBufferView(vector);
let secretKeys = data.secretKeys;
let decryptedMessageData;
let decryptedMessage;
createKey(encryptionKey)
.then(function(key) {
var msg = convertStringToArrayBufferView(data.message);
var vector = convertStringToArrayBufferView(data.vector);
return decryptData(msg, key, vector)
let mySecretKey = _.find(secretKeys, function(key) {
return key.id === myUserId;
});
let signature = data.signature;
let signatureData = convertStringToArrayBufferView(signature);
let secretKeyArrayBuffer = convertStringToArrayBufferView(mySecretKey.secretKey);
decryptSecretKey(secretKeyArrayBuffer, keys.primary.private)
.then(function(data) {
return new Uint8Array(data);
})
.then(function(data) {
var decryptedData = new Uint8Array(data);
var msg = convertArrayBufferViewtoString(decryptedData);
addChatMessage({
username: username,
message: msg
});
return importSecretKey(data, "raw");
})
.catch(function() {
.then(function(data) {
let secretKey = data;
return decryptMessage(messageData, secretKey, vectorData);
})
.then(function(data) {
decryptedMessageData = data;
decryptedMessage = convertArrayBufferViewToString(new Uint8Array(data))
})
.then(function() {
// Find who sent msg (senderId), get their public key and verifyKey() with it and signature
let sender = _.find(users, function(user) {
return user.id === senderId;
});
let senderPublicVerifyKey = sender.publicSigningKey;
return verifyKey(signatureData, decryptedMessageData, senderPublicVerifyKey)
})
.then(function(bool) {
if (bool) {
addChatMessage({
username: username,
message: decryptedMessage
});
}
});
});
// Whenever the server emits 'user joined', log it in the chat body
socket.on('user joined', function (data) {
log(data.username + ' joined');
addParticipantsMessage(data);
users = data.users;
renderParticipantsList();
});
// Whenever the server emits 'user left', log it in the chat body
socket.on('user left', function (data) {
log(data.username + ' left');
addParticipantsMessage(data);
removeChatTyping(data);
users = data.users;
users = _.without(users, _.findWhere(users, {id: data.id}));
renderParticipantsList();
});
@ -401,11 +510,7 @@ $(function() {
removeChatTyping(data);
});
socket.on('first', function() {
$('#first-modal').modal('show');
});
setUsername();
initChat();
window.onfocus = function () {
isActive = true;
@ -426,33 +531,8 @@ $(function() {
$('#about-modal').modal('show');
});
$('.room-url').text('https://darkwire.io' + roomId);
$('.room-id').text(roomId.replace('/', ''));
$('[data-toggle="tooltip"]').tooltip();
function joinKeyInputChanged(val) {
if (!val.trim().length) {
$('#join-modal .modal-footer button').attr('disabled', 'disabled');
} else {
$('#join-modal .modal-footer button').removeAttr('disabled');
}
}
$('#join-modal .key').on('input propertychange paste change', function() {
let val = $(this).val().trim();
joinKeyInputChanged(val);
});
$('#settings-modal input.key').on('input propertychange paste change', function() {
let val = $(this).val().trim();
if (val !== encryptionKey && val.length) {
$('#settings-modal #save-key-edit').removeAttr('disabled');
} else {
$('#settings-modal #save-key-edit').attr('disabled', 'disabled');
}
});
$('.navbar .participants').click(function() {
renderParticipantsList();
$('#participants-modal').modal('show');
@ -460,101 +540,29 @@ $(function() {
function renderParticipantsList() {
$('#participants-modal ul.users').empty();
_.each(users, function(username) {
_.each(users, function(user) {
let li;
if (username === window.username) {
if (user.username === window.username) {
// User is me
li = $("<li>" + username + " <span class='you'>(you)</span></li>").css('color', getUsernameColor(username));
li = $("<li>" + user.username + " <span class='you'>(you)</span></li>").css('color', getUsernameColor(user.username));
} else {
li = $("<li>" + username + "</li>").css('color', getUsernameColor(username));
li = $("<li>" + user.username + "</li>").css('color', getUsernameColor(user.username));
}
$('#participants-modal ul.users')
.append(li);
});
}
function updateKeyVal(val) {
$('.key').val(val);
$('.key').text(val);
encryptionKey = val;
$('textarea.share-text').val("Let's chat on darkwire.io at https://darkwire.io" + roomId + " using the passphrase " + encryptionKey);
autosize.update($('textarea.share-text'));
}
// Prevent closing join-modal
$('#join-modal').modal({
backdrop: 'static',
show: false,
keyboard: false
});
$('.read-key').click(function() {
$('.edit-key').show();
$('.edit-key input').focus();
$(this).hide();
});
$('.edit-key #cancel-key-edit').click(function() {
cancelSaveKey();
});
$('.edit-key #save-key-edit').click(function() {
saveKey();
});
function cancelSaveKey() {
$('.edit-key').hide();
$('.read-key').show();
updateKeyVal(encryptionKey);
}
function saveKey() {
let key = $('.edit-key input.key').val().trim();
if (!key.length) return;
$('.edit-key').hide();
$('.read-key').show();
updateKeyVal(key || encryptionKey);
}
$('#join-modal .modal-footer button').click(function() {
checkJoinKey();
});
function checkJoinKey() {
let key = $('#join-modal input').val().trim();
if (!key.length) return;
updateKeyVal(key);
$('#join-modal').modal('hide');
socket.emit('user joined');
}
$('#settings-modal').on('hide.bs.modal', function (e) {
cancelSaveKey();
});
$('#send-message-btn').click(function() {
sendMessage();
socket.emit('stop typing');
typing = false;
});
function generatePassword() {
return uuid.v4();
}
$('.navbar-collapse ul li a').click(function() {
$('.navbar-toggle:visible').click();
});
autosize($('textarea.share-text'));
$('textarea.share-text').click(function() {
$(this).focus();
$(this).select();
this.setSelectionRange(0, 9999);
});
$('input.bs-switch').bootstrapSwitch();
$('input.bs-switch').on('switchChange.bootstrapSwitch', function(event, state) {
@ -570,7 +578,7 @@ $(function() {
return bytes;
}
function convertArrayBufferViewtoString(buffer) {
function convertArrayBufferViewToString(buffer) {
var str = "";
for (var i = 0; i < buffer.byteLength; i++) {
str += String.fromCharCode(buffer[i]);
@ -579,29 +587,161 @@ $(function() {
return str;
}
function createKey(password) {
return cryptoSubtle.digest({
name: "SHA-256"
}, convertStringToArrayBufferView(password))
.then(function(result) {
return cryptoSubtle.importKey("raw", result, {
name: "AES-CBC"
}, false, ["encrypt", "decrypt"]);
});
function createSigningKeys() {
return crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048, //can be 1024, 2048, or 4096
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["sign", "verify"] //can be any combination of "sign" and "verify"
);
}
function encryptData(data, key, vector) {
return cryptoSubtle.encrypt({
name: "AES-CBC",
iv: vector
}, key, convertStringToArrayBufferView(data));
function createPrimaryKeys() {
return crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048, //can be 1024, 2048, or 4096
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["encrypt", "decrypt"] //must be ["encrypt", "decrypt"] or ["wrapKey", "unwrapKey"]
);
}
function decryptData(data, key, vector) {
return cryptoSubtle.decrypt({
name: "AES-CBC",
iv: vector
}, key, data);
function createSecretKey() {
return crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256, //can be 128, 192, or 256
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["encrypt", "decrypt", "wrapKey", "unwrapKey"] //can be "encrypt", "decrypt", "wrapKey", or "unwrapKey"
);
}
function encryptSecretKey(data, secretKey) {
// Secret key will be recipient's public key
return crypto.subtle.encrypt(
{
name: "RSA-OAEP"
},
secretKey,
data //ArrayBuffer of data you want to encrypt
);
}
function decryptSecretKey(data, key) {
// key will be my private key
return crypto.subtle.decrypt(
{
name: "RSA-OAEP",
//label: Uint8Array([...]) //optional
},
key,
data //ArrayBuffer of the data
);
}
function encryptMessage(data, secretKey, iv) {
return crypto.subtle.encrypt(
{
name: "AES-CBC",
//Don't re-use initialization vectors!
//Always generate a new iv every time your encrypt!
iv: iv,
},
secretKey, //from generateKey or importKey above
data //ArrayBuffer of data you want to encrypt
);
}
function decryptMessage(data, secretKey, iv) {
return crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv, //The initialization vector you used to encrypt
},
secretKey, //from generateKey or importKey above
data //ArrayBuffer of the data
);
}
function importSecretKey(jwkData, format) {
return crypto.subtle.importKey(
format || "jwk", //can be "jwk" or "raw"
//this is an example jwk key, "raw" would be an ArrayBuffer
jwkData,
{ //this is the algorithm options
name: "AES-CBC",
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["encrypt", "decrypt"] //can be "encrypt", "decrypt", "wrapKey", or "unwrapKey"
);
}
function importPrimaryKey(jwkData) {
// Will be someone's public key
return crypto.subtle.importKey(
"jwk", //can be "jwk" (public or private), "spki" (public only), or "pkcs8" (private only)
jwkData,
{ //these are the algorithm options
name: "RSA-OAEP",
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["encrypt"] //"encrypt" or "wrapKey" for public key import or
//"decrypt" or "unwrapKey" for private key imports
);
}
function exportKey(key, format) {
// Will be public primary key or public signing key
return crypto.subtle.exportKey(
format || "jwk", //can be "jwk" (public or private), "spki" (public only), or "pkcs8" (private only)
key //can be a publicKey or privateKey, as long as extractable was true
);
}
function importSigningKey(jwkData) {
return crypto.subtle.importKey(
"jwk", //can be "jwk" (public or private), "spki" (public only), or "pkcs8" (private only)
//this is an example jwk key, other key types are Uint8Array objects
jwkData,
{ //these are the algorithm options
name: "RSASSA-PKCS1-v1_5",
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["verify"] //"verify" for public key import, "sign" for private key imports
);
}
function signKey(data, keyToSignWith) {
// Will use my private key
return crypto.subtle.sign(
{
name: "RSASSA-PKCS1-v1_5"
},
keyToSignWith, //from generateKey or importKey above
data //ArrayBuffer of data you want to sign
);
}
function verifyKey(signature, data, keyToVerifyWith) {
// Will verify with sender's public key
return crypto.subtle.verify(
{
name: "RSASSA-PKCS1-v1_5"
},
keyToVerifyWith, //from generateKey or importKey above
signature, //ArrayBuffer of the signature
data //ArrayBuffer of the data
);
}
});

View File

@ -1,6 +1,7 @@
import _ from 'underscore';
import {EventEmitter} from 'events';
import util from 'util';
import uuid from 'uuid';
class Room {
constructor(io = {}, id = {}) {
@ -19,33 +20,28 @@ class Room {
// we tell the client to execute 'new message'
socket.broadcast.emit('new message', {
username: socket.username,
id: socket.user.id,
message: data.message,
vector: data.vector
vector: data.vector,
secretKeys: data.secretKeys,
signature: data.signature
});
});
socket.on('add user', (username) => {
socket.on('add user', (data) => {
if (addedUser) return;
if (this.numUsers === 0) {
socket.emit('first');
}
this.users.push(username);
data.id = uuid.v4();
this.users.push(data);
// we store the username in the socket session for this client
socket.username = username;
socket.username = data.username;
socket.user = data;
++this.numUsers;
addedUser = true;
socket.emit('login', {
numUsers: this.numUsers,
users: this.users
});
});
socket.on('user joined', () => {
// echo globally (all clients) that a person has connected
socket.broadcast.emit('user joined', {
// Broadcast to ALL sockets, including this one
thisIO.emit('user joined', {
username: socket.username,
numUsers: this.numUsers,
users: this.users
@ -70,22 +66,20 @@ class Room {
socket.on('disconnect', () => {
if (addedUser) {
--this.numUsers;
this.users = _.without(this.users, socket.username);
this.users = _.without(this.users, socket.user);
// echo globally that this client has left
socket.broadcast.emit('user left', {
username: socket.username,
numUsers: this.numUsers,
users: this.users
users: this.users,
id: socket.user.id
});
// remove room from rooms array
if (this.numUsers === 0) {
this.emit('empty');
}
this.users = _.without(this.users, socket.username);
}
});
});

View File

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>darkwire.io - anonymous, encrypted chat</title>
<meta name="description" content="darkwire.io is the simplest way to chat online anonymously.">
<title>darkwire.io - encrypted web chat</title>
<meta name="description" content="darkwire.io is the simplest way to chat with encryption online.">
<link rel="shortcut icon" type="image/png" href="favicon.ico">
<link rel="stylesheet" href="/vendor/bootstrap-switch.min.css">
<link rel="stylesheet" href="/vendor/bootstrap.min.css">
@ -50,7 +50,7 @@
<div class="chatArea">
<ul class="messages">
<li class="log">
<p>Welcome to darkwire.io - anonymous, encrypted chat</p>
<p>Welcome to darkwire.io - encrypted chat</p>
</li>
</ul>
</div>
@ -69,11 +69,9 @@
<h4 class="modal-title">About</h4>
</div>
<div class="modal-body">
<p class="bold">WARNING: This product is in beta and its source code has not been peer-reviewed or undergone a security audit. This is a demo only and not intended for security-critical use.</p>
<p class="bold">WARNING: This product is in beta and its source code has not been peer-reviewed or undergone a security audit. This is a demo only and not intended for security-critical use. <a href="https://github.com/seripap/darkwire.io" target="_blank">View source code</a>.</p>
<p>darkwire.io is the simplest way to chat anonymously, and with encryption, online. Chat history is never stored on a server or database, and plain text messages are never transferred over the wire.</p>
<p>Your encryption passphrase ensures that only people you trust can decipher your messages. If you change your passphrase, make sure to notify all other participants.</p>
<p>darkwire.io is the simplest way to chat with encryption online. Chat history is never stored on a server or database, and plain text messages are never transferred over the wire.</p>
<p>Questions/comments? Email us at hello[at]darkwire.io</p>
</div>
@ -97,20 +95,6 @@
<textarea class="form-control share-text" rows="3" readonly id="settings-share-text" onclick="this.select()"></textarea>
</p>
<br>
<h6>Edit Your Passphrase</h6>
<div class="read-key">
<span class="key" id="read-key"></span>&nbsp;<span class="glyphicon glyphicon-pencil"></span>
</div>
<div class="edit-key">
<div class="input-group">
<input class="form-control key" placeholder="Enter passphrase here" type="text" id="settings-key"></input>
<div class="input-group-btn">
<button class="btn btn-default" type="button" id='cancel-key-edit'>Cancel</button>
<button class="btn btn-primary" type="button" id='save-key-edit' disabled="disabled">Save</button>
</div>
</div>
</div>
<br>
<h6>Sound</h6>
<input type="checkbox" name="my-checkbox" class="form-control bs-switch" checked>
</div>
@ -129,8 +113,8 @@
<h4 class="modal-title">Welcome to darkwire.io</h4>
</div>
<div class="modal-body">
<h6>We've placed you in a new chat room</h6>
<p class="bold">WARNING: This product is in beta and its source code has not been peer-reviewed or undergone a security audit. This is a demo only and not intended for security-critical use. <a href="https://github.com/seripap/darkwire.io" target="_blank">View source code</a>.</p>
<p>We've placed you in a new chat room.</p>
<br>
<h6>Invite People to This Room</h6>
<p>
@ -144,25 +128,6 @@
</div>
</div>
<div class="modal fade" id="join-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Welcome to darkwire.io</h4>
</div>
<div class="modal-body">
<h6>Enter Your Passphrase Below</h6>
<input class="form-control key" placeholder="Enter passphrase here" type="text" id='join-key'></input>
<br>
<p class="bold">WARNING: This product is in beta and its source code has not been peer-reviewed or undergone a security audit. This is a demo only and not intended for security-critical use.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary encryption-active" disabled="disabled" data-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="participants-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">