diff --git a/src/main/java/com/pusher/client/channel/PrivateEncryptedChannelEventListener.java b/src/main/java/com/pusher/client/channel/PrivateEncryptedChannelEventListener.java index a6aeeaae..119fc58a 100644 --- a/src/main/java/com/pusher/client/channel/PrivateEncryptedChannelEventListener.java +++ b/src/main/java/com/pusher/client/channel/PrivateEncryptedChannelEventListener.java @@ -6,5 +6,5 @@ */ public interface PrivateEncryptedChannelEventListener extends PrivateChannelEventListener { - // TODO: add onDecryptionFailure + void onDecryptionFailure(String event, String reason); } diff --git a/src/main/java/com/pusher/client/channel/impl/ChannelImpl.java b/src/main/java/com/pusher/client/channel/impl/ChannelImpl.java index 4ae849b6..b6f41309 100644 --- a/src/main/java/com/pusher/client/channel/impl/ChannelImpl.java +++ b/src/main/java/com/pusher/client/channel/impl/ChannelImpl.java @@ -89,33 +89,29 @@ public boolean isSubscribed() { /* InternalChannel implementation */ + @Override + public PusherEvent prepareEvent(String event, String message) { + return GSON.fromJson(message, PusherEvent.class); + } + @Override public void onMessage(final String event, final String message) { if (event.equals(SUBSCRIPTION_SUCCESS_EVENT)) { updateState(ChannelState.SUBSCRIBED); - } - else { - final Set listeners; - synchronized (lock) { - final Set sharedListeners = eventNameToListenerMap.get(event); - if (sharedListeners != null) { - listeners = new HashSet(sharedListeners); - } - else { - listeners = null; - } - } - + } else { + final Set listeners = getInterestedListeners(event); if (listeners != null) { - for (final SubscriptionEventListener listener : listeners) { - final PusherEvent e = GSON.fromJson(message, PusherEvent.class); - factory.queueOnEventThread(new Runnable() { - @Override - public void run() { - listener.onEvent(e); - } - }); + final PusherEvent pusherEvent = prepareEvent(event, message); + if (pusherEvent != null) { + for (final SubscriptionEventListener listener : listeners) { + factory.queueOnEventThread(new Runnable() { + @Override + public void run() { + listener.onEvent(pusherEvent); + } + }); + } } } } @@ -213,4 +209,18 @@ private void validateArguments(final String eventName, final SubscriptionEventLi "Cannot bind or unbind to events on a channel that has been unsubscribed. Call Pusher.subscribe() to resubscribe to this channel"); } } + + protected Set getInterestedListeners(String event) { + synchronized (lock) { + + final Set sharedListeners = + eventNameToListenerMap.get(event); + + if (sharedListeners == null) { + return null; + } + + return new HashSet<>(sharedListeners); + } + } } diff --git a/src/main/java/com/pusher/client/channel/impl/InternalChannel.java b/src/main/java/com/pusher/client/channel/impl/InternalChannel.java index e1668b7a..b6d8e032 100644 --- a/src/main/java/com/pusher/client/channel/impl/InternalChannel.java +++ b/src/main/java/com/pusher/client/channel/impl/InternalChannel.java @@ -3,6 +3,7 @@ import com.pusher.client.channel.Channel; import com.pusher.client.channel.ChannelEventListener; import com.pusher.client.channel.ChannelState; +import com.pusher.client.channel.PusherEvent; public interface InternalChannel extends Channel, Comparable { @@ -10,6 +11,8 @@ public interface InternalChannel extends Channel, Comparable { String toUnsubscribeMessage(); + PusherEvent prepareEvent(String event, String message); + void onMessage(String event, String message); void updateState(ChannelState state); diff --git a/src/main/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImpl.java b/src/main/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImpl.java index 2d54cd85..66a9c329 100644 --- a/src/main/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImpl.java +++ b/src/main/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImpl.java @@ -5,11 +5,13 @@ import com.pusher.client.channel.ChannelState; import com.pusher.client.channel.PrivateEncryptedChannel; import com.pusher.client.channel.PrivateEncryptedChannelEventListener; +import com.pusher.client.channel.PusherEvent; import com.pusher.client.channel.SubscriptionEventListener; import com.pusher.client.connection.ConnectionEventListener; import com.pusher.client.connection.ConnectionState; import com.pusher.client.connection.ConnectionStateChange; import com.pusher.client.connection.impl.InternalConnection; +import com.pusher.client.crypto.nacl.AuthenticityException; import com.pusher.client.crypto.nacl.SecretBoxOpener; import com.pusher.client.crypto.nacl.SecretBoxOpenerFactory; import com.pusher.client.util.Factory; @@ -17,6 +19,7 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; public class PrivateEncryptedChannelImpl extends ChannelImpl implements PrivateEncryptedChannel { @@ -124,6 +127,69 @@ public void updateState(ChannelState state) { } } + @Override + public PusherEvent prepareEvent(String event, String message) { + + try { + return decryptMessage(message); + } catch (AuthenticityException e1) { + + // retry once only. + disposeSecretBoxOpener(); + authenticate(); + + try { + return decryptMessage(message); + } catch (AuthenticityException e2) { + // deliberately not destroying the secretBoxOpener so the next message + // has an opportunity to fetch a new key and decrypt + notifyListenersOfDecryptFailure(event, "Failed to decrypt message."); + } + } + + return null; + } + + private void notifyListenersOfDecryptFailure(final String event, final String reason) { + Set listeners = getInterestedListeners(event); + if (listeners != null) { + for (SubscriptionEventListener listener : listeners) { + ((PrivateEncryptedChannelEventListener)listener).onDecryptionFailure( + event, reason); + } + } + } + + private class EncryptedReceivedData { + String nonce; + String ciphertext; + + public byte[] getNonce() { + return Base64.decode(nonce); + } + + public byte[] getCiphertext() { + return Base64.decode(ciphertext); + } + } + + private PusherEvent decryptMessage(String message) { + + Map receivedMessage = + GSON.>fromJson(message, Map.class); + + final EncryptedReceivedData encryptedReceivedData = + GSON.fromJson((String)receivedMessage.get("data"), EncryptedReceivedData.class); + + String decryptedData = new String(secretBoxOpener.open( + encryptedReceivedData.getCiphertext(), + encryptedReceivedData.getNonce())); + + receivedMessage.replace("data", decryptedData); + + return new PusherEvent(receivedMessage); + } + private void disposeSecretBoxOpener() { if (secretBoxOpener != null) { secretBoxOpener.clearKey(); diff --git a/src/main/java/com/pusher/client/example/PrivateEncryptedChannelExampleApp.java b/src/main/java/com/pusher/client/example/PrivateEncryptedChannelExampleApp.java index b883e60f..28f4a6ff 100644 --- a/src/main/java/com/pusher/client/example/PrivateEncryptedChannelExampleApp.java +++ b/src/main/java/com/pusher/client/example/PrivateEncryptedChannelExampleApp.java @@ -86,4 +86,10 @@ public void onError(String message, String code, Exception e) { code, e)); } + + @Override + public void onDecryptionFailure(String event, String reason) { + System.out.println(String.format( + "An error was received decrypting message for event:[%s] - reason: [%s]", event, reason)); + } } diff --git a/src/test/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImplTest.java b/src/test/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImplTest.java index d9e639bb..c11eb6ce 100644 --- a/src/test/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImplTest.java +++ b/src/test/java/com/pusher/client/channel/impl/PrivateEncryptedChannelImplTest.java @@ -1,17 +1,14 @@ package com.pusher.client.channel.impl; -import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - import com.pusher.client.AuthorizationFailureException; import com.pusher.client.Authorizer; import com.pusher.client.channel.ChannelEventListener; import com.pusher.client.channel.PrivateEncryptedChannelEventListener; import com.pusher.client.connection.impl.InternalConnection; +import com.pusher.client.crypto.nacl.SecretBoxOpener; import com.pusher.client.crypto.nacl.SecretBoxOpenerFactory; +import com.pusher.client.util.internal.Base64; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -19,6 +16,15 @@ import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + @RunWith(MockitoJUnitRunner.class) public class PrivateEncryptedChannelImplTest extends ChannelImplTest { @@ -26,6 +32,9 @@ public class PrivateEncryptedChannelImplTest extends ChannelImplTest { final String AUTH_RESPONSE_MISSING_AUTH = "{\"shared_secret\":\"iBvNoPVYwByqSfg6anjPpEQ2j051b3rt1Vmnb+z5doo=\"}"; final String AUTH_RESPONSE_MISSING_SHARED_SECRET = "{\"auth\":\"636a81ba7e7b15725c00:3ee04892514e8a669dc5d30267221f16727596688894712cad305986e6fc0f3c\"}"; final String AUTH_RESPONSE_INVALID_JSON = "potatoes"; + final String SHARED_SECRET = "iBvNoPVYwByqSfg6anjPpEQ2j051b3rt1Vmnb+z5doo="; + final String AUTH_RESPONSE_INCORRECT_SHARED_SECRET = "{\"auth\":\"636a81ba7e7b15725c00:3ee04892514e8a669dc5d30267221f16727596688894712cad305986e6fc0f3c\",\"shared_secret\":\"iBvNoPVYwByqSfg6anjPpEQ2j051b3rt1Vmnb+z5do0=\"}"; + final String SHARED_SECRET_INCORRECT = "iBvNoPVYwByqSfg6anjPpEQ2j051b3rt1Vmnb+z5do0="; @Mock InternalConnection mockInternalConnection; @@ -148,4 +157,208 @@ public void authenticationThrowsExceptionIfMalformedJson() { channel.toSubscribeMessage(); } + + /* + ON MESSAGE + */ + @Test + public void testDataIsExtractedFromMessageAndPassedToSingleListener() { + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener = mock(PrivateEncryptedChannelEventListener.class); + + channel.bind("my-event", mockListener); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener, times(1)).onEvent(argCaptor.capture()); + assertEquals("event1", argCaptor.getValue().getEventName()); + assertEquals("{\"message\":\"hello world\"}", argCaptor.getValue().getData()); + } + + @Test + public void testDataIsExtractedFromMessageAndPassedToMultipleListeners() { + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener1 = mock(PrivateEncryptedChannelEventListener.class); + PrivateEncryptedChannelEventListener mockListener2 = mock(PrivateEncryptedChannelEventListener.class); + + channel.bind("my-event", mockListener1); + channel.bind("my-event", mockListener2); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1).onEvent(argCaptor.capture()); + assertEquals("event1", argCaptor.getValue().getEventName()); + assertEquals("{\"message\":\"hello world\"}", argCaptor.getValue().getData()); + + verify(mockListener2).onEvent(argCaptor.capture()); + assertEquals("event1", argCaptor.getValue().getEventName()); + assertEquals("{\"message\":\"hello world\"}", argCaptor.getValue().getData()); + } + + @Test + public void onMessageRaisesExceptionWhenFailingToDecryptTwice() { + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener1 = mock(PrivateEncryptedChannelEventListener.class); + channel.bind("my-event", mockListener1); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1).onDecryptionFailure(anyString(), anyString()); + } + + @Test + public void onMessageRetriesDecryptionOnce() { + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener1 = mock(PrivateEncryptedChannelEventListener.class); + channel.bind("my-event", mockListener1); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1).onEvent(argCaptor.capture()); + assertEquals("event1", argCaptor.getValue().getEventName()); + assertEquals("{\"message\":\"hello world\"}", argCaptor.getValue().getData()); + } + + @Test + public void twoEventsReceivedWithSecondRetryCorrect() { + + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener1 = mock(PrivateEncryptedChannelEventListener.class); + channel.bind("my-event", mockListener1); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1).onDecryptionFailure("my-event", "Failed to decrypt message."); + + // send a second message + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1).onEvent(argCaptor.capture()); + assertEquals("event1", argCaptor.getValue().getEventName()); + assertEquals("{\"message\":\"hello world\"}", argCaptor.getValue().getData()); + } + + @Test + public void twoEventsReceivedWithIncorrectSharedSecret() { + + PrivateEncryptedChannelImpl channel = new PrivateEncryptedChannelImpl( + mockInternalConnection, + getChannelName(), + mockAuthorizer, + factory, + mockSecretBoxOpenerFactory); + + when(mockAuthorizer.authorize(Matchers.anyString(), Matchers.anyString())) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET) + .thenReturn(AUTH_RESPONSE_INCORRECT_SHARED_SECRET); + when(mockSecretBoxOpenerFactory.create(any())) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))) + .thenReturn(new SecretBoxOpener(Base64.decode(SHARED_SECRET_INCORRECT))); + + channel.toSubscribeMessage(); + + PrivateEncryptedChannelEventListener mockListener1 = mock(PrivateEncryptedChannelEventListener.class); + channel.bind("my-event", mockListener1); + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + // send a second message + channel.onMessage("my-event", "{\"event\":\"event1\",\"data\":\"{" + + "\\\"nonce\\\": \\\"4sVYwy4j/8dCcjyxtPCWyk19GaaViaW9\\\"," + + "\\\"ciphertext\\\": \\\"/GMESnFGlbNn01BuBjp31XYa3i9vZsGKR8fgR9EDhXKx3lzGiUD501A=\\\"" + + "}\"}"); + + verify(mockListener1, times(2)) + .onDecryptionFailure("my-event", "Failed to decrypt message."); + } + }