There's a possible MITM attack between client and server I've discovered some time ago. To be honest, it's not much of a problem because messages between clients are still end-to-end encrypted and cannot be decrypted by the server at all. It only works on unsecure WebSocket servers (no TLS) or WebSocket servers with broken TLS. Still, it makes our transport encryption look rather pointless.
Terminology
C = Client
S = Server
A = Attacker
MITM Attack (Initiator)
A generates two key pairs, a1 and a2. pk_a1 is the public key of a1 and sk_a1 is the secret key of a1.
A replaces the path the initiator provided by pk_a2.
S <-- Path: pk_a2 ---------- A <-- Path: pk_c ------------ C
A replaces the key of the server in server-hello by pk_a1.
S --- server-hello: pk_s --> A --- server-hello: pk_a1 --> C
A decrypts client-auth by box(sk_a1, pk_c)
, then encrypts and sends it to the server by box(sk_a2, pk_s)
.
S <-- client-auth ---------- A <-- client-auth ----------- C
A decrypts server-auth by box(sk_a2, pk_s)
, then encrypts and sends it to the initiator by box(sk_a1, pk_c)
. Any further messages between initiator and server can be decrypted and modified by A.
S --- server-auth ---------> A --- server-auth ----------> C
The benefit for A is questionable at best because A needs to change the path. Therefore, no responder would be able to connect to the initiator. Still, A can take the role of the server for the initiator.
MITM Attack (Responder)
A generates two key pairs, a1 and a2. pk_a1 is the public key of a1 and sk_a1 is the secret key of a1.
A does not need to change the path for responders as the path does not take part during authentication of responders.
S <-- Path: pk_i ----------- A <-- Path: pk_i ------------ C
A replaces the key of the server in server-hello with pk_a1.
S --- server-hello: pk_s --> A --- server-hello: pk_a1 --> C
A replaces the key of the responder in client-hello with pk_a2.
S <-- client-hello: pk_a2 -- A <-- server-hello: pk_c ---- C
A decrypts client-auth by box(sk_a1, pk_c)
, then encrypts and sends it to the server by box(sk_a2, pk_s)
.
S <-- client-auth ---------- A <-- client-auth ----------- C
A decrypts server-auth by box(sk_a2, pk_s)
, then encrypts and sends it to the responder by box(sk_a1, pk_c)
. Any further messages between responder and server can be decrypted and modified by A.
S --- server-auth ---------> A --- server-auth ----------> C
In this case, the benefit for A is much better than with an initiator as the path does not need to be changed. Therefore, clients that communicate with one another via the server would not even know that their communication is completely visible to A. Still, the benefits are marginal: A would be able to announce new initiators, drop responders and let responders repeat messages.
Mitigation
So, my idea is to add an OPTIONAL but RECOMMENDED section to the server.
The server has an OPTIONAL permanent key pair (sk_sp and pk_sp). Clients know the public key of the server's permanent key pair. The key has no lifetime or any other fancy certificate stuff, it should be as simple as possible.
In the server-auth, we add another field that is REQUIRED to be set by the server if he has a permanent key pair. That field, named signed_keys contains sign(pk_s || pk_c, sk_sp)
. The client verifies that signature. (Note: I've edited my previous stupid idea here)
And that's it. An attacker would need to have the permanent key pair to sign its own keys.
Discussion
Have I missed something? Is signing the concatenation of the public keys enough to mitigate replay attacks? Or should we add a x byte random field as well and add that to the concatenated data? @dbrgn