Skip to content

Conversation

@daniellevass
Copy link
Contributor

@daniellevass daniellevass commented Apr 6, 2020

What

  • Decrypts messages - we convert the received data into a EncryptedReceivedData object - we then replace the data part of the JSON with the decrypted message and pass it back to be packaged as a PusherEvent.
  • Retries only once if we can't decrypt it - if we can't decrypt it a second time we notify the listeners of an onDecryptionFailure - I have an open Q about what sort of Exception we should be returning here.
  • Added a prepareEvent method in the base Channel class which converts the json to the PusherEvent class, but this is where we can hook into decrypting it without having to copy all the other logic to handle messages.
  • Added a getInterestedListeners to get all the interested listeners for an event - we needed this in the ChannelImpl to notify when a message is received, I also needed a hook into these for notifying when failing to decrypt.

@daniellevass daniellevass self-assigned this Apr 6, 2020
if (listeners != null) {
for (SubscriptionEventListener listener : listeners) {
((PrivateEncryptedChannelEventListener)listener).onDecryptionFailure(
new Exception("Failed to decrypt message"));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what kind of exception we should be returning here...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not even sure if we should be passing an exception in a callback. A simple string describing the error would be sufficient.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice to pass some more details of the event as well. Perhaps the name of the event at least. I'm not sure the customer will have much use for the ciphertext or anything else though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if we made the method .onDecryptionFailure(String event, String reason) - you think that would be sufficient? People will understand it's a failure callback, so we don't need to give them a packaged Exception to handle.

Comment on lines 143 to 158
// retry once only.
disposeSecretBoxOpener();
authenticate();

try {
Map receivedMessage = GSON.fromJson(message, Map.class);
final String decryptedMessage = decryptMessage((String) receivedMessage.get("data"));
receivedMessage.replace("data", decryptedMessage);

return GSON.fromJson(
GSON.toJson(receivedMessage), PusherEvent.class);
} catch (AuthenticityException e2) {
disposeSecretBoxOpener();
notifyListenersOfDecryptFailure(event);
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure I like this nested try/catch but I'm not sure how to improve it... 🤔

Copy link
Contributor

@mdpye mdpye Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's not ideal, although it kind of works for a single retry. If we wanted multiple retries, we might prefer a loop:

for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  try {
    // decrypt it
    return decryptedMessage;
  } catch {
    // dispose of the secret
    // authenticate (possibly unless attempt == MAX_ATTEMPTS-1)
  }
}

// notify listeners of failure because we ran out of attempts before returning
return null;

Alternatively, you can un-nest the second attempt by recognising that the try block ends with return, so the only way to reach code after the whole try/catch construct is via the catch.

try {
  // decrypt it
  return decryptedMessage;
} catch {
  // dispose of the secret
}

// authenticate

try {
  // decrypt it
  return decryptedMessage;
} catch {
  // dispose of the secret
}

// notify listeners
return null;

It might also benefit from a private function to encapsulated the repeated code in the decryption attempt.

None of them seem super elegant, but some food for thought.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the helpful response. I'll have a think over this and see what I can do to refactor it to be a little bit better :)

Copy link
Contributor Author

@daniellevass daniellevass Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've refactored out Json parts to decryptMessage so it is now responsible for returning the PusherEvent. I moved the retry logic into its own method so hopefully it's easier to follow what's happening instead of nested try's and catches.

@daniellevass daniellevass marked this pull request as ready for review April 6, 2020 13:19
@daniellevass daniellevass requested a review from mdpye April 6, 2020 13:19
Copy link
Contributor

@mdpye mdpye left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't been able to look in to the tests yet, but I wanted to deliver this batch of feedback to you, because it contains some food for thought...

listeners = null;
}
}
return listeners;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there's nothing else in this method other than the synchronized block, we don't really need the local variable, you can just replace the assignments of listeners with return statements.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's very interesting. So Android Studio helped me simplify it even more. The if listeners = null is redundant, because if it's null we can just return... null - which then means we can simplify it down to just:

protected Set<SubscriptionEventListener> getInterestedListeners(String event) {
       synchronized (lock) {
           return eventNameToListenerMap.get(event);
       }
   }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's equivalent. The old code returned a copy of the collection of listeners, not the actual collection from the map.

Because Java's collections are all mutable by default, it's important to copy the collection returned, so that if the caller manipulates it they aren't making unsynchronized changes to the contents of the eventNameToListenerMap.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Good to know! I've updated this code to return a copy of the interested listeners - it's still a little bit simplified than what was there before.

}
});
final PusherEvent pusherEvent = prepareEvent(event, message);
if (pusherEvent != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under what circumstances might this be null? If it's possible then it seems like an error we should handle, if it isn't then this check can be omitted...

Copy link
Contributor Author

@daniellevass daniellevass Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The circumstance is that we failed to decrypt an encrypted message - we return null for the prepareEvent method and this then means it won't notify the listeners for onEvent instead we'll be notifying them through the PrivateChannelImpl that of an onDecryptionFailure.

Comment on lines 143 to 158
// retry once only.
disposeSecretBoxOpener();
authenticate();

try {
Map receivedMessage = GSON.fromJson(message, Map.class);
final String decryptedMessage = decryptMessage((String) receivedMessage.get("data"));
receivedMessage.replace("data", decryptedMessage);

return GSON.fromJson(
GSON.toJson(receivedMessage), PusherEvent.class);
} catch (AuthenticityException e2) {
disposeSecretBoxOpener();
notifyListenersOfDecryptFailure(event);
}
}
Copy link
Contributor

@mdpye mdpye Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's not ideal, although it kind of works for a single retry. If we wanted multiple retries, we might prefer a loop:

for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  try {
    // decrypt it
    return decryptedMessage;
  } catch {
    // dispose of the secret
    // authenticate (possibly unless attempt == MAX_ATTEMPTS-1)
  }
}

// notify listeners of failure because we ran out of attempts before returning
return null;

Alternatively, you can un-nest the second attempt by recognising that the try block ends with return, so the only way to reach code after the whole try/catch construct is via the catch.

try {
  // decrypt it
  return decryptedMessage;
} catch {
  // dispose of the secret
}

// authenticate

try {
  // decrypt it
  return decryptedMessage;
} catch {
  // dispose of the secret
}

// notify listeners
return null;

It might also benefit from a private function to encapsulated the repeated code in the decryption attempt.

None of them seem super elegant, but some food for thought.

return GSON.fromJson(
GSON.toJson(receivedMessage), PusherEvent.class);
} catch (AuthenticityException e2) {
disposeSecretBoxOpener();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen to the next event if we dispose of the secret here on the final attempt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. It will probably crash with an exception as the secretbox will be null. What should we be doing in this situation? We can do a check to see if the secretbox is null at the start of the prepareEvent, and call the onDecryptionFailure with a different reason?

At any point, there should definitely be a test for this which i will add now.

Copy link
Contributor Author

@daniellevass daniellevass Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added the test 4bfc70d#diff-adf187cd707fe9cbe8285aefab4bafbbR287 - and assumed i was okay to implement the logic as described. Please let me know what you think on this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are circumstances during the rotation of master key on the customer's backend where races can occur between events sent and keys received, so I think it's important that the SDK recover from a failed decryption and not fail all future events because of a period of instability when the keys are rotated.

Personally, I would leave the last fetched key in place and attempt the next event using it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Marek left feedback before that he expected it to be cleared after each failed decryption.

I think what you say makes sense - each subsequent message should be able to send 1 retry to the authorization endpoint to get a new shared_secret.

I've updated the code to do this.

I've also updated the tests: there is one test to ensure that two messages that have incorrect shared secrets after a retry, i also have a test for if a second message when it does the retry gets a correct key, it should succeed.

Does that make sense?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, sounds great

receivedMessage.replace("data", decryptedMessage);

return GSON.fromJson(
GSON.toJson(receivedMessage), PusherEvent.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this round-trip via GSON. If you look at the constructor for PusherEvent, it just wraps a map, so if we update the "data" key in our map, we should be able to construct a PusherEvent around it directly without needing to use serialisation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I'll try again to see what I can do. It was proving to be a little difficult to get this working.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay - so i'm passing in the Map<String, Object> into the PusherEvent and all the tests still pass. Thank you for the feedback!

@pusher pusher deleted a comment from codecov-io Apr 7, 2020
@daniellevass daniellevass requested a review from mdpye April 7, 2020 09:05
Copy link
Contributor

@mdpye mdpye left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just the one comment on accidentally removing the copy of the listeners Set, then I think we're good to go.

@daniellevass daniellevass requested a review from mdpye April 7, 2020 13:22
@daniellevass daniellevass merged commit f4fa2dd into decrypt-integration Apr 7, 2020
@daniellevass daniellevass deleted the dev-decrypt-plus-retry branch April 7, 2020 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants