RocketChat / Rocket.Chat

The communications platform that puts data protection first.
https://rocket.chat/
Other
40.8k stars 10.72k forks source link

Off-the-Record (OTR) Messaging [$1,000] #36

Closed wanderer closed 2 years ago

wanderer commented 9 years ago

There is a $1,000 open bounty on this issue. Add to the bounty at Bountysource.

charlieman commented 9 years ago

You mean beyond https?

wanderer commented 9 years ago

it would be nice if it has some OTR for direct messaging

sampaiodiego commented 9 years ago

the idea of OTR implies in not saving the message on db?

engelgabriel commented 9 years ago

Off-the-Record (OTR) Messaging

Allows you to have private conversations over instant messaging by providing:

Encryption

No one else can read your instant messages.

Authentication

You are assured the correspondent is who you think it is.

Deniability

The messages you send do not have digital signatures that are checkable by a third party. Anyone can forge messages after a conversation to make them look like they came from you. However, during a conversation, your correspondent is assured the messages he sees are authentic and unmodified.

Perfect forward secrecy

If you lose control of your private keys, no previous conversation is compromised.

kumavis commented 9 years ago

I don't know if this implementation has been audited or not but https://github.com/arlolra/otr

Calinou commented 9 years ago

Warning

This library hasn't been properly vetted by security researchers. Do not use in life and death situations!

kumavis commented 9 years ago

And further reading on the state of otr https://github.com/arlolra/otr/issues/59

comigor commented 9 years ago

I think otr is the best option we have, audited or not, cryptocat uses it.

As said on arlolra/otr#59, pointed by @kumavis, just few other implementations are available, and otr4-em and node-otr4 aren't well compatible with Meteor, because we can't use client-side node packages.

comigor commented 9 years ago

It's indeed very easy to implement OTR using arlolra/otr. Just made a simple PoC Igor1201/otr.

engelgabriel commented 9 years ago

Looks like this feature has more people interested than I expected. I'll raise its priority. :+1:

engelgabriel commented 9 years ago

Can any of you guys do a Pull Request or create a fork with a proposed implementation?

engelgabriel commented 9 years ago

Hi @Igor1201 can we work together to get your PoC to work on Rocket.Chat?

engelgabriel commented 9 years ago

See #268

taoeffect commented 9 years ago

Problem with OTR is that it only works while both users are online.

I recommend using Axolotl instead, developed by Open WhisperSystems of TextSecure/Signal fame. It's like OTR but it supports secure asynchronous messaging, which is important because it's rare that everyone is online all the time. :)

There are lots of Axolotl libraries out there, available for both iOS, Android, and Web.

kdar commented 9 years ago

I agree with @taoeffect. OTR isn't optimal here.

taoeffect commented 9 years ago

FYI, this feature, regardless of how you implement it, really only makes sense for DMs (for now). Encrypting group conversations is extremely difficult, and with today's tech it would result in most of the other features of RocketChat having to be removed (like search).

thoys commented 9 years ago

You could have 2 types of groups, insecure groups and OTR groups.

taoeffect commented 9 years ago

You could have 2 types of groups, insecure groups and OTR groups.

The problem is that "OTR groups" do not exist (to my knowledge). Axolotl has support for groups. There is also np1sec being developed: https://equalit.ie/portfolio/np1sec/

infinitnet commented 8 years ago

I'd love to see this. If not for whole groups, it would be a good first step to at least add OTR for personal messages where only 2 endpoints are involved. That should be easy enough and great to keep private messages actually private. Being able to read everything as a sysadmin is not ideal and actually something that would keep us from using this software.

lazyfrosch commented 8 years ago

@cryptono how about safely storing chat history for private chats on the server? I would like to see a way to keep the (encrypted) history on server, so I can read it on multiple devices.

Of course this would require the user a way to remove the data, and decide how long to store the encrypted blobs.

mitar commented 8 years ago

Can somebody first explain what is a threat model here? Against which type of an attacker are we trying to defend by doing OTR?

engelgabriel commented 8 years ago

Anyone that can get access to the server DB or application... as all the messages are stored there. I guess we could use GPG Keychain so only the clients would know how to decrypt the messages.

