Skip to content

refactor!: drop uncached encryption support #5963

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

Open
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

mikesposito
Copy link
Member

@mikesposito mikesposito commented Jun 11, 2025

Explanation

In preparation for #5940, this PR drops support for uncached encryption. Previously, KeyringController accepted a cacheEncryptionKey option that allowed the encryption key to be stored in memory and used during encryption/decryption directly as opposed to using a password. The cacheEncryptionKey option is being removed, and the encryption key is now always derived and cached when the password is provided.

This change allows to simplify #unlockKeyrings and #updateVault methods, and remove all the logic and tests related to cacheEncryptionKey. This also allows to remove this.#password, that has been replaced by this.#encryptionKey.

The this.#encryptionKey assignment logic has been moved to two new internal methods with these specific responsibilities:

  • #deriveEncryptionKey(string): Derives the encryption key from the password, to be used during password login and password change.
  • #useEncryptionKey(string, string): Uses an existing encryption key to be used directly, to be used by submitEncryptionKey mainly.

With the upcoming changes in #5940, this allows to change the encryption key to use (i.e. by calling the aformentioned new internal methods) without having to deal with logic related to vault unlock/update, and code branches related to password-based encryption and key caching.

This PR can be tested on extension with the following: MetaMask/metamask-extension#33613

References

Changelog

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

@mikesposito mikesposito requested review from a team as code owners June 11, 2025 17:21
@mikesposito mikesposito marked this pull request as draft June 11, 2025 17:21
@mikesposito mikesposito force-pushed the mikesposito/refactor/remove-uncached-encryption branch from ba3626f to cdddc1c Compare June 11, 2025 17:30
@mikesposito mikesposito force-pushed the mikesposito/refactor/remove-uncached-encryption branch from cdddc1c to a11b2a3 Compare June 11, 2025 17:31
Copy link
Contributor

@matthiasgeihs matthiasgeihs left a comment

Choose a reason for hiding this comment

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

i don't understand: how can this be used to inject an external encryption key? (which is the main thing we need for seedless onboarding option 3)

@mikesposito
Copy link
Member Author

mikesposito commented Jun 12, 2025

@matthiasgeihs this PR doesn't allow encryption key injection, but rather removes this.#password and this.#cacheEncryptionKey from the controller and it splits some of the controller encryption logic (separating key derivation and encryption/decryption). This is not a replacement for your PR (which is implementing option 3), but rather a prerequisite for it to be more maintainlable and readable.

@mikesposito mikesposito force-pushed the mikesposito/refactor/remove-uncached-encryption branch from a11b2a3 to cf465ce Compare June 12, 2025 10:09
@mikesposito
Copy link
Member Author

@metamaskbot publish-previews

@mikesposito mikesposito marked this pull request as ready for review June 12, 2025 10:12
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "0.1.1-preview-cf465cec",
  "@metamask-previews/accounts-controller": "30.0.0-preview-cf465cec",
  "@metamask-previews/address-book-controller": "6.1.0-preview-cf465cec",
  "@metamask-previews/announcement-controller": "7.0.3-preview-cf465cec",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-cf465cec",
  "@metamask-previews/approval-controller": "7.1.3-preview-cf465cec",
  "@metamask-previews/assets-controllers": "68.1.0-preview-cf465cec",
  "@metamask-previews/base-controller": "8.0.1-preview-cf465cec",
  "@metamask-previews/bridge-controller": "32.1.1-preview-cf465cec",
  "@metamask-previews/bridge-status-controller": "29.1.0-preview-cf465cec",
  "@metamask-previews/build-utils": "3.0.3-preview-cf465cec",
  "@metamask-previews/chain-agnostic-permission": "0.7.0-preview-cf465cec",
  "@metamask-previews/composable-controller": "11.0.0-preview-cf465cec",
  "@metamask-previews/controller-utils": "11.10.0-preview-cf465cec",
  "@metamask-previews/delegation-controller": "0.4.0-preview-cf465cec",
  "@metamask-previews/earn-controller": "1.1.0-preview-cf465cec",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-cf465cec",
  "@metamask-previews/ens-controller": "16.0.0-preview-cf465cec",
  "@metamask-previews/error-reporting-service": "1.0.0-preview-cf465cec",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-cf465cec",
  "@metamask-previews/foundryup": "1.0.0-preview-cf465cec",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-cf465cec",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-cf465cec",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-cf465cec",
  "@metamask-previews/keyring-controller": "22.0.2-preview-cf465cec",
  "@metamask-previews/logging-controller": "6.0.4-preview-cf465cec",
  "@metamask-previews/message-manager": "12.0.1-preview-cf465cec",
  "@metamask-previews/multichain": "4.1.0-preview-cf465cec",
  "@metamask-previews/multichain-api-middleware": "0.4.0-preview-cf465cec",
  "@metamask-previews/multichain-network-controller": "0.8.0-preview-cf465cec",
  "@metamask-previews/multichain-transactions-controller": "2.0.0-preview-cf465cec",
  "@metamask-previews/name-controller": "8.0.3-preview-cf465cec",
  "@metamask-previews/network-controller": "23.6.0-preview-cf465cec",
  "@metamask-previews/notification-services-controller": "10.0.0-preview-cf465cec",
  "@metamask-previews/permission-controller": "11.0.6-preview-cf465cec",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-cf465cec",
  "@metamask-previews/phishing-controller": "12.5.0-preview-cf465cec",
  "@metamask-previews/polling-controller": "13.0.0-preview-cf465cec",
  "@metamask-previews/preferences-controller": "18.1.0-preview-cf465cec",
  "@metamask-previews/profile-sync-controller": "17.1.0-preview-cf465cec",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-cf465cec",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-cf465cec",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-cf465cec",
  "@metamask-previews/sample-controllers": "0.1.0-preview-cf465cec",
  "@metamask-previews/seedless-onboarding-controller": "1.0.0-preview-cf465cec",
  "@metamask-previews/selected-network-controller": "22.1.0-preview-cf465cec",
  "@metamask-previews/signature-controller": "30.0.0-preview-cf465cec",
  "@metamask-previews/token-search-discovery-controller": "3.3.0-preview-cf465cec",
  "@metamask-previews/transaction-controller": "57.3.0-preview-cf465cec",
  "@metamask-previews/user-operation-controller": "36.0.0-preview-cf465cec"
}

@mikesposito mikesposito force-pushed the mikesposito/refactor/remove-uncached-encryption branch from e73e808 to 27657e3 Compare June 12, 2025 10:47
@mikesposito mikesposito requested a review from a team as a code owner June 12, 2025 11:28
@mikesposito mikesposito force-pushed the mikesposito/refactor/remove-uncached-encryption branch from 6112020 to 86b6397 Compare June 12, 2025 11:44
@mikesposito
Copy link
Member Author

@metamaskbot publish-previews

Comment on lines 319 to 328
type CachedEncryptionKey = {
/**
* The exported encryption key string.
*/
exported: string;
/**
* The salt used to derive the encryption key.
*/
salt?: string;
};
Copy link
Member Author

Choose a reason for hiding this comment

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

We can easily add encryptedEncryptionKey in #5940 as a property of this type, so we can ensure that they are kept in sync with each other and with the values in the state and vault by placing them in the same data structure.

@matthiasgeihs
Copy link
Contributor

matthiasgeihs commented Jun 12, 2025

The main problem I see here is that it risks delaying the seedless onboarding feature. I think we should apply these optimizations after the changes needed for seedless onboarding.

Otherwise, I think these changes are worthwile looking into further.

(btw: a refactor is a code change that doesn't change functionality. so technically, this is not a refactor.)

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Will dive a little more deeply into this soon, but had some initial comments.

@mikesposito
Copy link
Member Author

@metamaskbot publish-previews

Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "0.1.1-preview-13f36eb8",
  "@metamask-previews/accounts-controller": "30.0.0-preview-13f36eb8",
  "@metamask-previews/address-book-controller": "6.1.0-preview-13f36eb8",
  "@metamask-previews/announcement-controller": "7.0.3-preview-13f36eb8",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-13f36eb8",
  "@metamask-previews/approval-controller": "7.1.3-preview-13f36eb8",
  "@metamask-previews/assets-controllers": "68.1.0-preview-13f36eb8",
  "@metamask-previews/base-controller": "8.0.1-preview-13f36eb8",
  "@metamask-previews/bridge-controller": "32.1.1-preview-13f36eb8",
  "@metamask-previews/bridge-status-controller": "29.1.0-preview-13f36eb8",
  "@metamask-previews/build-utils": "3.0.3-preview-13f36eb8",
  "@metamask-previews/chain-agnostic-permission": "0.7.0-preview-13f36eb8",
  "@metamask-previews/composable-controller": "11.0.0-preview-13f36eb8",
  "@metamask-previews/controller-utils": "11.10.0-preview-13f36eb8",
  "@metamask-previews/delegation-controller": "0.4.0-preview-13f36eb8",
  "@metamask-previews/earn-controller": "1.1.0-preview-13f36eb8",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-13f36eb8",
  "@metamask-previews/ens-controller": "16.0.0-preview-13f36eb8",
  "@metamask-previews/error-reporting-service": "1.0.0-preview-13f36eb8",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-13f36eb8",
  "@metamask-previews/foundryup": "1.0.0-preview-13f36eb8",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-13f36eb8",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-13f36eb8",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-13f36eb8",
  "@metamask-previews/keyring-controller": "22.0.2-preview-13f36eb8",
  "@metamask-previews/logging-controller": "6.0.4-preview-13f36eb8",
  "@metamask-previews/message-manager": "12.0.1-preview-13f36eb8",
  "@metamask-previews/multichain": "4.1.0-preview-13f36eb8",
  "@metamask-previews/multichain-api-middleware": "0.4.0-preview-13f36eb8",
  "@metamask-previews/multichain-network-controller": "0.8.0-preview-13f36eb8",
  "@metamask-previews/multichain-transactions-controller": "2.0.0-preview-13f36eb8",
  "@metamask-previews/name-controller": "8.0.3-preview-13f36eb8",
  "@metamask-previews/network-controller": "23.6.0-preview-13f36eb8",
  "@metamask-previews/notification-services-controller": "10.0.0-preview-13f36eb8",
  "@metamask-previews/permission-controller": "11.0.6-preview-13f36eb8",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-13f36eb8",
  "@metamask-previews/phishing-controller": "12.5.0-preview-13f36eb8",
  "@metamask-previews/polling-controller": "13.0.0-preview-13f36eb8",
  "@metamask-previews/preferences-controller": "18.1.0-preview-13f36eb8",
  "@metamask-previews/profile-sync-controller": "17.1.0-preview-13f36eb8",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-13f36eb8",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-13f36eb8",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-13f36eb8",
  "@metamask-previews/sample-controllers": "0.1.0-preview-13f36eb8",
  "@metamask-previews/seedless-onboarding-controller": "1.0.0-preview-13f36eb8",
  "@metamask-previews/selected-network-controller": "22.1.0-preview-13f36eb8",
  "@metamask-previews/signature-controller": "30.0.0-preview-13f36eb8",
  "@metamask-previews/token-search-discovery-controller": "3.3.0-preview-13f36eb8",
  "@metamask-previews/transaction-controller": "57.3.0-preview-13f36eb8",
  "@metamask-previews/user-operation-controller": "36.0.0-preview-13f36eb8"
}

@mikesposito
Copy link
Member Author

This can now be tested on extension using MetaMask/metamask-extension#33613

if (typeof password !== 'string') {
throw new TypeError(KeyringControllerError.WrongPasswordType);
}
const { exportedEncryptionKey } = credentials;
Copy link
Contributor

@danroc danroc Jun 19, 2025

Choose a reason for hiding this comment

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

Is it ok to assume that it will be defined in the runtime? Maybe we could use something like:

if ('password' in credentials) {
  // ...
} else if ('exportedEncryptionKey' in credentials) {
  // ...
} else {
  // Handle error
}

I don't have a strong opinion about this. It's a question, not a suggestion.

Copy link
Member Author

@mikesposito mikesposito Jun 19, 2025

Choose a reason for hiding this comment

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

The key derivation / addition could indeed fail (or the passed credentials invalid), but there is this check right after this if/else block:

  const encryptionKey = this.#encryptionKey?.exported;
  if (!encryptionKey) {
    throw new Error(KeyringControllerError.MissingCredentials);
  }

I tried to keep the if/else block as simple as possible, but let me know if you think we should handle the error differently.

Copy link
Member Author

Choose a reason for hiding this comment

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

Note also that if we include the check in the if/else block we'll still need to make TypeScript happy with the additional check I quoted in the previous comment, as this.#encryptionKey may still be undefined

Copy link
Member Author

@mikesposito mikesposito Jun 19, 2025

Choose a reason for hiding this comment

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

Though, we could make this prettier by moving the if/else block to another internal method that handles the credentials, moving complexity out of this method

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed, it will be tested in the #useEncryptionKey method:

    if (
      typeof encryptionKey !== 'string' ||  // <-- exportedEncryptionKey
      typeof encryptionSalt !== 'string'
    ) {
      throw new TypeError(KeyringControllerError.WrongEncryptionKeyType);
    }

I find this a bit confusing because we validate the password directly, but delegate the validation of the exportedEncryptionKey .

That said, I'm okay with keeping it as is. I’d prefer to validate inputs in the public methods, but that’s probably outside the scope of this PR.

Copy link
Member Author

Choose a reason for hiding this comment

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

I find this a bit confusing because we validate the password directly, but delegate the validation of the exportedEncryptionKey

Where do you see the validation of the password? I believe #unlockKeyrings does not validate the password type, it's delegated to the #deriveEncryptionKey method

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, I meant the if ('password' in credentials) { check.

Comment on lines +202 to +205
const controller = new SeedlessOnboardingController<
EncryptionKey | webcrypto.CryptoKey,
KeyDerivationOptions
>({
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 surprised the type parameters did not get inferred here 🤔 Should be "inferable" from encryptor no?

Copy link
Member Author

@mikesposito mikesposito Jun 19, 2025

Choose a reason for hiding this comment

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

It usually works with functions, but TypeScript does not infer class type parameters depending on constructors params unless the generic type is explicitly specified when instantiating the object

So we need to specify the derivation options type in the same way we specify the key type

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, but given that we were not providing the EncryptionKey type parameter explicitly before this PR, I thought it would have worked the same with a 2nd parameter 😅 anyway, I was just curious! Thanks.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually EncryptionKey was already passed in explicitly, I only added the second one:

-export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
+export class SeedlessOnboardingController<
+  EncryptionKey,
+  SupportedKeyDerivationOptions,
+> extends BaseController<

Copy link
Member Author

Choose a reason for hiding this comment

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

On this note, I'm wondering if we should add the same to KeyringController.. thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

It was not passed explicitly though? 🤔

- const controller = new SeedlessOnboardingController({

Anyway, if that's required, that's ok, I was just curious 😄

if we should add the same to KeyringController.. thoughts?

Good question... I generally like to have type parameters, and right now we're using unknown on our encryptor 😅 So that does sound better to me yes and it would "align" with the SeedlessOnbordingController too, this way we can make sure that once we introduce the other encryption/decryption key, both types are aligned.

Comment on lines +529 to +532
const controller = new SeedlessOnboardingController<
EncryptionKey | webcrypto.CryptoKey,
KeyDerivationOptions
>({
Copy link
Contributor

Choose a reason for hiding this comment

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

Same question here.

state.encryptionSalt = updatedState.encryptionSalt;
}
state.encryptionKey = encryptionKey;
state.encryptionSalt = parsedEncryptedVault.salt;
Copy link
Contributor

Choose a reason for hiding this comment

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

What about adding a const encryptionSalt = this.#encryptionKey?.salt; above and use it when checking for throw new Error(KeyringControllerError.MissingCredentials); and use it here like:

Suggested change
state.encryptionSalt = parsedEncryptedVault.salt;
state.encryptionSalt = encryptionSalt;

I just think that the salt and the encryption key have to be "coupled" together here even though yes, both salts would be the same here.

Copy link
Member Author

Choose a reason for hiding this comment

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

We could check if the salt is matching the vault, but it's not strictly necessary: the actual decryption can still happen because we only need the encryption key to decrypt the vault, and if the salt is out of date we can simply update it at the end of the decryption (when we are sure that the encryption key is the correct one) - this way we implicitly keep them in sync, but we don't block decryption if it's not necessary.

Copy link
Member Author

Choose a reason for hiding this comment

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

Now that I'm thinking about this, there probably is one counterargument to trying the encryption key directly: comparing the salt is faster than decrypting with the wrong key

Copy link
Member Author

Choose a reason for hiding this comment

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

Now that I'm thinking about this, there probably is one counterargument to trying the encryption key directly: comparing the salt is faster than decrypting with the wrong key

Copy link
Contributor

Choose a reason for hiding this comment

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

it's not strictly necessary

Yes, that one was of the reason I wanted to use the salt "associated" with the key, cause I think this makes a bit more sense when you read the code. But again, at this point they should both be equal

comparing the salt is faster than decrypting with the wrong key

True, but IDK if comparing salt is a common practice in that scenario. I'd just the code as it is for this, it looks great IMO.

but we don't block decryption if it's not necessary.

I don't see why using the encryptionSalt (from this.#encryptionKey?.salt) would block anything though? 🤔 I mean, as you said, we can just decrypt the vault with just the key, so updating the this state.encryptionSalt using the key's salt here seems a bit more natural to me. But if you prefer to keep it as-is, I'm fine too 😄

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.

[KeyringController] Drop uncached encryption support
5 participants