From fef4f9a2aca3f617d6e3c06234e6bc6130d5fcdc Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Wed, 24 Nov 2021 15:37:47 -0500 Subject: [PATCH] import aws s3 impl from worker-pastebin --- src/aws/auth.coffee | 118 ++++++++++++++++++++++++++++++++++++++++++++ src/aws/s3.coffee | 88 +++++++++++++++++++++++++++++++++ src/crypto.coffee | 32 ++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/aws/auth.coffee create mode 100644 src/aws/s3.coffee create mode 100644 src/crypto.coffee diff --git a/src/aws/auth.coffee b/src/aws/auth.coffee new file mode 100644 index 0000000..f08cdfb --- /dev/null +++ b/src/aws/auth.coffee @@ -0,0 +1,118 @@ +import * as crypto from '../crypto' + +# Implement AWS signature authentication +class AwsAuth + constructor: (url, conf) -> + @url = new URL url + @date = new Date() + @conf = conf + @region = conf.region + + # Setters + setMethod: (method) -> + @method = method.toUpperCase() + @ + setQueryStringMap: (qsMap) -> + @qsMap = qsMap + @ + setHeaderMap: (headerMap) -> + # headers MUST contain `x-amz-content-sha256: UNSIGNED-PAYLOAD` + @headerMap = + 'host': @url.host + for [key, val] in Object.entries headerMap + key = key.toLowerCase() + val = val.trim() + if key == "content-type" || key.startsWith "x-amz-" + @headerMap[key] = val + if !@headerMap['x-amz-content-sha256'] + throw "Must contain sha256 header" + @ + setRegion: (region) -> + @region = region + @ + setService: (service) -> + @service = service # "s3" + @ + + # Signature calculation: + authorizedHeader: (origHeader) -> + origHeader['x-amz-date'] = @timeStampISO8601() + origHeader['authorization'] = await @authorizationHeader() + + authorizationHeader: -> + "AWS4-HMAC-SHA256 " + + "Credential=" + @credential() + "," + + "SignedHeaders=" + @signedHeaders() + "," + + "Signature=" + await @calculateSignature() + + credential: -> + @conf.accessKeyId + "/" + @scope() + + calculateSignature: -> + crypto.hex await crypto.HMAC_SHA256 await @signingKey(), await @stringToSign() + + signingKey: -> + accessKey = crypto.utf8Bytes "AWS4" + @conf.secretAccessKey + dateKey = await crypto.HMAC_SHA256 accessKey, @timeStampYYYYMMDD() + dateRegionKey = await crypto.HMAC_SHA256 dateKey, @region + dateRegionServiceKey = await crypto.HMAC_SHA256 dateRegionKey, @service + crypto.HMAC_SHA256 dateRegionServiceKey, "aws4_request" + + stringToSign: -> + "AWS4-HMAC-SHA256" + "\n" + + @timeStampISO8601() + "\n" + + @scope() + "\n" + + await crypto.hex await crypto.SHA256 @canonicalRequest() + + scope: -> + @timeStampYYYYMMDD() + "/" + + @region + "/" + @service + "/aws4_request" + + canonicalRequest: -> + @method + "\n" + + @canonicalURI() + "\n" + + @canonicalQueryString() + "\n" + + @canonicalHeaders() + "\n" + + @signedHeaders() + "\n" + + @headerMap['x-amz-content-sha256'] + + canonicalURI: -> + # new URL already handles URI encoding for the pathname. No need to repeat here. + @url.pathname + + canonicalQueryString: -> + return "" if not @qsMap + [...Object.entries @qsMap].sort() + .map (pair) -> + pair.map (x) -> encodeURIComponent x + .join "=" + .join "&" + + canonicalHeaders: -> + ([...Object.entries @headerMap].sort() + .map (pair) -> + pair.join ':' + .join "\n") + "\n" # There's a trailing "\n" + signedHeaders: -> + [...Object.entries @headerMap].sort() + .map (pair) -> pair[0] + .join ";" + + pad: (num) -> + if num < 10 + "0" + num + else + num + + timeStampISO8601: -> + @timeStampYYYYMMDD() + "T" + + @pad(@date.getUTCHours()) + + @pad(@date.getUTCMinutes()) + + @pad(@date.getUTCSeconds()) + "Z" + + timeStampYYYYMMDD: -> + "#{@date.getUTCFullYear()}" + + "#{@pad(@date.getUTCMonth() + 1)}" + + "#{@pad(@date.getUTCDate())}" + +export default AwsAuth diff --git a/src/aws/s3.coffee b/src/aws/s3.coffee new file mode 100644 index 0000000..e4e1443 --- /dev/null +++ b/src/aws/s3.coffee @@ -0,0 +1,88 @@ +import AwsAuth from "./auth" +import parser from "fast-xml-parser" + +class S3 + constructor: (@conf) -> + @baseURL = @conf.s3.endpoint + "/" + @conf.s3.bucket + "/" + + # Wrapper of Fetch API with automatic AWS signature + # Note that query string in url is not supported + # options.method must be specified + request: (url, qsMap, options) -> + # We only support unsigned payload for now + options.headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD' + + # Sign the request + auth = new AwsAuth url, @conf.aws + .setMethod options.method + .setQueryStringMap qsMap + .setHeaderMap options.headers + .setService "s3" + + # Write needed authorization headers to header object + await auth.authorizedHeader options.headers + fetch url + "?" + auth.canonicalQueryString(), options + + # Used for some requests to convert params to snake-cased x-amz-* + camelToSnake: (str) -> + str.replace /([A-Z])/g, "-$1" + .toLowerCase()[1..] + + makeHeaders: (params) -> Object.fromEntries do => + Object.entries params + .map ([key, val]) => + key = @camelToSnake key + if not (key == "expires" or key.startsWith "content-" or key.startsWith "cache-") + key = 'x-amz-' + key + [key, val] + + # See AWS docs for params + # params are passed in query string + # Paging not implemented yet; Need to + listObjects: (params) -> + # Send request using params as query string + resp = await @request @baseURL, params, + method: "GET" + headers: + 'x-amz-request-payer': 'requester' + txt = await resp.text() + console.log txt + if not resp.ok + # no error handling yet + throw txt + result = parser.parse txt, + arrayMode: true + console.log result + if not result.ListBucketResult + return null + result.ListBucketResult[0] + + # params are in CamelCase, but converted to x-amz-* headers automatically + # Content* headers are exempt from the x-amz-* prefix, as well as Expires + # data can be a buffer or a readable stream + putObject: (key, data, params) -> + # Convert camel-cased params to snake-cased headers + headers = @makeHeaders params + + # Send request + resp = await @request @baseURL + key, null, + method: 'PUT' + headers: headers + body: data + + txt = await resp.text() + console.log txt + if not resp.ok + # no error handling yet + throw resp.status + else + txt + + # params are processed similar to putObject + # returns the full response object (because content-range may be needed) + getObject: (key, params) -> + @request @baseURL + key, null, + method: 'GET' + headers: @makeHeaders params + +export default S3 \ No newline at end of file diff --git a/src/crypto.coffee b/src/crypto.coffee new file mode 100644 index 0000000..83a8c82 --- /dev/null +++ b/src/crypto.coffee @@ -0,0 +1,32 @@ +utf8Bytes = (str) -> + new TextEncoder 'utf-8' + .encode str + +fromUtf8Bytes = (bytes) -> + new TextDecoder 'utf-8' + .decode bytes + +hex = (buf) -> (Array::map.call new Uint8Array(buf), + (x) => ('00' + x.toString 16).slice(-2)).join '' + +fromHex = (str) -> + new Uint8Array str.match(/.{1,2}/g).map (byte) => parseInt byte, 16 + +HMAC_SHA256_KEY = (buf) -> + crypto.subtle.importKey 'raw', buf, + { name: 'HMAC', hash: 'SHA-256' }, true, [ 'sign' ] + +HMAC_SHA256 = (key, str) -> + cryptoKey = await HMAC_SHA256_KEY key + buf = utf8Bytes str + await crypto.subtle.sign "HMAC", cryptoKey, buf + +SHA256 = (str) -> + crypto.subtle.digest "SHA-256", utf8Bytes str + +export { + utf8Bytes, + hex, + HMAC_SHA256, + SHA256, +}