Skip to content

Commit 7cc1156

Browse files
committed
sigv4 implementation
1 parent f88bfe1 commit 7cc1156

6 files changed

Lines changed: 209 additions & 89 deletions

File tree

.evergreen/run-mongodb-aws-ecs-test.sh

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,3 @@ source ./.evergreen/prepare-shell.sh # should not run git clone
1313

1414
# load node.js
1515
source $DRIVERS_TOOLS/.evergreen/init-node-and-npm-env.sh
16-
17-
# run the tests
18-
npm install aws4

.evergreen/setup-mongodb-aws-auth-tests.sh

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,5 @@ cd $DRIVERS_TOOLS/.evergreen/auth_aws
2222

2323
cd $BEFORE
2424

25-
npm install --no-save aws4
26-
2725
# revert to show test output
2826
set -x

src/aws4.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import * as crypto from 'node:crypto';
2+
import * as queryString from 'node:querystring';
3+
4+
export interface AWS4 {
5+
/**
6+
* Created these inline types to better assert future usage of this API
7+
* @param options - options for request
8+
* @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y
9+
*/
10+
sign(
11+
this: void,
12+
options: {
13+
path: '/';
14+
body: string;
15+
host: string;
16+
method: 'POST';
17+
headers: {
18+
'Content-Type': 'application/x-www-form-urlencoded';
19+
'Content-Length': number;
20+
'X-MongoDB-Server-Nonce': string;
21+
'X-MongoDB-GS2-CB-Flag': 'n';
22+
};
23+
service: string;
24+
region: string;
25+
},
26+
credentials:
27+
| {
28+
accessKeyId: string;
29+
secretAccessKey: string;
30+
sessionToken: string;
31+
}
32+
| {
33+
accessKeyId: string;
34+
secretAccessKey: string;
35+
}
36+
| undefined
37+
): {
38+
headers: {
39+
Authorization: string;
40+
'X-Amz-Date': string;
41+
};
42+
};
43+
}
44+
45+
export function aws4Sign(
46+
this: void,
47+
options: {
48+
path: '/';
49+
body: string;
50+
host: string;
51+
method: 'POST';
52+
headers: {
53+
'Content-Type': 'application/x-www-form-urlencoded';
54+
'Content-Length': number;
55+
'X-MongoDB-Server-Nonce': string;
56+
'X-MongoDB-GS2-CB-Flag': 'n';
57+
};
58+
service: string;
59+
region: string;
60+
},
61+
credentials:
62+
| {
63+
accessKeyId: string;
64+
secretAccessKey: string;
65+
sessionToken: string;
66+
}
67+
| {
68+
accessKeyId: string;
69+
secretAccessKey: string;
70+
}
71+
| undefined
72+
): {
73+
headers: {
74+
Authorization: string;
75+
'X-Amz-Date': string;
76+
};
77+
} {
78+
let path: string;
79+
let query: queryString.ParsedUrlQuery | undefined;
80+
81+
const encode = (str: string) => {
82+
const encoded = encodeURIComponent(str);
83+
const replaced = encoded.replace(/[!'()*]/g, function (c) {
84+
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
85+
});
86+
return replaced;
87+
};
88+
89+
const queryIndex = options.path.indexOf('?');
90+
if (queryIndex < 0) {
91+
path = options.path;
92+
query = undefined;
93+
} else {
94+
path = options.path.slice(0, queryIndex);
95+
query = queryString.parse(options.path.slice(queryIndex + 1));
96+
}
97+
98+
let canonicalQuerystring = '';
99+
if (query) {
100+
const isS3 = options.service === 's3';
101+
const useFirstArrayValue = isS3;
102+
// const decodeSlashesInPath = isS3;
103+
// const decodePath = isS3;
104+
// const normalizePath = !isS3;
105+
const queryStrings: string[] = [];
106+
const sortedQueryKeys = Object.keys(query).sort();
107+
for (const key of sortedQueryKeys) {
108+
if (!key) {
109+
continue;
110+
}
111+
112+
const encodedKey = encode(key);
113+
let value: string | string[] | undefined = query[key];
114+
if (Array.isArray(value)) {
115+
let values: string[] = value;
116+
if (useFirstArrayValue) {
117+
values = [value[0]];
118+
}
119+
120+
for (const item of values) {
121+
const encodedValue = encode(item);
122+
queryStrings.push(`${encodedKey}=${encodedValue}`);
123+
}
124+
} else {
125+
value = value ?? '';
126+
const encodedValue = encode(value);
127+
queryStrings.push(`${encodedKey}=${encodedValue}`);
128+
}
129+
}
130+
canonicalQuerystring = queryStrings.join('&');
131+
}
132+
133+
const convertHeaderValue = (value: string | number) => {
134+
return value.toString().trim().replace(/\s+/g, ' ');
135+
};
136+
const headers: string[] = [
137+
`content-length:${convertHeaderValue(options.headers['Content-Length'])}\n`,
138+
`content-type:${convertHeaderValue(options.headers['Content-Type'])}\n`,
139+
`x-mongodb-gs2-cb-flag:${convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag'])}\n`,
140+
`x-mongodb-server-nonce:${convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])}\n`
141+
];
142+
const canonicalHeaders = headers.sort().join('\n');
143+
144+
const signedHeaders = 'content-length;content-type;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce';
145+
146+
const getHash = (str: string): string => {
147+
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
148+
};
149+
const getHmac = (key: string, str: string): string => {
150+
return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex');
151+
};
152+
const hashedPayload = getHash(options.body || '');
153+
154+
const canonicalUri = path;
155+
const canonicalRequest = [
156+
options.method,
157+
canonicalUri,
158+
canonicalQuerystring,
159+
canonicalHeaders,
160+
signedHeaders,
161+
hashedPayload
162+
].join('\n');
163+
164+
const canonicRequestHash = getHash(canonicalRequest);
165+
const requestDateTime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
166+
const requestDate = requestDateTime.substring(0, 8);
167+
const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`;
168+
169+
const stringToSign = [
170+
'AWS4-HMAC-SHA256',
171+
requestDateTime,
172+
credentialScope,
173+
canonicRequestHash
174+
].join('\n');
175+
176+
const getEnvCredentials = () => {
177+
const env = process.env;
178+
return {
179+
accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY,
180+
secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY,
181+
sessionToken: env.AWS_SESSION_TOKEN
182+
};
183+
};
184+
const creds = credentials || getEnvCredentials();
185+
const dateKey = getHmac('AWS4' + creds.secretAccessKey, requestDate);
186+
const dateRegionKey = getHmac(dateKey, options.region);
187+
const dateRegionServiceKey = getHmac(dateRegionKey, options.service);
188+
const signingKey = getHmac(dateRegionServiceKey, 'aws4_request');
189+
const signature = getHmac(signingKey, stringToSign);
190+
191+
const authorizationHeader = [
192+
'AWS4-HMAC-SHA256 Credential=' + creds.accessKeyId + '/' + credentialScope,
193+
'SignedHeaders=' + signedHeaders,
194+
'Signature=' + signature
195+
].join(', ');
196+
197+
return {
198+
headers: {
199+
Authorization: authorizationHeader,
200+
'X-Amz-Date': requestDateTime
201+
}
202+
};
203+
}
204+
205+
// export const aws4: AWS4 = {
206+
// sign: aws4Sign
207+
// };

src/cmap/auth/mongodb_aws.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { aws4Sign } from '../../aws4';
12
import type { Binary, BSONSerializeOptions } from '../../bson';
23
import * as BSON from '../../bson';
3-
import { aws4 } from '../../deps';
44
import {
55
MongoCompatibilityError,
66
MongoMissingCredentialsError,
@@ -45,11 +45,6 @@ export class MongoDBAWS extends AuthProvider {
4545
throw new MongoMissingCredentialsError('AuthContext must provide credentials.');
4646
}
4747

48-
if ('kModuleError' in aws4) {
49-
throw aws4['kModuleError'];
50-
}
51-
const { sign } = aws4;
52-
5348
if (maxWireVersion(connection) < 9) {
5449
throw new MongoCompatibilityError(
5550
'MONGODB-AWS authentication requires MongoDB version 4.4 or later'
@@ -114,7 +109,7 @@ export class MongoDBAWS extends AuthProvider {
114109
}
115110

116111
const body = 'Action=GetCallerIdentity&Version=2011-06-15';
117-
const options = sign(
112+
const options = aws4Sign(
118113
{
119114
method: 'POST',
120115
host,

src/deps.ts

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -203,66 +203,6 @@ export function getSocks(): SocksLib | { kModuleError: MongoMissingDependencyErr
203203
}
204204
}
205205

206-
interface AWS4 {
207-
/**
208-
* Created these inline types to better assert future usage of this API
209-
* @param options - options for request
210-
* @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y
211-
*/
212-
sign(
213-
this: void,
214-
options: {
215-
path: '/';
216-
body: string;
217-
host: string;
218-
method: 'POST';
219-
headers: {
220-
'Content-Type': 'application/x-www-form-urlencoded';
221-
'Content-Length': number;
222-
'X-MongoDB-Server-Nonce': string;
223-
'X-MongoDB-GS2-CB-Flag': 'n';
224-
};
225-
service: string;
226-
region: string;
227-
},
228-
credentials:
229-
| {
230-
accessKeyId: string;
231-
secretAccessKey: string;
232-
sessionToken: string;
233-
}
234-
| {
235-
accessKeyId: string;
236-
secretAccessKey: string;
237-
}
238-
| undefined
239-
): {
240-
headers: {
241-
Authorization: string;
242-
'X-Amz-Date': string;
243-
};
244-
};
245-
}
246-
247-
export const aws4: AWS4 | { kModuleError: MongoMissingDependencyError } = loadAws4();
248-
249-
function loadAws4() {
250-
let aws4: AWS4 | { kModuleError: MongoMissingDependencyError };
251-
try {
252-
// eslint-disable-next-line @typescript-eslint/no-require-imports
253-
aws4 = require('aws4');
254-
} catch (error) {
255-
aws4 = makeErrorModule(
256-
new MongoMissingDependencyError(
257-
'Optional module `aws4` not found. Please install it to enable AWS authentication',
258-
{ cause: error, dependencyName: 'aws4' }
259-
)
260-
);
261-
}
262-
263-
return aws4;
264-
}
265-
266206
/** A utility function to get the instance of mongodb-client-encryption, if it exists. */
267207
export function getMongoDBClientEncryption():
268208
| typeof import('mongodb-client-encryption')

test/unit/assorted/optional_require.test.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { resolve } from 'path';
44

55
import { AuthContext } from '../../../src/cmap/auth/auth_provider';
66
import { GSSAPI } from '../../../src/cmap/auth/gssapi';
7-
import { MongoDBAWS } from '../../../src/cmap/auth/mongodb_aws';
87
import { compress } from '../../../src/cmap/wire_protocol/compression';
98
import { MongoMissingDependencyError } from '../../../src/error';
109
import { HostAddress } from '../../../src/utils';
@@ -51,20 +50,4 @@ describe('optionalRequire', function () {
5150
expect(error).to.be.instanceOf(MongoMissingDependencyError);
5251
});
5352
});
54-
55-
describe('aws4', function () {
56-
it('should error if not installed', async function () {
57-
const moduleName = 'aws4';
58-
if (moduleExistsSync(moduleName)) {
59-
return this.skip();
60-
}
61-
const mdbAWS = new MongoDBAWS();
62-
63-
const error = await mdbAWS
64-
.auth(new AuthContext({ hello: { maxWireVersion: 9 } }, true, null))
65-
.catch(error => error);
66-
67-
expect(error).to.be.instanceOf(MongoMissingDependencyError);
68-
});
69-
});
7053
});

0 commit comments

Comments
 (0)