mitar commented 8 years ago

Can you be a bit more precise? You mean read-only access server? Can they modify files? Could they for example modify JavaScript on the server, so that client loads malicious JavaScript, which once they submit a message for encryption on the client side, it is also send to their server unencrypted?

Do we want then messages to work only when both parties are connected (which is what OTR is) or do we want to store them in the database so that you can load history (which OTR is not). We can have the latter, but then again, we have to be able to decrypt them, so attacker can just client side as an oracle to do that for them, by sending them compromised code.

So, the easy approach is to prevent DB dumps from containing messages in plain text. But if you have an attacker which can control the server, things become much more complicated. I would be interested in helping here and in my research group we have done some preliminary work on securing Rocket.Chat. But it would also be important to understand what is a threat model community is interested here. Do we care only about maybe your employee not being able to see messages? Maybe then integration with lets encrypt for Docker images is enough. Do we just want to prevent the server to be subpoenaed to get logs? Then simply not storing any logs is also good a solution.

So, what are we trying to achieve. What is a threat model?

lazyfrosch commented 8 years ago

The thread model is that no admin should be able to read private messages of the users. They lay in plaintext within the history store.

Usually user want to have a good basis for private communication, and a way to communicate open in teams.

Example:

You wouldn't want these chats to be readable by an admin. The alternative would be to use an alternative channel, but why would I want that when I wanna provide a centralized communications tool for my group?

mitar commented 8 years ago

The thread model is that no admin should be able to read private messages of the users. They lay in plaintext within the history store.

Isn't this a contradictory statement? If they lay in plaintext withing the history store, then admins can read them?

mitar commented 8 years ago

What are capabilities of admins? Can they modify the code which is served to clients? Can they access the database with full rights?

lazyfrosch commented 8 years ago

The easiest way without traces is a look in the database, changing clients should get noticed eventually.

When the secret key is only in a users client, the server has few ways to read it.

mitar commented 8 years ago

People, please. Let's first determine the threat model and then discuss how to achieve security against that threat model. Let's not talk about approaches yet.

And no, changing clients with all the minimized code might not get noticed eventually. Or very late in the game. And maybe it is not even a problem if it gets noticed (employer can just say that yes, they are doing it, your problem).

When the secret key is only in a users client, the server has few ways to read it.

So what? You do not have to have a key if I can trick you to decrypt messages for me.

konsumate commented 8 years ago

This topic is important and I see multiple streams beeing discussed here, probably not everyone is aware of it.

Just a suggestion, OTR would still do the job IF it is for online-2-online communication only, no storing messages. As my @mitar wrote already, there are a few threat models beeing discussed here and we need to split them up.

PiNotEqual3 commented 8 years ago

I agree with the thread model from lazyfrosch: "no admin should be able to read messages".

Communication needs end2end encryption. It is a must have.

Yes, the server admin is in a good position to attack the clients. Still he can not change mobile apps and also web clients can check for code changes. In the time of cloud servers you should not think the server is secure.

I strongly vote for Axolotl instead of OTR. OTR has no group chat, no offline messaging, no multiple devices, ... it just does not do the job. Yes, there is mpOTR with more features but it has been proven many times that cryptocat is not secure.

Axolotl brings all the features you need and it should be possible to use it as default.

mitar commented 8 years ago

also web clients can check for code changes.

How? In theory yes, but in practice this has not yet been developed. Also, the question is how to check for code changes which are normal updates and check for code changes which are malicious updates.

The importance of having clear threat model is to understand the engineering efforts needed to achieve it. Sometimes, those efforts are prohibitively expensive.

I would suggest the following threat model: if attacker gets access to the server, then they can intercept only messages happening at the moment, but not messages which were done in the past. Moreover, if one of the clients in the chat room are malicious, then only messages while that client is in the chat room are compromised. This means that if the admin is malicious, you all messages is compromised. But for a case where admin is not malicious, but is later on forced to cooperate, or server is compromised, only future messages are compromised.

PiNotEqual3 commented 8 years ago

To your thread model I would add read access to the database because this is very common.

also web clients can check for code changes.

How? In theory yes, but in practice this has not yet been developed. Also, the question is how to check for code changes which are normal updates and check for code changes which are malicious updates.

Yes, web client is tricky. Not sure how rocket.chat works but in theory the web client can run from local files, another web server or a browser extension. I know in old days you always trust the server and his admin but I feel today this becomes more and more dangerous.

Sing-Li commented 8 years ago

I would suggest that we are slightly off topic.

Agreed that threat model analysis is extremely important in coming up with a workable security implementation for Rocket.Chat.

But in the spirit of OTR (topic of this thread) - Rocket.Chat can simply serve as a blind 'point-to-point transport'. If we toss ALL usage conveniences aside, and aim ONLY at one to one communication. A user can paste an externally encrypted message into Rocket.Chat. Reading that message requires decrypting that is external to Rocket.Chat.

Furthermore, we can allow this sort of blindly transported messages ONLY in DM and enforce that they never hit the database or log (zero persistence) to assist in mitigating historical messages concerns.

Just trying hard here to nail down something that is implementable in-reasonable-time and also be useful for an audience interested ONLY in OTR styled behaviors.

Chaos99 commented 8 years ago

I'd like to back the Axolotl proposal. Signal implemented Multi-User-Multi-Device chats with on-device history storage in their desktop app now showing that it can be done. I'd rather see the encryption layer implemented here with RocketChat instead of asking for all the convenience features like file transfer to be added to Signal.

Do you think this is worth it's own ticket? (Being not exactly OTR)

mitar commented 8 years ago

I'd like to back the Axolotl proposal. Signal implemented Multi-User-Multi-Device chats with on-device history storage in their desktop app now showing that it can be done. I'd rather see the encryption layer implemented here with RocketChat instead of asking for all the convenience features like file transfer to be added to Signal.

But Signal has a trusted client (or, more precisely, they bootstrap the trusted client of app stores trust, if app stores get compromised/forced to provide malicious versions you have a problem as well). So even the best protocol for encryption does not help you if attacker can tell you which code to run. If you want to protect against malicious admin, but admin is serving you files, what is the point?

Maybe the point would be that prevention is not important, just detection of an attack?

To conclude, security does not work like this. You cannot just combine arbitrary secure components to make things work. It has to be secure in all aspects. Attackers will attack the weakest link, not the hardest one you would like them to attack. Encryption is often not attacked directly, but just make not work correctly.

mitar commented 8 years ago

Just trying hard here to nail down something that is implementable in-reasonable-time and also be useful for an audience interested ONLY in OTR styled behaviors.

Yes, this is what my threat model would also describe. So let's make simple DM encryption and for those messages nothing is stored on the server. Or even, you can still store it on the server (so that you can reuse Meteor pub/sub), just that it is stored in the database with temporary session key based on ephemeral keys negotiated every time for that particular session of communicating between two parties.

So no messages are available if later on server is compromised. But I would suggest that at least for now trying to protect against malicious admin at the moment of communication (moment of serving files) should be out of the scope.

Also keep in mind that both parties have to be online at the same time for this to really work.

So I think this could be done with relatively low engineering efforts with what currently Web Crypto provides. You just create an a temporary symmetric key between parties and use the key to encrypt messages for that session. Users can have a button to force renegotiation if they want. Otherwise the key is renegotiated every so and so. You store messages normally in the database and keep the rest of the stack intact. So from the perspective of the user is that client sends a special message to the other user to negotiate the session key, once it gets back the other part of the key, it starts encrypted. Server does not have to do anything extra for this. They just keep relaying messages as they were doing before.

mitar commented 8 years ago

BTW, this would probably also not protect against the MITM attack. Otherwise clients would have to have some type of crypto identity. (Using Keybase?)

mitar commented 8 years ago

So this would protect again:

ghost commented 8 years ago

Just an insight. Independently of whathever is going to be implemented, for compability with irc, xmpp, etc. OTR would be really useful.

Sing-Li commented 8 years ago

@Chaos99 Yes. It is definitely worth another ticket. Please create it. I am thinking in the not too distant future when we have truly native mobile clients (non-web based) where it might be possible to have manually downloaded trusted signed client binaries from recognized authoritative source (or even physical courier delivery of trusted binaries on CD or USB keys for example).

@Mitar Thank you for the thorough analysis! Agreed and sold :smile: Hopefully we get support from @engelgabriel in the coming week to start development based on Web Crypto. Counting on your assistance moving this forward :+1:

mitar commented 8 years ago

passive attackers (but for those HTTPS should be enough)

One more thing. This is true only if one uses HTTPS with forward-secrecy. If HTTPS is not configured so (bad in general), then Rocket.Chat encryption would still help.

mitar commented 8 years ago

The question is what to do with private group chats. We could do something where all currently logged-in parties for a give private group chat negotiate a shared key between them as well. Maybe that could be the next step.

marceloschmidt commented 8 years ago

We've just made this TOP priority! @mitar any chance you could help us with development? We're going for the Web Crypto alternative, encrypting only DMs, for now.

Sing-Li commented 8 years ago

@mitar -- what to do about level of support for Web Crypto across browsers?
http://caniuse.com/#feat=cryptography
Any keys management library that you can recommend? Green lights - time to get the OTR ball rolling :grin:

mitar commented 8 years ago

(Sorry for a long comment, but I am trying to summarize some things here to be clear.)

OK, full disclosure. I know about this a lot because I have spend past few months working on something very similar for Rocket.Chat for an academic paper we wrote. But the paper is still in the peer-review phase so I cannot yet disclose things and contribute the code upstream. Moreover, the approach we took is different than the one I proposed above. But this is why I looked deep into various threat models around Rocket.Chat and advantages and disadvantages of various solutions.

Furthermore, we also developed a browser extension which will solve the problem of assuring that client code can be trusted and not compromised by the malicious server admin. From questions I noticed @moxie0 is asking around the Internet, I suspect they are doing something similar for Signal as well. We made our extension generic, and it would be sad to duplicate work here, but I am not sure how to coordinate efforts here. To my knowledge is the only one in existence which combines all the necessary parts together into a working and secure solution.

(I hope none of paper reviewers will be reading this comment.)

So I am a bit torn apart between the best approach to proceed here. :-)

I see four options:

  1. we implement some Rocket.Chat specific DM messaging only using WebCrypto
  2. we implement standard OTR DM messaging only using WebCrypto
  3. we implement Axolotl messaging and leave the issue of compromised server hosting malicious code to be addressed later, by for example using the extension I mentioned above; also, mobile apps can do this without an extension
  4. we integrate the solution we developed for the paper

Those options are not necessary mutually exclusive, but they do add extra complexity and engineering costs.

One issue which you discover once you start encrypting messages is that usability degrades on some things you are taking for granted. For example: full text search over messages (but we do not want history anyway, no?), and notifications of mentions in messages. How you do that without degrading security? I personally do not yet have a clean solution to this.

There are also side-channel attacks with messaging, like getting information from just the length of cryptotexts. And also the fact that Rocket.Chat post-processes messages on the server-side to render Markdown, and so on. All this makes things pretty tricky. The whole architecture of the Rocket.Chat is not very helpful for a secure implementation, BTW. So the question is how much of these changes to we want to do, to improve this.

Let me list some advantages and disadvantages, as I see them, for the options presented above. I am not in-depth familiar with Axolotl protocol, so I might miss some advantages and disadvantages.

  1. our own DM
    • Advantages:
      • relatively easy and straightforward to implement from the coding perspective
      • you can use crypto primitives available in Web Crypto
      • could easily support special features provided by Rocket.Chat and not existing in other systems
    • Disadvantages:
      • developing your own protocol is error-prone, you have to thing about many aspects besides just crypto, like preventing replay attacks, side-channels, etc.
      • it is not compatible with other messaging systems
      • we might have to reimplement some of features available in other systems on our own
  2. using standard OTR
    • Advantages:
      • compatible with other messaging systems
      • could potentially reuse some existing OTR JavaScript library
    • Disadvantages:
      • because of limitation of Web Crypto, you might not have all needed crypto primitives available
      • making byte compatible implementations with other messages systems might be complicated and take time (especially because Rocket.Chat integration with them is not yet stable, to my knowledge)
      • all those integrations with other messages systems where we want OTR compatibility would probably have to have some knowledge of OTR messages, initialization, etc.
      • we would probably be limited to lower common denominator in features between various systems and could not use Rocket.Chat specific features (I am not sure which one those would be, though)
  3. Axolotl
    • Advantages:
      • compatible with other Axolotl systems
      • support for multi-user rooms
      • could reuse existing JavaScript implementation
    • Disadvantages:
      • not yet widely used
      • to my knowledge not yet 3rd party audited and verified, nor the existing implementation nor the protocol
  4. approach from the paper
    • Advantages:
      • a threat model with a very powerful attacker
      • most functionalities of Rocket.Chat are kept
    • Disadvantages:
      • it is experimental
      • not yet available
      • much more complex than anything above
      • I do not think all users will like the trade-offs we chose and requirements needed for it to work (but we do have a threat model with a very powerful attacker)
      • it is more a proof-of-concept implementation at current stage and would require a lot of extra work to get it to production grade implementation
      • uses ad-hoc security protocols without any formal proofs (yet), again, proof-of-concept

what to do about level of support for Web Crypto across browsers?

Good question. But we cannot really do much here, except use pure JavaScript implementations (which might not be a problem because messages are short in length and thus crypto operations will not take too much CPU). The question is also about particular algorithms implemented by browsers. So Web Crypto is just an interface which defines a set of algorithms, but not necessary all browsers implement all of those. But this is also converging through time.

One thing to keep in mind is how to make server and client use isomorphic code for web crypto. We used this package to be able to use the same APIs in Meteor for crypto both in browser and server, but it is really hacky work and more or less a proof of concept. I would be vary of using it in production without some real security audit and a lot more work. Not sure if we really need crypto on the server though, but sometimes you do want to do some parts on the server as well.

Any keys management library that you can recommend?

What exactly you mean with this? What type of key management? For the 1. approach above you would negotiate keys every time from scratch. The consequence is that MITM active attacks would be possible, but messages stored in the server after the end of the session would not be possible to be decrypted. Of course, this means no history as well.

Sing-Li commented 8 years ago

@mitar, thank you for the extremely informative and well researched summary. Not a single word is wasted. We all learnt a whole lot just from reading through it.

Your disclosure fully eased our concern on how someone can have such thorough contemporary knowledge on this niche difficult-to-research topic and yet also be willing to assist us in its implementation.

The green light is ON for an immediate implementation of OTR as top priority.

In Rocket.Chat’s terms, it means a commitment of all necessary resources to the task, until it is done.

Currently, @marceloschmidt - our resident Rocket.Chat code magician (he is the guru I go to when I have question about where certain code may be located) is leading the charge, supported by other code masters on the team.

This will be an implementation as detailed in your Alternative 1 (our own DM). And in fact, the first iteration will be completed within the next 24 to 48 hours.

We would count on your assistance to review and help with the crypto related matters and some suggestions on testing and verification. If possible, can you please come hang-out with us on the public channel - https://demo.rocket.chat/channel/otr ? Otherwise, we will attempt to reach you via all possible legacy means. (we realize there is a time zone difference between us)

Our goal is to have a working OTR implementation by the next Rocket.Chat release (Monday March 14).

In the longer term, there is no doubt that we want to pursue Alternative 3 Axolotl (already subject matter of another ticket #2430) – becoming a ‘feature rich’ equivalence of Signal in the future. As you have pointed out, it will be a long road ahead due to the architectural changes that might be necessary.

After the initial OTR implementation, we will continue to work with you on identifying the Rocket.Chat elements that need to be reviewed and possibly modified towards that goal. Together, we will define a set of work items (issues) . And over time, count on the core team, and perhaps other contributors and security researchers/experts in the community – to get them vetted and implemented. It is understood that there might be conflicting objectives between improved Rocket.Chat performance and support for Axolotl; and we might have to re-visit the possibility of having different editions at that time.

Your academic paper project is very exciting. Please let us know once it can become fully public information. At that time, we can review together how we might get it integrated into core Rocket.Chat.

We want to thank you again for this AMAZING work, and are basically speechless – and find ourselves of better use by focusing back on coding :+1:

mitar commented 8 years ago

Ah, I also started looking into it. Just adding a new message type to text and html, so that we bypass all processing functions. And have first control messages exchange and then data. :-) And use localStorage on the client to store client's key, if we want to reuse it. We could also store peer's key and maybe trust it on first use. Alternatively, you could use sessionStorage if you do not want to have permanent per-device keys for users.

I will join the chat.

Just to be clear, this is not OTR implementation then, so not sure if this really resolves this ticket. OTR had quite some iterations on the protocol for many good ideas and improvements.

marceloschmidt commented 8 years ago

I've started building something, which can be seen in #2457. I don't know the specific requirements for it to be considered OTR, but that's what I've been calling it ;) lol. I started by using the Notifications stream to send a "handshake" message to the peer. Then the peer will send a "acknowledge" back. Both streams contain the sender's public key, which are then stored in the client. I've created a local object RocketChat.OTR to store these details (own's privatekey and publickey, peer's public key and whether it's established or not). I've also created a "onClientBeforeSend" callback that will encrypt the messages and a "onClientBeforeRender" that should decrypt. This last part is not working yet, so the PR is still WIP.

marceloschmidt commented 8 years ago

I've updated the code and now encrypt / decrypt fully works, even with UTF-8 characters. The bad thing is that I used RSA all over the place, which is slow and has a size limit.

My next steps are:

  1. Change the name from OTR to something else encrypt-related
  2. Use RSA to exchange a secret key, to be used with AES
  3. Use AES-CBC to encrypt messages faster and theoretically infinite in length.

Let me know if you think of anything else.

mitar commented 8 years ago

I think you can keep calling it OTR, just say that this is OTR protocol version Rocket.Chat. ;-) But it is true that we will not have all the properties of OTR. Maybe client-encryption?

OK, I do not have time to go into precise protocol design mode at the moment, but you are doing it wrong (but I am amazed how quickly you did all the stuff, so you are doing it right from the API perspective, but just choice of crypto primitives is not the right one). The main property we want from this encryption is forward secrecy. You are not getting that with RSA. If a key is compromised later on, one can decrypt everything from day one.

So, let me give you some basic ideas, if something is unclear help. But just so that you have some material to work with. The main idea is that you generate a symmetric key which is used to encrypt messages, but that symmetric key is generated without storing it really anywhere. So when both clients forget about it (session is closed), there is no way to retrieve it from the database.

Each peer should on the client generate an ephemeral key pair:

// Generate an ephemeral key pair.
crypto.subtle.generateKey({
  name: 'ECDH',
  namedCurve: 'P-256'
}, false, ['deriveKey', 'deriveBits']).then(function (keyPair) {
  return crypto.subtle.exportKey('spki', keyPair.publicKey);
}).function (exportedPublicKey) {
  // Send it to the other peer.
});

I simply use SPKI export type, because JWK seems bloated to me. :-) You probably have to wrap it into new Uint8Array(exportedPublicKey) to have it EJSON-able. (You can +1 this ticket to get EJSON to support promse-based values and then you could simply send the key directly, and it would be exported internally when doing EJSON serialization.)

OK, so now each side has its own key pair, and a public key of the peer. You can now generate some bits to use it for your session/symmetric/temporary key.

crypto.subtle.importKey('spki', peerPublicKeyContent, {
  name: 'ECDH',
  namedCurve: 'P-256'
}, false, []).then(function (peerPublicKey) {
  return crypto.subtle.deriveBits({
    name: 'ECDH',
    namedCurve: 'P-256',
    public: peerPublicKey
  }, keyPair.privateKey, 256);
}).then(function (bits) {
  return crypto.subtle.digest({
    name: 'SHA-256'
  }, bits);
}).then(function (hashedBits) {
  // We truncate the hash to 128 bits.
  var sessionKeyData = new Uint8Array(hashedBits).slice(0, 16);
  return crypto.subtle.importKey('raw', sessionKeyData, {
    name: 'AES-GCM'
  }, false, ['encrypt', 'decrypt']);
}).then(function (sessionKey) {
  // Session key available.
});

You can do then encryption with:

clearText = new Uint8Array(clearText);
var serial = nextSerialForThisPeer();
var data = new Uint8Array(1 + 1 + serial.length + clearText.length);
if (isFirstPeer()) {
  data[0] = 1;
}
data[1] = serial.length;
data.set(serial, 2);
data.set(clearText, 2 + serial.length);

var iv = crypto.getRandomValues(new Uint8Array(12));

return crypto.subtle.encrypt({
  name: 'AES-GCM',
  iv: iv
}, sessionKey, data).then(function (cipherText) {
  cipherText = new Uint8Array(cipherText);
  var output = new Uint8Array(iv.length + cipherText.length);
  output.set(iv, 0);
  output.set(cipherText, iv.length);
  return output;
});

Decryption:

cipherText = new Uint8Array(cipherText);

var iv = cipherText.slice(0, 12);
cipherText = cipherText.slice(12);

return crypto.subtle.decrypt({
  name: 'AES-GCM',
  iv: iv
}, sessionKey, cipherText).then(function (data) {
  data = new Uint8Array(data);

  if (isFirstPeer()) {
    if (data[0] !== 0) throw new Error("Can decrypt only encrypted data from the second peer.");
  }
  else {
    if (data[0] !== 1) throw new Error("Can decrypt only encrypted data from the first peer.");
  }

  var serial = data.slice(2, 2 + data[1]);
  var clearText = data.slice(2 + data[1]);

  // To copy over and make sure we do not have a shallow slice with simply non-zero byteOffset.
  serial = new Uint8Array(serial);
  clearText = new Uint8Array(clearText);

  // This prevents any replay attacks. Or attacks where messages are changed in order.
  if (!isOneLarger(serial, lastSerial)) throw new Error("Invalid serial.");
  storeLastSerial(serial);

  return clearText;
});

I think this would be a good first step. There is an issue of side-channel. You might want to add random padding to all messages which you append to a message, encrypt, but then throw away on the other side. Attackers would now have harder time knowing what is in the message. (Not sure if random padding is enough though. Especially if it is uniformly distributed randomness.)

I think this is a good first step. Next step is to add something for clients to know if the peerPublicKeyContent they are getting is really from the their peer and not somebody doing MITM attack. But this can then be improved later on. For example, peerPublicKeyContent can be signed by each client with another set of keys. For that you can generate ECDSA keys (SHA-256 as a parameter is OK) on both sides and use it to sign peerPublicKeyContent. Now, the question of course is how to trust the ECDSA keys. :-) What we could do for now is simply send public key to each other and trust them on first use. Clients would remember their ECDSA keys into localStorage on their device. Those keys (for signing only) would be reused, but the keys used to generate the session key would never be reused (and can even be regularly cycled even during one session). Each client could then store (username, public ECDSA key) mapping on each device.

You could add a way to see the fingerprint of the peer's public ECDSA key before they confirm it, but for most people this is magic. But some people could use SMS or e-mail for example to send the fingerprint to their peer (so called out-of-band communication). So, I would allow that users do not have to know anything about fingerprints, but I would also allow that power users can have this extra step of protection.

An open issue here is what if attacker tricks the client to think that the other user is using some other username or something. So confuse users about usernames. This is why some extra metadata should be stored along with the public ECDSA key when it is signed. Something like:

publicKey = new Uint8Array(publicKey);

var userIdArray = convertUserIdToArray(userId);
var data = new Uint8Array(userIdArray.length + publicKey.length);
data.set(userIdArray, 0);
data.set(publicKey, userIdArray.length);

return sandbox.crypto.subtle.sign({
  name: 'ECDSA',
  hash: {
    name: 'SHA-256'
  }
}, signingPrivateKey, data);
var userIdArray = convertUserIdToArray(userId);
var data = new Uint8Array(userIdArray.length + peerPublicKey.length);
data.set(userIdArray, 0);
data.set(peerPublicKey, userIdArray.length);

return crypto.subtle.verify({
  name: 'ECDSA',
  hash: {
    name: 'SHA-256'
  }
}, peerSigningPublicKey, peerPublicKeySignature, data).then(function (isValid) {
  if (!isValid) {
    throw new Error("Peer public key signature mismatch.");
  }
});