|
| 1 | +/* |
| 2 | + * Copyright 2023 F5, Inc. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +import utils from "./utils.js"; |
| 18 | + |
| 19 | +const mod_hmac = require('crypto'); |
| 20 | + |
| 21 | +/** |
| 22 | + * Constant checksum for an empty HTTP body. |
| 23 | + * @type {string} |
| 24 | + */ |
| 25 | +const EMPTY_PAYLOAD_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; |
| 26 | + |
| 27 | +/** |
| 28 | + * Constant defining the headers being signed. |
| 29 | + * @type {string} |
| 30 | + */ |
| 31 | +const DEFAULT_SIGNED_HEADERS = 'host;x-amz-content-sha256;x-amz-date'; |
| 32 | + |
| 33 | + |
| 34 | +/** |
| 35 | + * Create HTTP Authorization header for authenticating with an AWS compatible |
| 36 | + * v4 API. |
| 37 | + * |
| 38 | + * @param r {Request} HTTP request object |
| 39 | + * @param timestamp {Date} timestamp associated with request (must fall within a skew) |
| 40 | + * @param region {string} API region associated with request |
| 41 | + * @param service {string} service code (for example, s3, lambda) |
| 42 | + * @param uri {string} The URI-encoded version of the absolute path component URL to create a canonical request |
| 43 | + * @param queryParams {string} The URL-encoded query string parameters to create a canonical request |
| 44 | + * @param host {string} HTTP host header value |
| 45 | + * @param credentials {object} Credential object with AWS credentials in it (AccessKeyId, SecretAccessKey, SessionToken) |
| 46 | + * @returns {string} HTTP Authorization header value |
| 47 | + */ |
| 48 | +function signatureV4(r, timestamp, region, service, uri, queryParams, host, credentials) { |
| 49 | + const eightDigitDate = utils.getEightDigitDate(timestamp); |
| 50 | + const amzDatetime = utils.getAmzDatetime(timestamp, eightDigitDate); |
| 51 | + const canonicalRequest = _buildCanonicalRequest( |
| 52 | + r.method, uri, queryParams, host, amzDatetime, credentials.sessionToken); |
| 53 | + const signature = _buildSignatureV4(r, amzDatetime, eightDigitDate, |
| 54 | + credentials, region, service, canonicalRequest); |
| 55 | + const authHeader = 'AWS4-HMAC-SHA256 Credential=' |
| 56 | + .concat(credentials.accessKeyId, '/', eightDigitDate, '/', region, '/', service, '/aws4_request,', |
| 57 | + 'SignedHeaders=', _signedHeaders(credentials.sessionToken), ',Signature=', signature); |
| 58 | + |
| 59 | + utils.debug_log(r, 'AWS v4 Auth header: [' + authHeader + ']'); |
| 60 | + |
| 61 | + return authHeader; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Creates a canonical request that will later be signed |
| 66 | + * |
| 67 | + * @see {@link https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html | Creating a Canonical Request} |
| 68 | + * @param method {string} HTTP method |
| 69 | + * @param uri {string} URI associated with request |
| 70 | + * @param queryParams {string} query parameters associated with request |
| 71 | + * @param host {string} HTTP Host header value |
| 72 | + * @param amzDatetime {string} ISO8601 timestamp string to sign request with |
| 73 | + * @returns {string} string with concatenated request parameters |
| 74 | + * @private |
| 75 | + */ |
| 76 | +function _buildCanonicalRequest(method, uri, queryParams, host, amzDatetime, sessionToken) { |
| 77 | + let canonicalHeaders = 'host:' + host + '\n' + |
| 78 | + 'x-amz-content-sha256:' + EMPTY_PAYLOAD_HASH + '\n' + |
| 79 | + 'x-amz-date:' + amzDatetime + '\n'; |
| 80 | + |
| 81 | + if (sessionToken) { |
| 82 | + canonicalHeaders += 'x-amz-security-token:' + sessionToken + '\n' |
| 83 | + } |
| 84 | + |
| 85 | + let canonicalRequest = method + '\n'; |
| 86 | + canonicalRequest += uri + '\n'; |
| 87 | + canonicalRequest += queryParams + '\n'; |
| 88 | + canonicalRequest += canonicalHeaders + '\n'; |
| 89 | + canonicalRequest += _signedHeaders(sessionToken) + '\n'; |
| 90 | + canonicalRequest += EMPTY_PAYLOAD_HASH; |
| 91 | + |
| 92 | + return canonicalRequest; |
| 93 | +} |
| 94 | + |
| 95 | +/** |
| 96 | + * Creates a signature for use authenticating against an AWS compatible API. |
| 97 | + * |
| 98 | + * @see {@link https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html | AWS V4 Signing Process} |
| 99 | + * @param r {Request} HTTP request object |
| 100 | + * @param amzDatetime {string} ISO8601 timestamp string to sign request with |
| 101 | + * @param eightDigitDate {string} date in the form of 'YYYYMMDD' |
| 102 | + * @param creds {object} AWS credentials |
| 103 | + * @param region {string} API region associated with request |
| 104 | + * @param service {string} service code (for example, s3, lambda) |
| 105 | + * @param canonicalRequest {string} string with concatenated request parameters |
| 106 | + * @returns {string} hex encoded hash of signature HMAC value |
| 107 | + * @private |
| 108 | + */ |
| 109 | +function _buildSignatureV4( |
| 110 | + r, amzDatetime, eightDigitDate, creds, region, service, canonicalRequest) { |
| 111 | + utils.debug_log(r, 'AWS v4 Auth Canonical Request: [' + canonicalRequest + ']'); |
| 112 | + |
| 113 | + const canonicalRequestHash = mod_hmac.createHash('sha256') |
| 114 | + .update(canonicalRequest) |
| 115 | + .digest('hex'); |
| 116 | + |
| 117 | + utils.debug_log(r, 'AWS v4 Auth Canonical Request Hash: [' + canonicalRequestHash + ']'); |
| 118 | + |
| 119 | + const stringToSign = _buildStringToSign( |
| 120 | + amzDatetime, eightDigitDate, region, service, canonicalRequestHash); |
| 121 | + |
| 122 | + utils.debug_log(r, 'AWS v4 Auth Signing String: [' + stringToSign + ']'); |
| 123 | + |
| 124 | + let kSigningHash; |
| 125 | + |
| 126 | + /* If we have a keyval zone and key defined for caching the signing key hash, |
| 127 | + * then signing key caching will be enabled. By caching signing keys we can |
| 128 | + * accelerate the signing process because we will have four less HMAC |
| 129 | + * operations that have to be performed per incoming request. The signing |
| 130 | + * key expires every day, so our cache key can persist for 24 hours safely. |
| 131 | + */ |
| 132 | + if ("variables" in r && r.variables.cache_signing_key_enabled == 1) { |
| 133 | + // cached value is in the format: [eightDigitDate]:[signingKeyHash] |
| 134 | + const cached = "signing_key_hash" in r.variables ? r.variables.signing_key_hash : ""; |
| 135 | + const fields = _splitCachedValues(cached); |
| 136 | + const cachedEightDigitDate = fields[0]; |
| 137 | + const cacheIsValid = fields.length === 2 && eightDigitDate === cachedEightDigitDate; |
| 138 | + |
| 139 | + // If true, use cached value |
| 140 | + if (cacheIsValid) { |
| 141 | + utils.debug_log(r, 'AWS v4 Using cached Signing Key Hash'); |
| 142 | + /* We are forced to JSON encode the string returned from the HMAC |
| 143 | + * operation because it is in a very specific format that include |
| 144 | + * binary data and in order to preserve that data when persisting |
| 145 | + * we encode it as JSON. By doing so we can gracefully decode it |
| 146 | + * when reading from the cache. */ |
| 147 | + kSigningHash = Buffer.from(JSON.parse(fields[1])); |
| 148 | + // Otherwise, generate a new signing key hash and store it in the cache |
| 149 | + } else { |
| 150 | + kSigningHash = _buildSigningKeyHash(creds.secretAccessKey, eightDigitDate, region, service); |
| 151 | + utils.debug_log(r, 'Writing key: ' + eightDigitDate + ':' + kSigningHash.toString('hex')); |
| 152 | + r.variables.signing_key_hash = eightDigitDate + ':' + JSON.stringify(kSigningHash); |
| 153 | + } |
| 154 | + // Otherwise, don't use caching at all (like when we are using NGINX OSS) |
| 155 | + } else { |
| 156 | + kSigningHash = _buildSigningKeyHash(creds.secretAccessKey, eightDigitDate, region, service); |
| 157 | + } |
| 158 | + |
| 159 | + utils.debug_log(r, 'AWS v4 Signing Key Hash: [' + kSigningHash.toString('hex') + ']'); |
| 160 | + |
| 161 | + const signature = mod_hmac.createHmac('sha256', kSigningHash) |
| 162 | + .update(stringToSign).digest('hex'); |
| 163 | + |
| 164 | + utils.debug_log(r, 'AWS v4 Authorization Header: [' + signature + ']'); |
| 165 | + |
| 166 | + return signature; |
| 167 | +} |
| 168 | + |
| 169 | +/** |
| 170 | + * Creates a string to sign by concatenating together multiple parameters required |
| 171 | + * by the signatures algorithm. |
| 172 | + * |
| 173 | + * @see {@link https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html | String to Sign} |
| 174 | + * @param amzDatetime {string} ISO8601 timestamp string to sign request with |
| 175 | + * @param eightDigitDate {string} date in the form of 'YYYYMMDD' |
| 176 | + * @param region {string} region associated with server API |
| 177 | + * @param service {string} service code (for example, s3, lambda) |
| 178 | + * @param canonicalRequestHash {string} hex encoded hash of canonical request string |
| 179 | + * @returns {string} a concatenated string of the passed parameters formatted for signatures |
| 180 | + * @private |
| 181 | + */ |
| 182 | +function _buildStringToSign(amzDatetime, eightDigitDate, region, service, canonicalRequestHash) { |
| 183 | + return 'AWS4-HMAC-SHA256\n' + |
| 184 | + amzDatetime + '\n' + |
| 185 | + eightDigitDate + '/' + region + '/' + service + '/aws4_request\n' + |
| 186 | + canonicalRequestHash; |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Creates a string containing the headers that need to be signed as part of v4 |
| 191 | + * signature authentication. |
| 192 | + * |
| 193 | + * @param sessionToken {string|undefined} AWS session token if present |
| 194 | + * @returns {string} semicolon delimited string of the headers needed for signing |
| 195 | + * @private |
| 196 | + */ |
| 197 | +function _signedHeaders(sessionToken) { |
| 198 | + let headers = DEFAULT_SIGNED_HEADERS; |
| 199 | + if (sessionToken) { |
| 200 | + headers += ';x-amz-security-token'; |
| 201 | + } |
| 202 | + return headers; |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Creates a signing key HMAC. This value is used to sign the request made to |
| 207 | + * the API. |
| 208 | + * |
| 209 | + * @param kSecret {string} secret access key |
| 210 | + * @param eightDigitDate {string} date in the form of 'YYYYMMDD' |
| 211 | + * @param region {string} region associated with server API |
| 212 | + * @param service {string} name of service that request is for e.g. s3, lambda |
| 213 | + * @returns {ArrayBuffer} signing HMAC |
| 214 | + * @private |
| 215 | + */ |
| 216 | +function _buildSigningKeyHash(kSecret, eightDigitDate, region, service) { |
| 217 | + const kDate = mod_hmac.createHmac('sha256', 'AWS4'.concat(kSecret)) |
| 218 | + .update(eightDigitDate).digest(); |
| 219 | + const kRegion = mod_hmac.createHmac('sha256', kDate) |
| 220 | + .update(region).digest(); |
| 221 | + const kService = mod_hmac.createHmac('sha256', kRegion) |
| 222 | + .update(service).digest(); |
| 223 | + const kSigning = mod_hmac.createHmac('sha256', kService) |
| 224 | + .update('aws4_request').digest(); |
| 225 | + |
| 226 | + return kSigning; |
| 227 | +} |
| 228 | + |
| 229 | +/** |
| 230 | + * Splits the cached values into an array with two elements or returns an |
| 231 | + * empty array if the input string is invalid. The first element contains |
| 232 | + * the eight digit date string and the second element contains a JSON string |
| 233 | + * of the kSigningHash. |
| 234 | + * |
| 235 | + * @param cached input string to parse |
| 236 | + * @returns {string[]|*[]} array containing eight digit date and kSigningHash or empty |
| 237 | + * @private |
| 238 | + */ |
| 239 | +function _splitCachedValues(cached) { |
| 240 | + const matchedPos = cached.indexOf(':', 0); |
| 241 | + // Do a sanity check on the position returned, if it isn't sane, return |
| 242 | + // an empty array and let the caller logic process it. |
| 243 | + if (matchedPos < 0 || matchedPos + 1 > cached.length) { |
| 244 | + return [] |
| 245 | + } |
| 246 | + |
| 247 | + const eightDigitDate = cached.substring(0, matchedPos); |
| 248 | + const kSigningHash = cached.substring(matchedPos + 1); |
| 249 | + |
| 250 | + return [eightDigitDate, kSigningHash] |
| 251 | +} |
| 252 | + |
| 253 | + |
| 254 | +export default { |
| 255 | + signatureV4, |
| 256 | + // These functions do not need to be exposed, but they are exposed so that |
| 257 | + // unit tests can run against them. |
| 258 | + _buildCanonicalRequest, |
| 259 | + _buildSignatureV4, |
| 260 | + _buildSigningKeyHash, |
| 261 | + _splitCachedValues |
| 262 | +} |
0 commit comments