Skip to content
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

Add field-level encryption to API Keys #5046

Merged
merged 12 commits into from
Jan 26, 2024

Conversation

djabarovgeorge
Copy link
Contributor

What change does this PR introduce?

  • Using the encryptProviderSecret function in application-generic, update the following use-cases to encrypt prior to write:
    • Update the create-environment use-case to encrypt the generated key prior to insertion in the environment DAL
  • Using the decryptProviderSecret function in application-generic, update the following use-cases to decrypt after read and before sending back in response:
    • get-api-keys
    • get-environment
    • get-my-environments
  • Update the authService.apiKeyAuthenticate method to:
    • Remove the apiKey from the cached result in authService.getUserData
    • Generate a hash from the unencrypted apiKey to act as the cache key in the @CachedEntity builder for authService.getUserData
    • Update the authService.getUserData method to encrypt the provided apiKey prior to environmentRepository.findByApiKey lookup
      Remove the apiKey from return of authService.getUserData
      Remove the apiKey from JWT payload in authService.getApiSignedToken
  • Create a migration in the API to idempotently (i.e. only once through subsequent migrations) encrypt the API keys on all environments
  • Update the data migration docs to include the new migration for the relevant version

Why was this change needed?

The API key is not currently encrypted at rest, this poses a vulnerability if the raw contents of the database or the cache are available to a bad actor who may use the api key with bad intentions. We need to obfuscate the value of the api key at rest to prevent direct use of the key in the event of a database breach

We want to be able to show the API key to users from the dashboard to simplify and ease product adoption, therefore hashing of the API key in the database is not an option. We must therefore implement encryption prior to writes and decryption during reads to support the necessary obfuscation.

Definition of Done

  • API keys are encrypted at rest in the database
  • API keys are hashed at rest in the cache
  • API keys in dashboard are visible in decrypted form
  • API keys remain usable as authentication credentials for the API
  • All existing API keys become encrypted through a data migration script
  • Running the data migration script more than once does not re-encrypt the api keys
    https://docs.novu.co/additional-resources/data-migrations has the new migration listed

Copy link

linear bot commented Jan 3, 2024

@djabarovgeorge
Copy link
Contributor Author

Things I was not sure about, these and could not validate with Richard because he is on vacation 🎉

Are the notes regarding authService.apiKeyAuthenticate and authService.getUserData redundant? because i could not find those functions.

Why generate a hash from the unencrypted apiKey to act as the cache key in the @CachedEntity builder for authService.getUserData
• we can use an encrypted key, so we won't expose the key, and the key should be relatively small. so i wonder if we need to hash it?

Remove the apiKey from the JWT payload in authService.getApiSignedToken
• is it already removed? i did not find apiKey in the JWT payload.

Comment on lines 23 to 26
export interface IApiKeyDto {
key: string;
_userId: string;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can not use the DAL Entity because the interfaces are different

})
apiKeys: IApiKey[];
@ApiPropertyOptional()
apiKeys?: IApiKeyDto[];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Optional because get-environment.usecase does not return the api keys, i wonder if we need to return them at all under environment-response.dto. I would suggest removing it at altogether and providing the keys only under GET /environments/api-keys

Copy link
Contributor

Choose a reason for hiding this comment

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

The GET /environments does use that interface and returns the apiKeys.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, that is why I left it as optional because GET /environments/me do not return it.


export const NOVU_SUB_MASK = 'nvsk.';

export type EncryptedSecret = `${typeof NOVU_SUB_MASK}${string}`;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

kudos to Richard for helping me to be more familiar with Template Literal Types.
This small type highlighted some potential issues we could have in compilation time while developing this feature, and will probably prevent future issues.

Comment on lines 55 to 69
export function encryptApiKey(apiKey: string): EncryptedSecret {
if (novuEncrypted(apiKey)) {
return apiKey;
}

return encryptSecret(apiKey);
}

export function decryptApiKey(apiKey: string): string {
if (novuEncrypted(apiKey)) {
return decryptSecret(apiKey);
}

return apiKey;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

those are middleware for the encryption-decryption that will help us make sure that we won't encrypt/decrypt twice for no reason.

Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we have these checks actually in the encryptSecret, decryptSecret functions? because I see that they are exposed and used in other places.

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 is a good point but i did not want to merge the base functions encryptSecret, decryptSecret that at the moment responsible for adding and removing the prefix. as it could affect the integration credentials as well.

})
apiKeys: IApiKey[];
@ApiPropertyOptional()
apiKeys?: IApiKeyDto[];
Copy link
Contributor

Choose a reason for hiding this comment

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

The GET /environments does use that interface and returns the apiKeys.

Comment on lines 55 to 69
export function encryptApiKey(apiKey: string): EncryptedSecret {
if (novuEncrypted(apiKey)) {
return apiKey;
}

return encryptSecret(apiKey);
}

export function decryptApiKey(apiKey: string): string {
if (novuEncrypted(apiKey)) {
return decryptSecret(apiKey);
}

return apiKey;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we have these checks actually in the encryptSecret, decryptSecret functions? because I see that they are exposed and used in other places.

@@ -1,9 +1,10 @@
import { nanoid } from 'nanoid';
Copy link
Contributor

Choose a reason for hiding this comment

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

could you please also cover this functionality with e2e tests for all use-cases touched?

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 was not sure what would be the best way to test it as the API key is i the code of our testing environment. i wanted to revisit the tests tomorrow once again.

small note, our test env organically simulates backward compatibility because we do not store an encrypted api key by the @novu/test service on sesstion init. i could not add the encryption because we do not propose application genetic in test lib.
meaning that we created not encrypted api keys in the DB on session init and the application (e2e tests) works as expected (backward compatibility).

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, we should have in the EnvironmentService the same encryption ideally by reusing the utils encryptApiKey, decryptApiKey.
Theoretically, both packages are built to CJS, so we might include @novu/application-generic in @novu/testing, but not suggesting that 😅 Another way would be to have another lib that is pure for reusable utils and without app-related logic, which is headache 🤕 so the only easy thing would be to duplicate the code?

@rifont rifont self-requested a review January 19, 2024 14:42
@rifont
Copy link
Collaborator

rifont commented Jan 19, 2024

Things I was not sure about, these and could not validate with Richard because he is on vacation 🎉
Are the notes regarding authService.apiKeyAuthenticate and authService.getUserData redundant? because i could not find those functions.

Yes, they are redundant - the API Key auth flow was recently simplified and these functions were removed.

Why generate a hash from the unencrypted apiKey to act as the cache key in the @CachedEntity builder for authService.getUserData • we can use an encrypted key, so we won't expose the key, and the key should be relatively small. so i wonder if we need to hash it?

The approach you have taken is much better. Nice work.

Remove the apiKey from the JWT payload in authService.getApiSignedToken • is it already removed? i did not find apiKey in the JWT payload.

Yes, they were removed in the API Key auth flow simplification mentioned above.

Copy link

netlify bot commented Jan 21, 2024

Deploy Preview for dev-web-novu ready!

Name Link
🔨 Latest commit 78b472c
🔍 Latest deploy log https://app.netlify.com/sites/dev-web-novu/deploys/65ad159a77d0d900082c95b6
😎 Deploy Preview https://deploy-preview-5046--dev-web-novu.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

netlify bot commented Jan 21, 2024

Deploy Preview for dev-web-novu ready!

Name Link
🔨 Latest commit 7fe814d
🔍 Latest deploy log https://app.netlify.com/sites/dev-web-novu/deploys/65b0cbcfe07ef60008c5e6ed
😎 Deploy Preview https://deploy-preview-5046--dev-web-novu.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@djabarovgeorge djabarovgeorge merged commit 046c0ad into next Jan 26, 2024
31 checks passed
Copy link
Collaborator

@rifont rifont left a comment

Choose a reason for hiding this comment

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

Awesome work @djabarovgeorge . Great security posture improvement 🦾

@djabarovgeorge djabarovgeorge deleted the nv-3172-add-field-level-encryption-to-api-keys branch January 26, 2024 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants