1var aws4 = exports, 2 url = require('url'), 3 querystring = require('querystring'), 4 crypto = require('crypto'), 5 lru = require('./lru'), 6 credentialsCache = lru(1000) 7 8// http://docs.amazonwebservices.com/general/latest/gr/signature-version-4.html 9 10function hmac(key, string, encoding) { 11 return crypto.createHmac('sha256', key).update(string, 'utf8').digest(encoding) 12} 13 14function hash(string, encoding) { 15 return crypto.createHash('sha256').update(string, 'utf8').digest(encoding) 16} 17 18// This function assumes the string has already been percent encoded 19function encodeRfc3986(urlEncodedString) { 20 return urlEncodedString.replace(/[!'()*]/g, function(c) { 21 return '%' + c.charCodeAt(0).toString(16).toUpperCase() 22 }) 23} 24 25// request: { path | body, [host], [method], [headers], [service], [region] } 26// credentials: { accessKeyId, secretAccessKey, [sessionToken] } 27function RequestSigner(request, credentials) { 28 29 if (typeof request === 'string') request = url.parse(request) 30 31 var headers = request.headers = (request.headers || {}), 32 hostParts = this.matchHost(request.hostname || request.host || headers.Host || headers.host) 33 34 this.request = request 35 this.credentials = credentials || this.defaultCredentials() 36 37 this.service = request.service || hostParts[0] || '' 38 this.region = request.region || hostParts[1] || 'us-east-1' 39 40 // SES uses a different domain from the service name 41 if (this.service === 'email') this.service = 'ses' 42 43 if (!request.method && request.body) 44 request.method = 'POST' 45 46 if (!headers.Host && !headers.host) { 47 headers.Host = request.hostname || request.host || this.createHost() 48 49 // If a port is specified explicitly, use it as is 50 if (request.port) 51 headers.Host += ':' + request.port 52 } 53 if (!request.hostname && !request.host) 54 request.hostname = headers.Host || headers.host 55 56 this.isCodeCommitGit = this.service === 'codecommit' && request.method === 'GIT' 57} 58 59RequestSigner.prototype.matchHost = function(host) { 60 var match = (host || '').match(/([^\.]+)\.(?:([^\.]*)\.)?amazonaws\.com(\.cn)?$/) 61 var hostParts = (match || []).slice(1, 3) 62 63 // ES's hostParts are sometimes the other way round, if the value that is expected 64 // to be region equals ‘es’ switch them back 65 // e.g. search-cluster-name-aaaa00aaaa0aaa0aaaaaaa0aaa.us-east-1.es.amazonaws.com 66 if (hostParts[1] === 'es') 67 hostParts = hostParts.reverse() 68 69 return hostParts 70} 71 72// http://docs.aws.amazon.com/general/latest/gr/rande.html 73RequestSigner.prototype.isSingleRegion = function() { 74 // Special case for S3 and SimpleDB in us-east-1 75 if (['s3', 'sdb'].indexOf(this.service) >= 0 && this.region === 'us-east-1') return true 76 77 return ['cloudfront', 'ls', 'route53', 'iam', 'importexport', 'sts'] 78 .indexOf(this.service) >= 0 79} 80 81RequestSigner.prototype.createHost = function() { 82 var region = this.isSingleRegion() ? '' : 83 (this.service === 's3' && this.region !== 'us-east-1' ? '-' : '.') + this.region, 84 service = this.service === 'ses' ? 'email' : this.service 85 return service + region + '.amazonaws.com' 86} 87 88RequestSigner.prototype.prepareRequest = function() { 89 this.parsePath() 90 91 var request = this.request, headers = request.headers, query 92 93 if (request.signQuery) { 94 95 this.parsedPath.query = query = this.parsedPath.query || {} 96 97 if (this.credentials.sessionToken) 98 query['X-Amz-Security-Token'] = this.credentials.sessionToken 99 100 if (this.service === 's3' && !query['X-Amz-Expires']) 101 query['X-Amz-Expires'] = 86400 102 103 if (query['X-Amz-Date']) 104 this.datetime = query['X-Amz-Date'] 105 else 106 query['X-Amz-Date'] = this.getDateTime() 107 108 query['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256' 109 query['X-Amz-Credential'] = this.credentials.accessKeyId + '/' + this.credentialString() 110 query['X-Amz-SignedHeaders'] = this.signedHeaders() 111 112 } else { 113 114 if (!request.doNotModifyHeaders && !this.isCodeCommitGit) { 115 if (request.body && !headers['Content-Type'] && !headers['content-type']) 116 headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8' 117 118 if (request.body && !headers['Content-Length'] && !headers['content-length']) 119 headers['Content-Length'] = Buffer.byteLength(request.body) 120 121 if (this.credentials.sessionToken && !headers['X-Amz-Security-Token'] && !headers['x-amz-security-token']) 122 headers['X-Amz-Security-Token'] = this.credentials.sessionToken 123 124 if (this.service === 's3' && !headers['X-Amz-Content-Sha256'] && !headers['x-amz-content-sha256']) 125 headers['X-Amz-Content-Sha256'] = hash(this.request.body || '', 'hex') 126 127 if (headers['X-Amz-Date'] || headers['x-amz-date']) 128 this.datetime = headers['X-Amz-Date'] || headers['x-amz-date'] 129 else 130 headers['X-Amz-Date'] = this.getDateTime() 131 } 132 133 delete headers.Authorization 134 delete headers.authorization 135 } 136} 137 138RequestSigner.prototype.sign = function() { 139 if (!this.parsedPath) this.prepareRequest() 140 141 if (this.request.signQuery) { 142 this.parsedPath.query['X-Amz-Signature'] = this.signature() 143 } else { 144 this.request.headers.Authorization = this.authHeader() 145 } 146 147 this.request.path = this.formatPath() 148 149 return this.request 150} 151 152RequestSigner.prototype.getDateTime = function() { 153 if (!this.datetime) { 154 var headers = this.request.headers, 155 date = new Date(headers.Date || headers.date || new Date) 156 157 this.datetime = date.toISOString().replace(/[:\-]|\.\d{3}/g, '') 158 159 // Remove the trailing 'Z' on the timestamp string for CodeCommit git access 160 if (this.isCodeCommitGit) this.datetime = this.datetime.slice(0, -1) 161 } 162 return this.datetime 163} 164 165RequestSigner.prototype.getDate = function() { 166 return this.getDateTime().substr(0, 8) 167} 168 169RequestSigner.prototype.authHeader = function() { 170 return [ 171 'AWS4-HMAC-SHA256 Credential=' + this.credentials.accessKeyId + '/' + this.credentialString(), 172 'SignedHeaders=' + this.signedHeaders(), 173 'Signature=' + this.signature(), 174 ].join(', ') 175} 176 177RequestSigner.prototype.signature = function() { 178 var date = this.getDate(), 179 cacheKey = [this.credentials.secretAccessKey, date, this.region, this.service].join(), 180 kDate, kRegion, kService, kCredentials = credentialsCache.get(cacheKey) 181 if (!kCredentials) { 182 kDate = hmac('AWS4' + this.credentials.secretAccessKey, date) 183 kRegion = hmac(kDate, this.region) 184 kService = hmac(kRegion, this.service) 185 kCredentials = hmac(kService, 'aws4_request') 186 credentialsCache.set(cacheKey, kCredentials) 187 } 188 return hmac(kCredentials, this.stringToSign(), 'hex') 189} 190 191RequestSigner.prototype.stringToSign = function() { 192 return [ 193 'AWS4-HMAC-SHA256', 194 this.getDateTime(), 195 this.credentialString(), 196 hash(this.canonicalString(), 'hex'), 197 ].join('\n') 198} 199 200RequestSigner.prototype.canonicalString = function() { 201 if (!this.parsedPath) this.prepareRequest() 202 203 var pathStr = this.parsedPath.path, 204 query = this.parsedPath.query, 205 headers = this.request.headers, 206 queryStr = '', 207 normalizePath = this.service !== 's3', 208 decodePath = this.service === 's3' || this.request.doNotEncodePath, 209 decodeSlashesInPath = this.service === 's3', 210 firstValOnly = this.service === 's3', 211 bodyHash 212 213 if (this.service === 's3' && this.request.signQuery) { 214 bodyHash = 'UNSIGNED-PAYLOAD' 215 } else if (this.isCodeCommitGit) { 216 bodyHash = '' 217 } else { 218 bodyHash = headers['X-Amz-Content-Sha256'] || headers['x-amz-content-sha256'] || 219 hash(this.request.body || '', 'hex') 220 } 221 222 if (query) { 223 queryStr = encodeRfc3986(querystring.stringify(Object.keys(query).sort().reduce(function(obj, key) { 224 if (!key) return obj 225 obj[key] = !Array.isArray(query[key]) ? query[key] : 226 (firstValOnly ? query[key][0] : query[key].slice().sort()) 227 return obj 228 }, {}))) 229 } 230 if (pathStr !== '/') { 231 if (normalizePath) pathStr = pathStr.replace(/\/{2,}/g, '/') 232 pathStr = pathStr.split('/').reduce(function(path, piece) { 233 if (normalizePath && piece === '..') { 234 path.pop() 235 } else if (!normalizePath || piece !== '.') { 236 if (decodePath) piece = decodeURIComponent(piece) 237 path.push(encodeRfc3986(encodeURIComponent(piece))) 238 } 239 return path 240 }, []).join('/') 241 if (pathStr[0] !== '/') pathStr = '/' + pathStr 242 if (decodeSlashesInPath) pathStr = pathStr.replace(/%2F/g, '/') 243 } 244 245 return [ 246 this.request.method || 'GET', 247 pathStr, 248 queryStr, 249 this.canonicalHeaders() + '\n', 250 this.signedHeaders(), 251 bodyHash, 252 ].join('\n') 253} 254 255RequestSigner.prototype.canonicalHeaders = function() { 256 var headers = this.request.headers 257 function trimAll(header) { 258 return header.toString().trim().replace(/\s+/g, ' ') 259 } 260 return Object.keys(headers) 261 .sort(function(a, b) { return a.toLowerCase() < b.toLowerCase() ? -1 : 1 }) 262 .map(function(key) { return key.toLowerCase() + ':' + trimAll(headers[key]) }) 263 .join('\n') 264} 265 266RequestSigner.prototype.signedHeaders = function() { 267 return Object.keys(this.request.headers) 268 .map(function(key) { return key.toLowerCase() }) 269 .sort() 270 .join(';') 271} 272 273RequestSigner.prototype.credentialString = function() { 274 return [ 275 this.getDate(), 276 this.region, 277 this.service, 278 'aws4_request', 279 ].join('/') 280} 281 282RequestSigner.prototype.defaultCredentials = function() { 283 var env = process.env 284 return { 285 accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, 286 secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, 287 sessionToken: env.AWS_SESSION_TOKEN, 288 } 289} 290 291RequestSigner.prototype.parsePath = function() { 292 var path = this.request.path || '/', 293 queryIx = path.indexOf('?'), 294 query = null 295 296 if (queryIx >= 0) { 297 query = querystring.parse(path.slice(queryIx + 1)) 298 path = path.slice(0, queryIx) 299 } 300 301 // S3 doesn't always encode characters > 127 correctly and 302 // all services don't encode characters > 255 correctly 303 // So if there are non-reserved chars (and it's not already all % encoded), just encode them all 304 if (/[^0-9A-Za-z!'()*\-._~%/]/.test(path)) { 305 path = path.split('/').map(function(piece) { 306 return encodeURIComponent(decodeURIComponent(piece)) 307 }).join('/') 308 } 309 310 this.parsedPath = { 311 path: path, 312 query: query, 313 } 314} 315 316RequestSigner.prototype.formatPath = function() { 317 var path = this.parsedPath.path, 318 query = this.parsedPath.query 319 320 if (!query) return path 321 322 // Services don't support empty query string keys 323 if (query[''] != null) delete query[''] 324 325 return path + '?' + encodeRfc3986(querystring.stringify(query)) 326} 327 328aws4.RequestSigner = RequestSigner 329 330aws4.sign = function(request, credentials) { 331 return new RequestSigner(request, credentials).sign() 332} 333