-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(NODE-5393): Migrate AWS signature v4 logic into driver #4824
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?
Changes from all commits
7cc1156
811d453
a0ba1ec
449d677
a44f3b4
221044d
72ab61d
037bcf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,5 @@ cd $DRIVERS_TOOLS/.evergreen/auth_aws | |
|
|
||
| cd $BEFORE | ||
|
|
||
| npm install --no-save aws4 | ||
|
|
||
| # revert to show test output | ||
| set -x | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import * as crypto from 'node:crypto'; | ||
|
|
||
| import { type AWSCredentials } from './deps'; | ||
|
|
||
| export type Options = { | ||
| path: '/'; | ||
| body: string; | ||
| host: string; | ||
| method: 'POST'; | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded'; | ||
| 'Content-Length': number; | ||
| 'X-MongoDB-Server-Nonce': string; | ||
| 'X-MongoDB-GS2-CB-Flag': 'n'; | ||
| }; | ||
| service: string; | ||
| region: string; | ||
| date?: Date; | ||
| }; | ||
|
|
||
| export type SignedHeaders = { | ||
| headers: { | ||
| Authorization: string; | ||
| 'X-Amz-Date': string; | ||
| }; | ||
| }; | ||
|
|
||
| const getHash = (str: string): string => { | ||
| return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); | ||
| }; | ||
| const getHmacBuffer = (key: string | Uint8Array, str: string): Uint8Array => { | ||
| return crypto.createHmac('sha256', key).update(str, 'utf8').digest(); | ||
| }; | ||
| const getHmacString = (key: Uint8Array, str: string): string => { | ||
| return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use the webcrypto API for this? It's supported as a global in all versions of Node.js and browsers that we target, right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like webcrypto may be more limited:
We weren't planning on moving off
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Well,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're going to keep
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there might be an argument for adopting webcrypto now. If we add new usage of node:crypto, does that add additional polyfills for devtools? @nbbeeken
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We currently use https://github.com/browserify/crypto-browserify for our polyfill, so However, given the choice, the API common to all envs when writing new code seems preferable to me. Using webcrypto is also going to make this operation async, which I think is good (?). It seems advantageous to let the event loop turn while you do cpu bound work on another thread (many connections can be running this auth code at once right?). But perf considerations aside, if there's a future where we need to use webcrypto rather than it being a choice, figuring out the async plumbing is nice to do while we're writing the code that depends on it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds reasonable. Let me take a look at how involved this change would be. |
||
|
|
||
| const convertHeaderValue = (value: string | number) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this function URI encoding the header value? Could we just use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope, this is not URI encoding, this is replacing consecutive spaces with a single space. I'll add comments for all of these methods. |
||
| return value.toString().trim().replace(/\s+/g, ' '); | ||
| }; | ||
|
|
||
| /** | ||
| * This method implements AWS Signature 4 logic for a very specific request format. | ||
| * The signing logic is described here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html | ||
| */ | ||
| export function aws4Sign(options: Options, credentials: AWSCredentials): SignedHeaders { | ||
| /** | ||
| * From the spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html | ||
| * | ||
| * Summary of signing steps | ||
| * 1. Create a canonical request | ||
| * Arrange the contents of your request (host, action, headers, etc.) into a standard canonical format. The canonical request is one of the inputs used to create the string to sign. | ||
| * 2. Create a hash of the canonical request | ||
| * Hash the canonical request using the same algorithm that you used to create the hash of the payload. The hash of the canonical request is a string of lowercase hexadecimal characters. | ||
| * 3. Create a string to sign | ||
| * Create a string to sign with the canonical request and extra information such as the algorithm, request date, credential scope, and the hash of the canonical request. | ||
| * 4. Derive a signing key | ||
| * Use the secret access key to derive the key used to sign the request. | ||
| * 5. Calculate the signature | ||
| * Perform a keyed hash operation on the string to sign using the derived signing key as the hash key. | ||
| * 6. Add the signature to the request | ||
| * Add the calculated signature to an HTTP header or to the query string of the request. | ||
| */ | ||
|
|
||
| // 1: Create a canonical request | ||
|
|
||
| // Date – The date and time used to sign the request. If not provided, use the current date. | ||
| const date = options.date || new Date(); | ||
| // RequestDateTime – The date and time used in the credential scope. This value is the current UTC time in ISO 8601 format (for example, 20130524T000000Z). | ||
| const requestDateTime = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); | ||
| // RequestDate – The date used in the credential scope. This value is the current UTC date in YYYYMMDD format (for example, 20130524). | ||
| const requestDate = requestDateTime.substring(0, 8); | ||
| // Method – The HTTP request method. For us, this is always 'POST'. | ||
| const method = options.method; | ||
| // CanonicalUri – The URI-encoded version of the absolute path component URI, starting with the / that follows the domain name and up to the end of the string | ||
| // For our requests, this is always '/' | ||
| const canonicalUri = options.path; | ||
| // CanonicalQueryString – The URI-encoded query string parameters. For our requests, there are no query string parameters, so this is always an empty string. | ||
| const canonicalQuerystring = ''; | ||
|
|
||
| // CanonicalHeaders – A list of request headers with their values. Individual header name and value pairs are separated by the newline character ("\n"). | ||
| // All of our known/expected headers are included here, there are no extra headers. | ||
| const headers = new Headers({ | ||
| 'content-length': convertHeaderValue(options.headers['Content-Length']), | ||
| 'content-type': convertHeaderValue(options.headers['Content-Type']), | ||
| host: convertHeaderValue(options.host), | ||
| 'x-amz-date': convertHeaderValue(requestDateTime), | ||
| 'x-mongodb-gs2-cb-flag': convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag']), | ||
| 'x-mongodb-server-nonce': convertHeaderValue(options.headers['X-MongoDB-Server-Nonce']) | ||
| }); | ||
| // If session token is provided, include it in the headers | ||
| if ('sessionToken' in credentials && credentials.sessionToken) { | ||
| headers.append('x-amz-security-token', convertHeaderValue(credentials.sessionToken)); | ||
| } | ||
| // Canonical headers are lowercased and sorted. | ||
| const canonicalHeaders = Array.from(headers.entries()) | ||
| .map(([key, value]) => `${key.toLowerCase()}:${value}`) | ||
| .sort() | ||
| .join('\n'); | ||
| const canonicalHeaderNames = Array.from(headers.keys()).map(header => header.toLowerCase()); | ||
| // SignedHeaders – An alphabetically sorted, semicolon-separated list of lowercase request header names. | ||
| const signedHeaders = canonicalHeaderNames.sort().join(';'); | ||
|
|
||
| // HashedPayload – A string created using the payload in the body of the HTTP request as input to a hash function. This string uses lowercase hexadecimal characters. | ||
| const hashedPayload = getHash(options.body); | ||
|
|
||
| // CanonicalRequest – A string that includes the above elements, separated by newline characters. | ||
| const canonicalRequest = [ | ||
| method, | ||
| canonicalUri, | ||
| canonicalQuerystring, | ||
| canonicalHeaders + '\n', | ||
| signedHeaders, | ||
| hashedPayload | ||
| ].join('\n'); | ||
|
|
||
| // 2. Create a hash of the canonical request | ||
| // HashedCanonicalRequest – A string created by using the canonical request as input to a hash function. | ||
| const hashedCanonicalRequest = getHash(canonicalRequest); | ||
|
|
||
| // 3. Create a string to sign | ||
| // Algorithm – The algorithm used to create the hash of the canonical request. For SigV4, use AWS4-HMAC-SHA256. | ||
| const algorithm = 'AWS4-HMAC-SHA256'; | ||
| // CredentialScope – The credential scope, which restricts the resulting signature to the specified Region and service. | ||
| // Has the following format: YYYYMMDD/region/service/aws4_request. | ||
| const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; | ||
| // StringToSign – A string that includes the above elements, separated by newline characters. | ||
| const stringToSign = [algorithm, requestDateTime, credentialScope, hashedCanonicalRequest].join( | ||
| '\n' | ||
| ); | ||
|
|
||
| // 4. Derive a signing key | ||
| // To derive a signing key for SigV4, perform a succession of keyed hash operations (HMAC) on the request date, Region, and service, with your AWS secret access key as the key for the initial hashing operation. | ||
| const dateKey = getHmacBuffer('AWS4' + credentials.secretAccessKey, requestDate); | ||
| const dateRegionKey = getHmacBuffer(dateKey, options.region); | ||
| const dateRegionServiceKey = getHmacBuffer(dateRegionKey, options.service); | ||
| const signingKey = getHmacBuffer(dateRegionServiceKey, 'aws4_request'); | ||
|
|
||
| // 5. Calculate the signature | ||
| const signature = getHmacString(signingKey, stringToSign); | ||
|
|
||
| // 6. Add the signature to the request | ||
| // Calculate the Authorization header | ||
| const authorizationHeader = [ | ||
| 'AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credentialScope, | ||
| 'SignedHeaders=' + signedHeaders, | ||
| 'Signature=' + signature | ||
| ].join(', '); | ||
|
|
||
| // Return the calculated headers | ||
| return { | ||
| headers: { | ||
| Authorization: authorizationHeader, | ||
| 'X-Amz-Date': requestDateTime | ||
| } | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,7 +89,7 @@ export interface AWSCredentials { | |
| expiration?: Date; | ||
| } | ||
|
|
||
| type CredentialProvider = { | ||
| export type CredentialProvider = { | ||
| fromNodeProviderChain( | ||
| this: void, | ||
| options: { clientConfig: { region: string } } | ||
|
|
@@ -203,66 +203,6 @@ export function getSocks(): SocksLib | { kModuleError: MongoMissingDependencyErr | |
| } | ||
| } | ||
|
|
||
| interface AWS4 { | ||
| /** | ||
| * Created these inline types to better assert future usage of this API | ||
| * @param options - options for request | ||
| * @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y | ||
| */ | ||
| sign( | ||
| this: void, | ||
| options: { | ||
| path: '/'; | ||
| body: string; | ||
| host: string; | ||
| method: 'POST'; | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded'; | ||
| 'Content-Length': number; | ||
| 'X-MongoDB-Server-Nonce': string; | ||
| 'X-MongoDB-GS2-CB-Flag': 'n'; | ||
| }; | ||
| service: string; | ||
| region: string; | ||
| }, | ||
| credentials: | ||
| | { | ||
| accessKeyId: string; | ||
| secretAccessKey: string; | ||
| sessionToken: string; | ||
| } | ||
| | { | ||
| accessKeyId: string; | ||
| secretAccessKey: string; | ||
| } | ||
| | undefined | ||
| ): { | ||
| headers: { | ||
| Authorization: string; | ||
| 'X-Amz-Date': string; | ||
| }; | ||
| }; | ||
| } | ||
|
|
||
| export const aws4: AWS4 | { kModuleError: MongoMissingDependencyError } = loadAws4(); | ||
|
|
||
| function loadAws4() { | ||
| let aws4: AWS4 | { kModuleError: MongoMissingDependencyError }; | ||
| try { | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports | ||
| aws4 = require('aws4'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's make sure to mark the ticket as having DevTools impact (we can now also remove this dependency)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, but we are referencing it in devtools precisely because it's optional for the driver, but not for us :) Not a big thing, just a reminder that we need to do this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I hadn't realized that. Can you point me to the devtools repo? Don't think I've seen that one yet.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Like it is used here in mongosh: https://github.com/mongodb-js/mongosh/blob/e26f538b5f0b0c40b7f7643695cc0edd73372ef6/packages/service-provider-node-driver/package.json#L54 Anna's referring to in JIRA we have a field for "dev tools changes needed" that will auto file an attached ticket so that our team can do whatever follow up there might be, in this case, some welcome dep clean up :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, gotcha, still trying to figure out what's automated (well done, btw!) and what we have to update manually. |
||
| } catch (error) { | ||
| aws4 = makeErrorModule( | ||
| new MongoMissingDependencyError( | ||
| 'Optional module `aws4` not found. Please install it to enable AWS authentication', | ||
| { cause: error, dependencyName: 'aws4' } | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| return aws4; | ||
| } | ||
|
|
||
| /** A utility function to get the instance of mongodb-client-encryption, if it exists. */ | ||
| export function getMongoDBClientEncryption(): | ||
| | typeof import('mongodb-client-encryption') | ||
|
|
||
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.
Is this only exposed so we can unit test sigv4?
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. Do we have a different way to override
new Date()in tests?