-
Notifications
You must be signed in to change notification settings - Fork 140
Dev decrypt plus retry #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| if (listeners != null) { | ||
| for (SubscriptionEventListener listener : listeners) { | ||
| ((PrivateEncryptedChannelEventListener)listener).onDecryptionFailure( | ||
| new Exception("Failed to decrypt message")); |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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... 🤔
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 :)
There was a problem hiding this comment.
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.
There was a problem hiding this 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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);
}
}
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
This reverts commit a87547f.
…ges get a retry to the authorization endpoint
There was a problem hiding this 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.
What
EncryptedReceivedDataobject - we then replace thedatapart of the JSON with the decrypted message and pass it back to be packaged as a PusherEvent.onDecryptionFailure- I have an open Q about what sort of Exception we should be returning here.prepareEventmethod 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.getInterestedListenersto 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.