diff --git a/README.md b/README.md index c060be9..14a9eee 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ S3 URL manipulation helper similar to standard URL class - Support both Node.js and browser environment - Simple and lightweight - No dependencies - - Typescript support - - Built-in presigned URL generation + - TypeScript support + - Built-in presigned URL generation (sync and promised versions) ## Installation @@ -64,9 +64,9 @@ S3Url { // Making a http copy const httpUrl = s3Url.clone({ protocol: 'http:' }).href; -// Generaing presigned URL, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY +// Generaing presigned URL // env vars can be used instead of passing arguments -const presignedUrl = s3Url.sign({ accessKeyId, secretAccessKey }); +const presignedUrl = await s3Url.sign({ accessKeyId, secretAccessKey }); ``` ## Providers @@ -74,6 +74,7 @@ const presignedUrl = s3Url.sign({ accessKeyId, secretAccessKey }); Currently, the library is tested with the following providers: - Amazon S3 + - Cloudflare R2 - DigitalOcean Spaces - Stackpath Storage - Generic provider (Supports URL schema like bucket.region.example.com) @@ -91,7 +92,7 @@ s3Parser.addProvider(new S3Provider({ ### Adding a custom provider implementation -To add a parser for a custom provider you need to extend S3Provider class. +To add a parser for a custom provider, you need to extend S3Provider class. You can use [AmazonAwsProvider.js](src/providers/AmazonAwsProvider.js) as an example. diff --git a/src/S3Provider.js b/src/S3Provider.js index 9dd09be..92c0eb7 100644 --- a/src/S3Provider.js +++ b/src/S3Provider.js @@ -6,9 +6,8 @@ const S3Url = require('./S3Url'); const { decodeS3Key, encodeS3Key, - encodeSpecialUrlChars, } = require('./utils/encode'); -const { bufferToHex, hmacSha256, sha256 } = require('./utils/crypto'); +const { buildSignedUrl, buildSignedUrlSync } = require('./utils/sign'); class S3Provider { constructor({ id, domain, endpoint, title } = {}) { @@ -37,53 +36,20 @@ class S3Provider { .join('/'); } - async buildSignedUrl({ - accessKeyId = getEnv('AWS_ACCESS_KEY_ID'), - secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY'), - expires = 60 * 60 * 24 * 7, - method = 'GET', - s3Url, - timestamp = Date.now(), - }) { - const algo = 'AWS4-HMAC-SHA256'; - const url = new URL(this.buildUrl({ s3Url })); - const time = new Date(timestamp) - .toISOString() - .slice(0, 19) - .replace(/\W/g, '') + 'Z'; - const date = time.slice(0, 8); - const signRegion = this.getSignRegion(s3Url); - const scope = `${date}/${signRegion}/s3/aws4_request`; - - url.searchParams.set('X-Amz-Algorithm', algo); - url.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`); - url.searchParams.set('X-Amz-Date', time); - url.searchParams.set('X-Amz-Expires', expires.toString(10)); - url.searchParams.set('X-Amz-SignedHeaders', 'host'); - url.searchParams.sort(); - - url.search = encodeSpecialUrlChars(url.search); - url.pathname = encodeSpecialUrlChars(url.pathname); - - const request = [ - method.toUpperCase(), - url.pathname, - url.search.slice(1), - `host:${url.host}`, - '', - 'host', - 'UNSIGNED-PAYLOAD', - ].join('\n'); - - const signString = [algo, time, scope, await sha256(request)].join('\n'); - - const signPromise = [date, signRegion, 's3', 'aws4_request', signString] - .reduce( - (promise, data) => promise.then((prev) => hmacSha256(data, prev)), - Promise.resolve('AWS4' + secretAccessKey) - ); - - return `${url.href}&X-Amz-Signature=${bufferToHex(await signPromise)}`; + async buildSignedUrl({ s3Url, ...options }) { + return buildSignedUrl({ + ...options, + region: this.getSignRegion(s3Url), + url: this.buildUrl({ s3Url }), + }); + } + + buildSignedUrlSync({ s3Url, ...options }) { + return buildSignedUrlSync({ + ...options, + region: this.getSignRegion(s3Url), + url: this.buildUrl({ s3Url }), + }); } buildUrl({ s3Url }) { @@ -207,12 +173,4 @@ class S3Provider { } } -function getEnv(name) { - if (typeof process !== 'undefined' && process.env) { - return process.env[name]; - } - - return undefined; -} - module.exports = S3Provider; diff --git a/src/S3Url.js b/src/S3Url.js index 1e92d8e..134c73d 100644 --- a/src/S3Url.js +++ b/src/S3Url.js @@ -134,23 +134,20 @@ class S3Url { return this; } - async sign({ - accessKeyId, - expires, - method, - secretAccessKey, - } = {}) { + async sign(params = {}) { if (!this.provider) { throw new Error('Cannot sign url from invalid S3Url'); } - return this.provider.buildSignedUrl({ - accessKeyId, - expires, - method, - s3Url: this, - secretAccessKey, - }); + return this.provider.buildSignedUrl({ ...params, s3Url: this }); + } + + signSync(params = {}) { + if (!this.provider) { + throw new Error('Cannot sign url from invalid S3Url'); + } + + return this.provider.buildSignedUrlSync({ ...params, s3Url: this }); } trimSlashes({ begin = false, end = false } = {}) { diff --git a/src/__specs__/S3Provider.spec.js b/src/__specs__/S3Provider.spec.js index a473137..cb8143d 100644 --- a/src/__specs__/S3Provider.spec.js +++ b/src/__specs__/S3Provider.spec.js @@ -4,6 +4,7 @@ const { describe, expect, it } = require('humile'); const AmazonAwsProvider = require('../providers/AmazonAwsProvider'); const S3Provider = require('../S3Provider'); const S3Url = require('../S3Url'); +const signFixtures = require('../utils/__specs__/sign.fixtures'); describe('S3Provider', () => { describe('parseUrl', () => { @@ -54,21 +55,11 @@ describe('S3Provider', () => { const s3Url = new S3Url('https://bucket.s3.amazonaws.com/test/file.zip'); const provider = new AmazonAwsProvider(); const signedUrl = await provider.buildSignedUrl({ - accessKeyId: 'test', - secretAccessKey: 'test', + ...signFixtures.simpleAwsSign.input, + url: '', // make sure it's not used s3Url, - timestamp: 0, }); - expect(signedUrl).toBe( - 'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip' - + '?X-Amz-Algorithm=AWS4-HMAC-SHA256' - + '&X-Amz-Credential=test%2F19700101%2Fus-east-1%2Fs3%2Faws4_request' - + '&X-Amz-Date=19700101T000000Z' - + '&X-Amz-Expires=604800' - + '&X-Amz-SignedHeaders=host' - // eslint-disable-next-line max-len - + '&X-Amz-Signature=cbefd44bf6ccaec9a70b2eff6bcc17d14039c2d204c5e58545986fcf76cf28be' - ); + expect(signedUrl).toBe(signFixtures.simpleAwsSign.output); }); }); }); diff --git a/src/__specs__/S3Url.spec.js b/src/__specs__/S3Url.spec.js index 169878d..ce674ba 100644 --- a/src/__specs__/S3Url.spec.js +++ b/src/__specs__/S3Url.spec.js @@ -2,8 +2,9 @@ const { describe, expect, it } = require('humile'); const { S3Url } = require('..'); +const signFixtures = require('../utils/__specs__/sign.fixtures'); -describe('S3Url', () => { +describe(S3Url.name, () => { describe('isValid', () => { it('true when provider is detected', () => { const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/'); @@ -16,18 +17,6 @@ describe('S3Url', () => { }); }); - describe('clone', () => { - it('changes a key and bucket', () => { - const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/'); - - expect( - s3Url.clone({ key: 'My file.txt', region: 'eu-west-2' }).href - ).toBe( - 'https://mybucket.s3.eu-west-2.amazonaws.com/My+file.txt' - ); - }); - }); - describe('fileName', () => { it('returns empty string if a key is empty', () => { const s3Url = new S3Url('https://bucket.s3.amazonaws.com/'); @@ -148,7 +137,35 @@ describe('S3Url', () => { }); }); - describe('trimSlashes', () => { + describe(S3Url.prototype.clone.name, () => { + it('changes a key and bucket', () => { + const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/'); + + expect( + s3Url.clone({ key: 'My file.txt', region: 'eu-west-2' }).href + ).toBe( + 'https://mybucket.s3.eu-west-2.amazonaws.com/My+file.txt' + ); + }); + }); + + describe(S3Url.prototype.sign.name, () => { + it('signs a url', async () => { + const s3Url = new S3Url(signFixtures.simpleAwsSign.input.url); + const signedUrl = await s3Url.sign(signFixtures.simpleAwsSign.input); + expect(signedUrl).toBe(signFixtures.simpleAwsSign.output); + }); + }); + + describe(S3Url.prototype.signSync.name, () => { + it('signs a url', () => { + const s3Url = new S3Url(signFixtures.simpleAwsSign.input.url); + const signedUrl = s3Url.signSync(signFixtures.simpleAwsSign.input); + expect(signedUrl).toBe(signFixtures.simpleAwsSign.output); + }); + }); + + describe(S3Url.prototype.trimSlashes.name, () => { it('trims end slash', () => { const s3Url = new S3Url('https://bucket.s3.amazonaws.com/dir/file.zip/'); expect(s3Url.key).toBe('dir/file.zip/'); diff --git a/src/index.d.ts b/src/index.d.ts index a523082..8c118a4 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,3 +1,11 @@ +interface SignOptions { + accessKeyId?: string, + expires?: number, + method?: 'GET' | 'PUT', + secretAccessKey?: string, + timestamp?: number, +} + export class S3Url { bucket: string; bucketPosition: 'hostname' | 'pathname'; @@ -36,14 +44,8 @@ export class S3Url { setProtocol(protocol: string): this; setProvider(provider: S3Provider | string): this; setRegion(region: string): this; - - sign(opts?: { - accessKeyId?: string, - expires?: number, - method?: string, - secretAccessKey?: string, - }): Promise - + sign(options?: SignOptions): Promise; + signSync(options?: SignOptions): string; trimSlashes(options?: { begin?: boolean; end?: boolean }): this; } @@ -66,6 +68,8 @@ export class S3Provider implements ProviderInterface { title: string }); + buildSignedUrl(options: SignOptions & { s3Url: S3Url }): Promise; + buildSignedUrlSync(options: SignOptions & { s3Url: S3Url }): string; buildUrl({ s3Url }: { s3Url: S3Url }): string; getEndpoint({ region }?: { region: string }): string; matchHostName(hostName: string): boolean; diff --git a/src/utils/__specs__/sign.fixtures.js b/src/utils/__specs__/sign.fixtures.js new file mode 100644 index 0000000..5d4d9bd --- /dev/null +++ b/src/utils/__specs__/sign.fixtures.js @@ -0,0 +1,22 @@ +'use strict'; + +/* eslint-disable max-len */ + +module.exports = { + simpleAwsSign: { + input: { + accessKeyId: 'test', + secretAccessKey: 'test', + region: 'us-east-1', + timestamp: 0, + url: 'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip', + }, + output: 'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip' + + '?X-Amz-Algorithm=AWS4-HMAC-SHA256' + + '&X-Amz-Credential=test%2F19700101%2Fus-east-1%2Fs3%2Faws4_request' + + '&X-Amz-Date=19700101T000000Z' + + '&X-Amz-Expires=604800' + + '&X-Amz-SignedHeaders=host' + + '&X-Amz-Signature=cbefd44bf6ccaec9a70b2eff6bcc17d14039c2d204c5e58545986fcf76cf28be', + }, +}; diff --git a/src/utils/__specs__/sign.spec.js b/src/utils/__specs__/sign.spec.js new file mode 100644 index 0000000..b3634e3 --- /dev/null +++ b/src/utils/__specs__/sign.spec.js @@ -0,0 +1,25 @@ +'use strict'; + +const { describe, expect, it } = require('humile'); +const { buildSignedUrl, buildSignedUrlSync } = require('../sign'); +const fixtures = require('./sign.fixtures'); + +describe('sign', () => { + describe(buildSignedUrl.name, () => { + for (const [name, data] of Object.entries(fixtures)) { + it(`matches ${name} test result`, async () => { + const signedUrl = await buildSignedUrl(data.input); + expect(signedUrl).toBe(data.output); + }); + } + }); + + describe(buildSignedUrlSync.name, () => { + for (const [name, data] of Object.entries(fixtures)) { + it(`matches ${name} test result`, async () => { + const signedUrl = buildSignedUrlSync(data.input); + expect(signedUrl).toBe(data.output); + }); + } + }); +}); diff --git a/src/utils/crypto.browser.js b/src/utils/crypto.browser.js index 721f5b1..16c27d2 100644 --- a/src/utils/crypto.browser.js +++ b/src/utils/crypto.browser.js @@ -2,7 +2,15 @@ /* eslint-env browser */ -module.exports = { bufferToHex, hmacSha256, sha256 }; +const sha = require('./sha256'); + +module.exports = { + bufferToHex, + hmacSha256, + hmacSha256Sync, + sha256, + sha256Sync, +}; const encoder = new TextEncoder(); @@ -17,12 +25,20 @@ async function hmacSha256(message, secret) { return window.crypto.subtle.sign('HMAC', cryptoKey, toBuffer(message)); } +function hmacSha256Sync(message, secret) { + return sha.hmac_sha256(message, secret); +} + async function sha256(message) { return bufferToHex( await window.crypto.subtle.digest('SHA-256', toBuffer(message)) ); } +function sha256Sync(message) { + return sha.sha256(message); +} + function bufferToHex(buffer) { const hexArr = Array.prototype.map.call( new Uint8Array(buffer), diff --git a/src/utils/crypto.js b/src/utils/crypto.js index ce6c730..03d2b4e 100644 --- a/src/utils/crypto.js +++ b/src/utils/crypto.js @@ -2,9 +2,19 @@ const crypto = require('crypto'); -module.exports = { bufferToHex, hmacSha256, sha256 }; +module.exports = { + bufferToHex, + hmacSha256, + hmacSha256Sync, + sha256, + sha256Sync, +}; async function hmacSha256(message, secret) { + return hmacSha256Sync(message, secret); +} + +function hmacSha256Sync(message, secret) { return crypto .createHmac('sha256', secret) .update(message, 'utf8') @@ -12,6 +22,10 @@ async function hmacSha256(message, secret) { } async function sha256(message) { + return sha256Sync(message); +} + +function sha256Sync(message) { return crypto .createHash('sha256') .update(message, 'utf8') diff --git a/src/utils/sha256.js b/src/utils/sha256.js new file mode 100644 index 0000000..dfce775 --- /dev/null +++ b/src/utils/sha256.js @@ -0,0 +1,138 @@ +/* eslint-disable */ +/** @link https://github.com/6502/sha256/blob/main/sha256.js **/ +module.exports = { sha256, hmac_sha256 }; + +/* +Copyright 2022 Andrea Griffini + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +// sha256(data) returns the digest +// sha256() returns an object you can call .add(data) zero or more time and .digest() at the end +// digest is a 32-byte Uint8Array instance with an added .hex() function. +// Input should be either a string (that will be encoded as UTF-8) or an array-like object with values 0..255. +function sha256(data) { + let h0 = 0x6a09e667; let h1 = 0xbb67ae85; let h2 = 0x3c6ef372; let h3 = 0xa54ff53a; + let h4 = 0x510e527f; let h5 = 0x9b05688c; let h6 = 0x1f83d9ab; let h7 = 0x5be0cd19; + let tsz = 0; let + bp = 0; + const k = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]; + const rrot = (x, n) => (x >>> n) | (x << (32 - n)); + const w = new Uint32Array(64); + const buf = new Uint8Array(64); + const process = () => { + for (let j = 0, r = 0; j < 16; j++, r += 4) { + w[j] = (buf[r] << 24) | (buf[r + 1] << 16) | (buf[r + 2] << 8) | buf[r + 3]; + } + for (let j = 16; j < 64; j++) { + const s0 = rrot(w[j - 15], 7) ^ rrot(w[j - 15], 18) ^ (w[j - 15] >>> 3); + const s1 = rrot(w[j - 2], 17) ^ rrot(w[j - 2], 19) ^ (w[j - 2] >>> 10); + w[j] = (w[j - 16] + s0 + w[j - 7] + s1) | 0; + } + let a = h0; let b = h1; let c = h2; let d = h3; let e = h4; let f = h5; let g = h6; let + h = h7; + for (let j = 0; j < 64; j++) { + const S1 = rrot(e, 6) ^ rrot(e, 11) ^ rrot(e, 25); + const ch = (e & f) ^ ((~e) & g); + const t1 = (h + S1 + ch + k[j] + w[j]) | 0; + const S0 = rrot(a, 2) ^ rrot(a, 13) ^ rrot(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; + } + h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; + h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0; + bp = 0; + }; + const add = (data) => { + if (typeof data === 'string') { + data = typeof TextEncoder === 'undefined' ? Buffer.from(data) : (new TextEncoder()).encode(data); + } + for (let i = 0; i < data.length; i++) { + buf[bp++] = data[i]; + if (bp === 64) process(); + } + tsz += data.length; + }; + const digest = () => { + buf[bp++] = 0x80; if (bp == 64) process(); + if (bp + 8 > 64) { + while (bp < 64) buf[bp++] = 0x00; + process(); + } + while (bp < 58) buf[bp++] = 0x00; + // Max number of bytes is 35,184,372,088,831 + const L = tsz * 8; + buf[bp++] = (L / 1099511627776.0) & 255; + buf[bp++] = (L / 4294967296.0) & 255; + buf[bp++] = L >>> 24; + buf[bp++] = (L >>> 16) & 255; + buf[bp++] = (L >>> 8) & 255; + buf[bp++] = L & 255; + process(); + const reply = new Uint8Array(32); + reply[0] = h0 >>> 24; reply[1] = (h0 >>> 16) & 255; reply[2] = (h0 >>> 8) & 255; reply[3] = h0 & 255; + reply[4] = h1 >>> 24; reply[5] = (h1 >>> 16) & 255; reply[6] = (h1 >>> 8) & 255; reply[7] = h1 & 255; + reply[8] = h2 >>> 24; reply[9] = (h2 >>> 16) & 255; reply[10] = (h2 >>> 8) & 255; reply[11] = h2 & 255; + reply[12] = h3 >>> 24; reply[13] = (h3 >>> 16) & 255; reply[14] = (h3 >>> 8) & 255; reply[15] = h3 & 255; + reply[16] = h4 >>> 24; reply[17] = (h4 >>> 16) & 255; reply[18] = (h4 >>> 8) & 255; reply[19] = h4 & 255; + reply[20] = h5 >>> 24; reply[21] = (h5 >>> 16) & 255; reply[22] = (h5 >>> 8) & 255; reply[23] = h5 & 255; + reply[24] = h6 >>> 24; reply[25] = (h6 >>> 16) & 255; reply[26] = (h6 >>> 8) & 255; reply[27] = h6 & 255; + reply[28] = h7 >>> 24; reply[29] = (h7 >>> 16) & 255; reply[30] = (h7 >>> 8) & 255; reply[31] = h7 & 255; + reply.hex = () => { + let res = ''; + reply.forEach((x) => res += ('0' + x.toString(16)).slice(-2)); + return res; + }; + return reply; + }; + if (data === undefined) return { add, digest }; + add(data); + return digest(); +} + +// HMAC-SHA256 implementation +function hmac_sha256(key, message) { + if (typeof key === 'string') { + key = typeof TextEncoder === 'undefined' ? Buffer.from(key) : (new TextEncoder()).encode(key); + } + if (key.length > 64) key = sha256(key); + const inner = new Uint8Array(64).fill(0x36); + const outer = new Uint8Array(64).fill(0x5c); + for (let i = 0; i < key.length; i++) { + inner[i] ^= key[i]; + outer[i] ^= key[i]; + } + const pass1 = sha256(); const + pass2 = sha256(); + pass1.add(inner); + pass1.add(message); + pass2.add(outer); + pass2.add(pass1.digest()); + return pass2.digest(); +} diff --git a/src/utils/sign.js b/src/utils/sign.js new file mode 100644 index 0000000..4876a24 --- /dev/null +++ b/src/utils/sign.js @@ -0,0 +1,118 @@ +'use strict'; + +const { encodeSpecialUrlChars } = require('./encode'); +const { + bufferToHex, + hmacSha256, + hmacSha256Sync, + sha256, + sha256Sync, +} = require('./crypto'); + +module.exports = { buildSignedUrl, buildSignedUrlSync }; + +async function buildSignedUrl({ + accessKeyId = getEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY'), + expires = 60 * 60 * 24 * 7, + method = 'GET', + region, + timestamp = Date.now(), + url: stringUrl, +}) { + const algo = 'AWS4-HMAC-SHA256'; + const url = new URL(stringUrl); + const time = new Date(timestamp) + .toISOString() + .slice(0, 19) + .replace(/\W/g, '') + 'Z'; + const date = time.slice(0, 8); + const scope = `${date}/${region}/s3/aws4_request`; + + url.searchParams.set('X-Amz-Algorithm', algo); + url.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`); + url.searchParams.set('X-Amz-Date', time); + url.searchParams.set('X-Amz-Expires', expires.toString(10)); + url.searchParams.set('X-Amz-SignedHeaders', 'host'); + url.searchParams.sort(); + + url.search = encodeSpecialUrlChars(url.search); + url.pathname = encodeSpecialUrlChars(url.pathname); + + const request = [ + method.toUpperCase(), + url.pathname, + url.search.slice(1), + `host:${url.host}`, + '', + 'host', + 'UNSIGNED-PAYLOAD', + ].join('\n'); + + const signString = [algo, time, scope, await sha256(request)].join('\n'); + + const signPromise = [date, region, 's3', 'aws4_request', signString] + .reduce( + (promise, data) => promise.then((prev) => hmacSha256(data, prev)), + Promise.resolve('AWS4' + secretAccessKey) + ); + + return `${url.href}&X-Amz-Signature=${bufferToHex(await signPromise)}`; +} + +function buildSignedUrlSync({ + accessKeyId = getEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY'), + expires = 60 * 60 * 24 * 7, + method = 'GET', + region, + timestamp = Date.now(), + url: stringUrl, +}) { + const algo = 'AWS4-HMAC-SHA256'; + const url = new URL(stringUrl); + const time = new Date(timestamp) + .toISOString() + .slice(0, 19) + .replace(/\W/g, '') + 'Z'; + const date = time.slice(0, 8); + const scope = `${date}/${region}/s3/aws4_request`; + + url.searchParams.set('X-Amz-Algorithm', algo); + url.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`); + url.searchParams.set('X-Amz-Date', time); + url.searchParams.set('X-Amz-Expires', expires.toString(10)); + url.searchParams.set('X-Amz-SignedHeaders', 'host'); + url.searchParams.sort(); + + url.search = encodeSpecialUrlChars(url.search); + url.pathname = encodeSpecialUrlChars(url.pathname); + + const request = [ + method.toUpperCase(), + url.pathname, + url.search.slice(1), + `host:${url.host}`, + '', + 'host', + 'UNSIGNED-PAYLOAD', + ].join('\n'); + + const signString = [algo, time, scope, sha256Sync(request)].join('\n'); + + const signature = [date, region, 's3', 'aws4_request', signString] + .reduce( + (res, data) => hmacSha256Sync(data, res), + 'AWS4' + secretAccessKey + ); + + return `${url.href}&X-Amz-Signature=${bufferToHex(signature)}`; +} + +function getEnv(name) { + if (typeof process !== 'undefined' && process.env) { + return process.env[name]; + } + + return undefined; +}