Commit a3593dbc authored by JC Brand's avatar JC Brand

Implement and test sending of encrypted messages

updates #497
parent f40e4b4d
......@@ -73263,85 +73263,143 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
const _converse = this.__super__._converse;
return new Promise((resolve, reject) => {
_converse.getDevicesForContact(this.get('jid')).then(devices => {
const promises = devices.map(device => device.getBundle());
Promise.all(promises).then(() => {
this.buildSessions(devices).then(() => resolve(devices)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
Promise.all(devices.map(device => device.getBundle())).then(() => this.buildSessions(devices)).then(() => resolve(devices)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
},
buildSession(device) {
const _converse = this.__super__._converse;
const bundle = device.get('bundle'),
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
prekey = device.getRandomPreKey();
return sessionBuilder.processPreKey({
'registrationId': _converse.omemo_store.get('registration_id'),
'identityKey': _converse.omemo_store.get('identity_keypair'),
'signedPreKey': {
'keyId': bundle.signed_prekey.id,
// <Number>
'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
},
'preKey': {
'keyId': prekey.id,
// <Number>
'publicKey': u.base64ToArrayBuffer(prekey.key)
}
});
},
buildSessions(devices) {
return Promise.all(devices.map(device => this.buildSession(device)));
},
encryptMessage(plaintext) {
// The client MUST use fresh, randomly generated key/IV pairs
// with AES-128 in Galois/Counter Mode (GCM).
const TAG_LENGTH = 128,
iv = window.crypto.getRandomValues(new window.Uint8Array(16));
let key;
return window.crypto.subtle.generateKey({
'name': "AES-GCM",
'length': 256
}, true, // extractable
["encrypt", "decrypt"] // key usages
).then(result => {
key = result;
const algo = {
'name': 'AES-GCM',
'iv': iv,
'tagLength': TAG_LENGTH
};
return window.crypto.subtle.encrypt(algo, key, new TextEncoder().encode(plaintext));
}).then(ciphertext => {
return window.crypto.subtle.exportKey("jwk", key).then(key_str => {
return Promise.resolve({
'key_str': key_str,
'tag': ciphertext.slice(ciphertext.byteLength - (TAG_LENGTH + 7 >> 3)),
'iv': iv
});
});
});
},
encryptKey(plaintext, device) {
const _converse = this.__super__._converse,
device_id = _converse.omemo_store.get('device_id');
return Promise.all(_.map(devices, device => {
const recipient_id = device['id'];
const address = new libsignal.SignalProtocolAddress(parseInt(recipient_id, 10), device_id);
const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
return sessionBuilder.processPreKey({
'registrationId': _converse.omemo_store.get('registration_id'),
'identityKey': _converse.omemo_store.get('identity_keypair'),
'signedPreKey': {
'keyId': '',
// <Number>,
'publicKey': '',
// <ArrayBuffer>,
'signature': '' // <ArrayBuffer>
address = new libsignal.SignalProtocolAddress(this.get('jid'), device.get('id')),
sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
return sessionCipher.encrypt(plaintext);
},
},
'preKey': {
'keyId': '',
// <Number>,
'publicKey': '' // <ArrayBuffer>
addKeysToMessageStanza(stanza, devices, payloads) {
for (var i in payloads) {
if (Object.prototype.hasOwnProperty.call(payloads, i)) {
const payload = btoa(JSON.stringify(payloads[i]));
const prekey = 3 == parseInt(payloads[i].type, 10);
if (i == payloads.length - 1) {
stanza.c('key', {
'rid': devices.get('id')
}).t(payload);
if (prekey) {
stanza.attrs({
'prekey': prekey
});
}
stanza.up().c('iv').t(payloads[0].iv).up().up();
} else {
stanza.c('key', {
prekey: prekey,
rid: devices.get('id')
}).t(payload).up();
}
});
}));
},
}
}
encryptMessage(message) {// TODO:
// const { _converse } = this.__super__;
// const plaintext = message.get('message');
// const address = new libsignal.SignalProtocolAddress(recipientId, deviceId);
// return new Promise((resolve, reject) => {
// var sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
// sessionCipher.encrypt(plaintext).then((ciphertext) => {});
// });
return Promise.resolve(stanza);
},
createOMEMOMessageStanza(message, bundles) {
createOMEMOMessageStanza(message, devices) {
const _converse = this.__super__._converse,
__ = _converse.__;
const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. " + "Find more information on https://conversations.im/omemo");
const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. " + "Find more information on https://conversations.im/omemo"); // An encrypted header is added to the message for each device that is supposed to receive it.
// These headers simply contain the key that the payload message is encrypted with,
// and they are separately encrypted using the session corresponding to the counterpart device.
return new Promise((resolve, reject) => {
this.encryptMessage(message).then(payload => {
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up().c('encrypted').t(payload).c('header').t(payload).up();
_.forEach(bundles, bundle => {
const prekey = bundle.prekeys[Math.random(bundle.prekeys.length)].textContent;
stanza('key', {
'rid': bundle.identity_key
}).t(prekey).up();
}); // TODO: set storage hint urn:xmpp:hints
resolve(stanza);
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up().c('encrypted', {
'xmlns': Strophe.NS.OMEMO
}).c('header', {
'sid': _converse.omemo_store.get('device_id')
});
return this.encryptMessage(message).then(payload => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
// TODO: need to include own devices here as well (and filter out distrusted devices)
const promises = devices.map(device => this.encryptKey(payload.key_str + payload.tag, device));
return Promise.all(promises).then(payloads => this.addKeysToMessageStanza(stanza, devices, payloads));
});
},
sendMessage(attrs) {
const _converse = this.__super__._converse;
if (this.get('omemo_active')) {
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions().then(bundles => this.createOMEMOMessageStanza(message, bundles)).then(stanza => this.sendMessageStanza(stanza));
this.getBundlesAndBuildSessions().then(devices => this.createOMEMOMessageStanza(message, devices)).then(stanza => this.sendMessageStanza(stanza)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
} else {
return this.__super__.sendMessage.apply(this, arguments);
}
......@@ -73643,6 +73701,12 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
'trusted': UNDECIDED
},
getRandomPreKey() {
// XXX: assumes that the bundle has already been fetched
const bundle = this.get('bundle');
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
},
fetchBundleFromServer() {
return new Promise((resolve, reject) => {
const stanza = $iq({
......@@ -73670,7 +73734,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
* this device, if the information is not at hand already.
*/
if (this.get('bundle')) {
return Promise.resolve(this.get('bundle').toJSON());
return Promise.resolve(this.get('bundle').toJSON(), this);
} else {
return this.fetchBundleFromServer();
}
......@@ -82446,6 +82510,10 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
return bytes.buffer;
};
u.getRandomInt = function (max) {
return Math.floor(Math.random() * Math.floor(max));
};
u.getUniqueId = function () {
return 'xxxxxxxx-xxxx'.replace(/[x]/g, function (c) {
var r = Math.random() * 16 | 0,
......@@ -82474,7 +82542,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
//
// This is the utilities module.
//
// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
// Licensed under the Mozilla Public License (MPLv2)
//
......@@ -10,6 +10,122 @@
describe("The OMEMO module", function() {
it("enables encrypted messages to be sent",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
function (done, _converse) {
var sent_stanza;
let iq_stanza;
test_utils.createContacts(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
// First, fetch own device list
return test_utils.waitUntil(() => {
return _.filter(
_converse.connection.IQ_stanzas,
(iq) => {
const node = iq.nodeTree.querySelector('iq[to="'+_converse.bare_jid+'"] query[node="eu.siacs.conversations.axolotl.devicelist"]');
if (node) { iq_stanza = iq.nodeTree;}
return node;
}).length;
}).then(() => {
const stanza = $iq({
'from': contact_jid,
'id': iq_stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {
'xmlns': 'http://jabber.org/protocol/disco#items',
'node': 'eu.siacs.conversations.axolotl.devicelist'
}).c('device', {'id': '482886413b977930064a5888b92134fe'}).up()
_converse.connection._dataRecv(test_utils.createRequest(stanza));
_converse.emit('OMEMOInitialized');
// Check that device list for contact is fetched when chat is opened.
test_utils.openChatBoxFor(_converse, contact_jid);
return test_utils.waitUntil(() => {
return _.filter(
_converse.connection.IQ_stanzas,
(iq) => {
const node = iq.nodeTree.querySelector('iq[to="'+contact_jid+'"] query[node="eu.siacs.conversations.axolotl.devicelist"]');
if (node) { iq_stanza = iq.nodeTree; }
return node;
}).length;
});
}).then(() => {
const stanza = $iq({
'from': contact_jid,
'id': iq_stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('query', {
'xmlns': 'http://jabber.org/protocol/disco#items',
'node': 'eu.siacs.conversations.axolotl.devicelist'
}).c('device', {'id': '555'}).up()
_converse.connection._dataRecv(test_utils.createRequest(stanza));
const devicelist = _converse.devicelists.create({'jid': contact_jid});
expect(devicelist.devices.length).toBe(1);
const view = _converse.chatboxviews.get(contact_jid);
view.model.set('omemo_active', true);
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'This message will be encrypted';
view.keyPressed({
target: textarea,
preventDefault: _.noop,
keyCode: 13 // Enter
});
return test_utils.waitUntil(() => {
return _.filter(
_converse.connection.IQ_stanzas,
(iq) => {
const node = iq.nodeTree.querySelector('iq[to="'+contact_jid+'"] items[node="eu.siacs.conversations.axolotl.bundles:555"]');
if (node) { iq_stanza = iq.nodeTree; }
return node;
}).length;
});
}).then(() => {
const stanza = $iq({
'from': contact_jid,
'id': iq_stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('pubsub', {
'xmlns': 'http://jabber.org/protocol/pubsub'
}).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
.c('item')
.c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
.c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
.c('signedPreKeySignature').t(btoa('2222')).up()
.c('identityKey').t(btoa('3333')).up()
.c('prekeys')
.c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
.c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
.c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
_converse.connection._dataRecv(test_utils.createRequest(stanza));
return test_utils.waitUntil(() => sent_stanza);
}).then(function () {
expect(sent_stanza.toLocaleString()).toBe(
`<message from='dummy@localhost/resource' to='max.frankfurter@localhost' `+
`type='chat' id='${sent_stanza.nodeTree.getAttribute('id')}' xmlns='jabber:client'>`+
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
`<encrypted xmlns='eu.siacs.conversations.axolotl'>`+
`<header sid='123456789'>`+
`<key>eyJpdiI6IjEyMzQ1In0=</key>`+
`<iv>12345</iv>`+
`</header>`+
`</encrypted>`+
`</message>`);
done();
});
}));
it("will add processing hints to sent out encrypted <message> stanzas",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
......@@ -24,8 +140,8 @@
function (done, _converse) {
let iq_stanza;
test_utils.createContacts(_converse, 'current');
const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.createContacts(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.waitUntil(function () {
return _.filter(
......
......@@ -93,86 +93,143 @@
return new Promise((resolve, reject) => {
_converse.getDevicesForContact(this.get('jid'))
.then((devices) => {
const promises = devices.map((device) => device.getBundle());
Promise.all(promises).then(() => {
this.buildSessions(devices)
.then(() => resolve(devices))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
Promise.all(devices.map((device) => device.getBundle()))
.then(() => this.buildSessions(devices))
.then(() => resolve(devices))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
},
buildSession (device) {
const { _converse } = this.__super__;
const bundle = device.get('bundle'),
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
prekey = device.getRandomPreKey();
return sessionBuilder.processPreKey({
'registrationId': _converse.omemo_store.get('registration_id'),
'identityKey': _converse.omemo_store.get('identity_keypair'),
'signedPreKey': {
'keyId': bundle.signed_prekey.id, // <Number>
'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
},
'preKey': {
'keyId': prekey.id, // <Number>
'publicKey': u.base64ToArrayBuffer(prekey.key),
}
})
},
buildSessions (devices) {
return Promise.all(devices.map((device) => this.buildSession(device)));
},
encryptMessage (plaintext) {
// The client MUST use fresh, randomly generated key/IV pairs
// with AES-128 in Galois/Counter Mode (GCM).
const TAG_LENGTH = 128,
iv = window.crypto.getRandomValues(new window.Uint8Array(16));
let key;
return window.crypto.subtle.generateKey({
'name': "AES-GCM",
'length': 256
},
true, // extractable
["encrypt", "decrypt"] // key usages
).then((result) => {
key = result;
const algo = {
'name': 'AES-GCM',
'iv': iv,
'tagLength': TAG_LENGTH
}
return window.crypto.subtle.encrypt(algo, key, new TextEncoder().encode(plaintext));
}).then((ciphertext) => {
return window.crypto.subtle.exportKey("jwk", key)
.then((key_str) => {
return Promise.resolve({
'key_str': key_str,
'tag': ciphertext.slice(ciphertext.byteLength - ((TAG_LENGTH + 7) >> 3)),
'iv': iv
});
});
});
},
encryptKey (plaintext, device) {
const { _converse } = this.__super__,
device_id = _converse.omemo_store.get('device_id');
return Promise.all(_.map(devices, (device) => {
const recipient_id = device['id'];
const address = new libsignal.SignalProtocolAddress(parseInt(recipient_id, 10), device_id);
const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
return sessionBuilder.processPreKey({
'registrationId': _converse.omemo_store.get('registration_id'),
'identityKey': _converse.omemo_store.get('identity_keypair'),
'signedPreKey': {
'keyId': '', // <Number>,
'publicKey': '', // <ArrayBuffer>,
'signature': '', // <ArrayBuffer>
},
'preKey': {
'keyId': '', // <Number>,
'publicKey': '', // <ArrayBuffer>
}
});
}));
address = new libsignal.SignalProtocolAddress(this.get('jid'), device.get('id')),
sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
return sessionCipher.encrypt(plaintext);
},
encryptMessage (message) {
// TODO:
// const { _converse } = this.__super__;
// const plaintext = message.get('message');
// const address = new libsignal.SignalProtocolAddress(recipientId, deviceId);
// return new Promise((resolve, reject) => {
// var sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
// sessionCipher.encrypt(plaintext).then((ciphertext) => {});
// });
addKeysToMessageStanza (stanza, devices, payloads) {
for (var i in payloads) {
if (Object.prototype.hasOwnProperty.call(payloads, i)) {
const payload = btoa(JSON.stringify(payloads[i]))
const prekey = 3 == parseInt(payloads[i].type, 10)
if (i == payloads.length-1) {
stanza.c('key', {'rid': devices.get('id') }).t(payload)
if (prekey) {
stanza.attrs({'prekey': prekey});
}
stanza.up().c('iv').t(payloads[0].iv).up().up()
} else {
stanza.c('key', {prekey: prekey, rid: devices.get('id') }).t(payload).up()
}
}
}
return Promise.resolve(stanza);
},
createOMEMOMessageStanza (message, bundles) {
createOMEMOMessageStanza (message, devices) {
const { _converse } = this.__super__, { __ } = _converse;
const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
"Find more information on https://conversations.im/omemo");
return new Promise((resolve, reject) => {
this.encryptMessage(message).then((payload) => {
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up()
.c('encrypted').t(payload)
.c('header').t(payload).up()
_.forEach(bundles, (bundle) => {
const prekey = bundle.prekeys[Math.random(bundle.prekeys.length)].textContent;
stanza('key', {'rid': bundle.identity_key}).t(prekey).up()
});
// TODO: set storage hint urn:xmpp:hints
resolve(stanza);
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
// An encrypted header is added to the message for each device that is supposed to receive it.
// These headers simply contain the key that the payload message is encrypted with,
// and they are separately encrypted using the session corresponding to the counterpart device.
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up()
.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
.c('header', {'sid': _converse.omemo_store.get('device_id')});
return this.encryptMessage(message).then((payload) => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
// TODO: need to include own devices here as well (and filter out distrusted devices)
const promises = devices.map(device => this.encryptKey(payload.key_str+payload.tag, device));
return Promise.all(promises).then((payloads) => this.addKeysToMessageStanza(stanza, devices, payloads));
});
},
sendMessage (attrs) {
const { _converse } = this.__super__;
if (this.get('omemo_active')) {
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions()
.then((bundles) => this.createOMEMOMessageStanza(message, bundles))
.then((stanza) => this.sendMessageStanza(stanza));
.then((devices) => this.createOMEMOMessageStanza(message, devices))
.then((stanza) => this.sendMessageStanza(stanza))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
} else {
return this.__super__.sendMessage.apply(this, arguments);
}
},
}
},
ChatBoxView: {
......@@ -439,6 +496,12 @@
'trusted': UNDECIDED
},
getRandomPreKey () {
// XXX: assumes that the bundle has already been fetched
const bundle = this.get('bundle');
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
},
fetchBundleFromServer () {
return new Promise((resolve, reject) => {
const stanza = $iq({
......@@ -467,7 +530,7 @@
* this device, if the information is not at hand already.
*/
if (this.get('bundle')) {
return Promise.resolve(this.get('bundle').toJSON());
return Promise.resolve(this.get('bundle').toJSON(), this);
} else {
return this.fetchBundleFromServer();
}
......
......@@ -883,6 +883,10 @@
return bytes.buffer
};
u.getRandomInt = function (max) {
return Math.floor(Math.random() * Math.floor(max));
};
u.getUniqueId = function () {
return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
var r = Math.random() * 16 | 0,
......
......@@ -3,7 +3,7 @@
//
// This is the utilities module.
//
// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
// Licensed under the Mozilla Public License (MPLv2)
//
/*global define, escape, Jed */
......
......@@ -8,6 +8,22 @@
var $iq = converse.env.$iq;
window.libsignal = {
'SignalProtocolAddress': function (name, device_id) {
this.name = name;
this.deviceId = device_id;
},
'SessionCipher': function (storage, remote_address) {
this.remoteAddress = remote_address;
this.storage = storage;
this.encrypt = () => Promise.resolve({
'iv': '12345'
});
},
'SessionBuilder': function (storage, remote_address) {
this.processPreKey = function () {
return Promise.resolve();
}
},
'KeyHelper': {
'generateIdentityKeyPair': function () {
return Promise.resolve({
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment