-
-
Notifications
You must be signed in to change notification settings - Fork 233
feat(KeyringController): add exportEncryptionKey
method to export vault key
#5984
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
base: main
Are you sure you want to change the base?
Conversation
ab31883
to
721ff95
Compare
408fe42
to
97f3959
Compare
369af94
to
f2ef812
Compare
// There is a case where the controller is unlocked but the encryption key | ||
// is not set, even when #cacheEncryptionKey is true. This happens when | ||
// calling changePassword with the existing password. In this case, the | ||
// encryption key is deleted, but the state is not recreated, because the | ||
// session state does not change in this case, and #updateVault is not | ||
// called in #persistOrRollback. | ||
if (!this.state.encryptionKey) { | ||
return await this.#withVaultLock(async () => { | ||
assertIsExportableKeyEncryptor(this.#encryptor); | ||
assertIsValidPassword(this.#password); | ||
const result = await this.#encryptor.decryptWithDetail( | ||
this.#password, | ||
// Ignoring undefined. Assuming vault is set when unlocked. | ||
this.state.vault as string, | ||
); | ||
if (this.#cacheEncryptionKey) { | ||
this.update((state) => { | ||
state.encryptionKey = result.exportedKeyString; | ||
state.encryptionSalt = result.salt; | ||
}); | ||
} | ||
return result.exportedKeyString; | ||
}); | ||
} |
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.
Can't we just make changePassword
a noop if we re-use the same password in that case?
Thus, the encryption key won't be re-generated and state
would still have a valid encryptionKey
too?
Also, another option would be to put the encryption key as part of the session state I guess? 🤔 This way, we would detect it has changed and we would re-trigger updateVault
Any thoughts on that @mikesposito?
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 think that making changePassword
a noop if the password is the same is a good idea. I prefer it as a solution over putting the encryption key in the session state, as it would keep the password as single source of truth (e.g. is this encryption key that I'm using to encrypt the vault still valid and derived from the current password?).
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.
Actually, another reason for not putting the encryption key in the session state is that it is already part of the committed state, while the rest of the session would be internal instance variables
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.
On that last comment, I am not sure what the relevance is of the encryption key being in the controller state. Why should that be a reason to not include it as session state?
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 if i'm following all the comments here.
to clarify, this edge case only came up because of testing with changePassword
without changing password in the seedless onboarding flow.
after the changePassword
without actually changing the password the controller is left in an unlocked state with encryptionKey
removed from state.
obviously, the controller must still be able to export the encryption key in this state.
i see the following ways of dealing with this:
- we update the
changePassword
flow to rehydrate the encryption key in all cases. (might be a bit messy because we might have to touch#persistOrRollback
which many functions rely on.) - we keep
changePassword
functionality, but we derive the encryption key withinexportEncryptionKey
if it is not present. (in this case we can either choose to persist this key in the state (2a), or leave the state untouched (2b).) - we update
changePassword
to be a no-op in case the password is not changed.
I chose to implement 2, because I didn't want to mess with #persistOrRollback
, and 2a) specifically because i thought it is in line with how unlocking works and is more efficient when it comes to multiple calls to exportEncryptionKey
. i did not think of option 3 before.
with regards to 3) i see the following edge case where the controller is unlocked using encryption key and #password
is not set. in this case changePassword
would trigger re-encryption even though the password technically hasn't changed. in any case, it would still be fine here because it would trigger the vault to be updated (because undefined != newPassword
) and thus rehydrate the state.encryptionKey
as needed.
what do you think? i'm fine with doing 3) if this is the preferred solution.
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.
On that last comment, I am not sure what the relevance is of the encryption key being in the controller state. Why should that be a reason to not include it as session state?
The way #getSessionState()
is used is to get the current values of instance variables to decide whether the controller state needs to be updated or not. The variables included in the session are also the ones that can be rolled back in case of an error - so I feel that placing state.encryptionKey along with the session would mix up things a little. Though this issue goes away with #5963 as we are replacing #password
with #encryptionKey
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.
Though it is also true that state.encryptionKey has the lifetime of a single session as it is not persisted. So I don't have a super strong opinion, it's just that it would be slightly different compared to how these variables are separated 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.
changed to no-op in fee5652
// There is a case where the controller is unlocked but the encryption key | ||
// is not set, even when #cacheEncryptionKey is true. This happens when | ||
// calling changePassword with the existing password. In this case, the | ||
// encryption key is deleted, but the state is not recreated, because the | ||
// session state does not change in this case, and #updateVault is not | ||
// called in #persistOrRollback. | ||
if (!this.state.encryptionKey) { | ||
return await this.#withVaultLock(async () => { | ||
assertIsExportableKeyEncryptor(this.#encryptor); | ||
assertIsValidPassword(this.#password); | ||
const result = await this.#encryptor.decryptWithDetail( | ||
this.#password, | ||
// Ignoring undefined. Assuming vault is set when unlocked. | ||
this.state.vault as string, | ||
); | ||
if (this.#cacheEncryptionKey) { | ||
this.update((state) => { | ||
state.encryptionKey = result.exportedKeyString; | ||
state.encryptionSalt = result.salt; | ||
}); | ||
} | ||
return result.exportedKeyString; | ||
}); | ||
} |
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 think that making changePassword
a noop if the password is the same is a good idea. I prefer it as a solution over putting the encryption key in the session state, as it would keep the password as single source of truth (e.g. is this encryption key that I'm using to encrypt the vault still valid and derived from the current password?).
const result = await this.#encryptor.decryptWithDetail( | ||
this.#password, | ||
// Ignoring undefined. Assuming vault is set when unlocked. | ||
this.state.vault as string, | ||
); |
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 how often this will be called, but this call takes Key Derivation time + Decryption time
, and the Decryption time
is all thrown away - which is not ideal. We could use #encryptor.keyFromPassword
instead, although we'd need to:
- Add
keyFromPassword
toExportableKeyEncryptor
- Take care of using the same
keyMetadata
andsalt
from the vault
I think that adding keyFromPassword
and using the salt from the vault is trivial, but dealing with keyMetadata
may be trickier or even unwanted (because it heavily depends on the encryptor injected by the client). Perhaps the ideal solution would be to have an additional method on the encryptor interface to derive a key from a password, using salt and keyMetadata from an existing vault (without decrypting it).
This would mean having to add a method in @metamask/browser-passworder
and one in the mobile encryptor, and it may require more time to ship this feature. If we are ok with the performance hit for now, we can do it later.
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 would expect the decryption time to be negligible. (pure AES is super fast.) hence, i assumed it's not a problem.
one thing i did want here is for that key derivation to function exactly as it would in the #unlockKeyrings
method. (to avoid any potential inconsistencies between the derived keys.)
however, this issue would also be solved if changePassword
becomes a no-op when password isn't changed. (because then we don't need to do the derivation 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.
(PS: ideally, i'd like to avoid having to touch the encryptor interface)
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.
changed in fee5652
if (this.#cacheEncryptionKey) { | ||
this.update((state) => { | ||
state.encryptionKey = result.exportedKeyString; | ||
state.encryptionSalt = result.salt; | ||
}); | ||
} |
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.
Curious: why are we updating this state here if we already used the salt from the vault, and the key is presumably the same?
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 if i understand the question.
we do this only if state.encryptionKey
is undefined.
and in this case i do the update so that we don't have to go through key derivation again if we call exportEncryptionKey
multiple times.
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.
changed in fee5652
// There is a case where the controller is unlocked but the encryption key | ||
// is not set, even when #cacheEncryptionKey is true. This happens when | ||
// calling changePassword with the existing password. In this case, the | ||
// encryption key is deleted, but the state is not recreated, because the |
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 scenario described in this comment seems like a bug. Perhaps we should fix that bug rather than work around it. Especially given the complexity of this workaround.
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.
discussed in the thread above #5984 (comment)
making changePassword
a no-op in case of same passwords would "fix" 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.
Yes, I think so.
To be specific about why this is a bug: I would expect the encryptionKey
state to always represent the key used to encrypt the current vault
string that we have. The client in the past did rely on this expectation. In the scenario described, that expectation would not be met.
Making changePassword
a no-op when given the same password hopefully should ensure encryptionKey
remains present, and still matching the vault.
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.
changed in fee5652
checking for edge case where encryption key is not set after calling `changePassword` with same password
fee5652
to
9688e86
Compare
exportEncryptionKey
method to export vault key
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.
LGTM. Would be great to wait for approval from @Gudahtt or @mikesposito also.
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.
LGTM!
* @returns Promise resolving when the operation completes. | ||
*/ | ||
async submitEncryptionKey( | ||
encryptionKey: string, | ||
encryptionSalt: string, | ||
encryptionSalt?: string, |
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.
Nit: It would be nice to add a test case to ensure this works if the salt is omitted
Explanation
Keyring Controller:
controller.exportEncryptionKey
to export vault key.controller.submitEncryptionKey
to have an optional salt.This feature is relevant for resolving an audit finding with the seedless onboarding controller.
References
Previously, seedless onboarding was backing up the keyring password to allow for vault recovery after a password change. Now we backup the keyring encryption key.
See the ADR for more details.
This is part of the implementation of option 6.
Changelog
Checklist