View Javadoc
1   /*
2    * Copyright (C) 2015, Matthias Sohn <matthias.sohn@sap.com>
3    * Copyright (C) 2015, Sasa Zivkov <sasa.zivkov@sap.com>
4    * and other copyright owners as documented in the project's IP log.
5    *
6    * This program and the accompanying materials are made available
7    * under the terms of the Eclipse Distribution License v1.0 which
8    * accompanies this distribution, is reproduced below, and is
9    * available at http://www.eclipse.org/org/documents/edl-v10.php
10   *
11   * All rights reserved.
12   *
13   * Redistribution and use in source and binary forms, with or
14   * without modification, are permitted provided that the following
15   * conditions are met:
16   *
17   * - Redistributions of source code must retain the above copyright
18   *   notice, this list of conditions and the following disclaimer.
19   *
20   * - Redistributions in binary form must reproduce the above
21   *   copyright notice, this list of conditions and the following
22   *   disclaimer in the documentation and/or other materials provided
23   *   with the distribution.
24   *
25   * - Neither the name of the Eclipse Foundation, Inc. nor the
26   *   names of its contributors may be used to endorse or promote
27   *   products derived from this software without specific prior
28   *   written permission.
29   *
30   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
31   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
32   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
33   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
34   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
35   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
36   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
37   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
38   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
39   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
40   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
41   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
42   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
43   */
44  package org.eclipse.jgit.lfs.server.s3;
45  
46  import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
47  
48  import java.io.UnsupportedEncodingException;
49  import java.net.URL;
50  import java.net.URLEncoder;
51  import java.nio.charset.StandardCharsets;
52  import java.security.MessageDigest;
53  import java.text.MessageFormat;
54  import java.text.SimpleDateFormat;
55  import java.util.ArrayList;
56  import java.util.Collections;
57  import java.util.Date;
58  import java.util.Iterator;
59  import java.util.List;
60  import java.util.Locale;
61  import java.util.Map;
62  import java.util.SimpleTimeZone;
63  import java.util.SortedMap;
64  import java.util.TreeMap;
65  
66  import javax.crypto.Mac;
67  import javax.crypto.spec.SecretKeySpec;
68  
69  import org.eclipse.jgit.lfs.lib.Constants;
70  import org.eclipse.jgit.lfs.server.internal.LfsServerText;
71  
72  /**
73   * Signing support for Amazon AWS signing V4
74   * <p>
75   * See
76   * http://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
77   */
78  class SignerV4 {
79  	static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; //$NON-NLS-1$
80  
81  	private static final String ALGORITHM = "HMAC-SHA256"; //$NON-NLS-1$
82  	private static final String DATE_STRING_FORMAT = "yyyyMMdd"; //$NON-NLS-1$
83  	private static final String HEX = "0123456789abcdef"; //$NON-NLS-1$
84  	private static final String HMACSHA256 = "HmacSHA256"; //$NON-NLS-1$
85  	private static final String ISO8601_BASIC_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; //$NON-NLS-1$
86  	private static final String S3 = "s3"; //$NON-NLS-1$
87  	private static final String SCHEME = "AWS4"; //$NON-NLS-1$
88  	private static final String TERMINATOR = "aws4_request"; //$NON-NLS-1$
89  	private static final String UTC = "UTC"; //$NON-NLS-1$
90  	private static final String X_AMZ_ALGORITHM = "X-Amz-Algorithm"; //$NON-NLS-1$
91  	private static final String X_AMZ_CREDENTIAL = "X-Amz-Credential"; //$NON-NLS-1$
92  	private static final String X_AMZ_DATE = "X-Amz-Date"; //$NON-NLS-1$
93  	private static final String X_AMZ_SIGNATURE = "X-Amz-Signature"; //$NON-NLS-1$
94  	private static final String X_AMZ_SIGNED_HEADERS = "X-Amz-SignedHeaders"; //$NON-NLS-1$
95  
96  	static final String X_AMZ_CONTENT_SHA256 = "x-amz-content-sha256"; //$NON-NLS-1$
97  	static final String X_AMZ_EXPIRES = "X-Amz-Expires"; //$NON-NLS-1$
98  	static final String X_AMZ_STORAGE_CLASS = "x-amz-storage-class"; //$NON-NLS-1$
99  
100 	/**
101 	 * Create an AWSV4 authorization for a request, suitable for embedding in
102 	 * query parameters.
103 	 *
104 	 * @param bucketConfig
105 	 *            configuration of S3 storage bucket this request should be
106 	 *            signed for
107 	 * @param url
108 	 *            HTTP request URL
109 	 * @param httpMethod
110 	 *            HTTP method
111 	 * @param headers
112 	 *            The HTTP request headers; 'Host' and 'X-Amz-Date' will be
113 	 *            added to this set.
114 	 * @param queryParameters
115 	 *            Any query parameters that will be added to the endpoint. The
116 	 *            parameters should be specified in canonical format.
117 	 * @param bodyHash
118 	 *            Pre-computed SHA256 hash of the request body content; this
119 	 *            value should also be set as the header 'X-Amz-Content-SHA256'
120 	 *            for non-streaming uploads.
121 	 * @return The computed authorization string for the request. This value
122 	 *         needs to be set as the header 'Authorization' on the subsequent
123 	 *         HTTP request.
124 	 */
125 	static String createAuthorizationQuery(S3Config bucketConfig, URL url,
126 			String httpMethod, Map<String, String> headers,
127 			Map<String, String> queryParameters, String bodyHash) {
128 		addHostHeader(url, headers);
129 
130 		queryParameters.put(X_AMZ_ALGORITHM, SCHEME + "-" + ALGORITHM); //$NON-NLS-1$
131 
132 		Date now = new Date();
133 		String dateStamp = dateStamp(now);
134 		String scope = scope(bucketConfig.getRegion(), dateStamp);
135 		queryParameters.put(X_AMZ_CREDENTIAL,
136 				bucketConfig.getAccessKey() + "/" + scope); //$NON-NLS-1$
137 
138 		String dateTimeStampISO8601 = dateTimeStampISO8601(now);
139 		queryParameters.put(X_AMZ_DATE, dateTimeStampISO8601);
140 
141 		String canonicalizedHeaderNames = canonicalizeHeaderNames(headers);
142 		queryParameters.put(X_AMZ_SIGNED_HEADERS, canonicalizedHeaderNames);
143 
144 		String canonicalizedQueryParameters = canonicalizeQueryString(
145 				queryParameters);
146 		String canonicalizedHeaders = canonicalizeHeaderString(headers);
147 		String canonicalRequest = canonicalRequest(url, httpMethod,
148 				canonicalizedQueryParameters, canonicalizedHeaderNames,
149 				canonicalizedHeaders, bodyHash);
150 		byte[] signature = createSignature(bucketConfig, dateTimeStampISO8601,
151 				dateStamp, scope, canonicalRequest);
152 		queryParameters.put(X_AMZ_SIGNATURE, toHex(signature));
153 
154 		return formatAuthorizationQuery(queryParameters);
155 	}
156 
157 	private static String formatAuthorizationQuery(
158 			Map<String, String> queryParameters) {
159 		StringBuilder s = new StringBuilder();
160 		for (String key : queryParameters.keySet()) {
161 			appendQuery(s, key, queryParameters.get(key));
162 		}
163 		return s.toString();
164 	}
165 
166 	private static void appendQuery(StringBuilder s, String key,
167 			String value) {
168 		if (s.length() != 0) {
169 			s.append("&"); //$NON-NLS-1$
170 		}
171 		s.append(key).append("=").append(value); //$NON-NLS-1$
172 	}
173 
174 	/**
175 	 * Sign headers for given bucket, url and HTTP method and add signature in
176 	 * Authorization header.
177 	 *
178 	 * @param bucketConfig
179 	 *            configuration of S3 storage bucket this request should be
180 	 *            signed for
181 	 * @param url
182 	 *            HTTP request URL
183 	 * @param httpMethod
184 	 *            HTTP method
185 	 * @param headers
186 	 *            HTTP headers to sign
187 	 * @param bodyHash
188 	 *            Pre-computed SHA256 hash of the request body content; this
189 	 *            value should also be set as the header 'X-Amz-Content-SHA256'
190 	 *            for non-streaming uploads.
191 	 * @return HTTP headers signd by an Authorization header added to the
192 	 *         headers
193 	 */
194 	static Map<String, String> createHeaderAuthorization(
195 			S3Config bucketConfig, URL url, String httpMethod,
196 			Map<String, String> headers, String bodyHash) {
197 		addHostHeader(url, headers);
198 
199 		Date now = new Date();
200 		String dateTimeStamp = dateTimeStampISO8601(now);
201 		headers.put(X_AMZ_DATE, dateTimeStamp);
202 
203 		String canonicalizedHeaderNames = canonicalizeHeaderNames(headers);
204 		String canonicalizedHeaders = canonicalizeHeaderString(headers);
205 		String canonicalRequest = canonicalRequest(url, httpMethod, "", //$NON-NLS-1$
206 				canonicalizedHeaderNames, canonicalizedHeaders, bodyHash);
207 		String dateStamp = dateStamp(now);
208 		String scope = scope(bucketConfig.getRegion(), dateStamp);
209 
210 		byte[] signature = createSignature(bucketConfig, dateTimeStamp,
211 				dateStamp, scope, canonicalRequest);
212 
213 		headers.put(HDR_AUTHORIZATION, formatAuthorizationHeader(bucketConfig,
214 				canonicalizedHeaderNames, scope, signature)); // $NON-NLS-1$
215 
216 		return headers;
217 	}
218 
219 	private static String formatAuthorizationHeader(
220 			S3Config bucketConfig, String canonicalizedHeaderNames,
221 			String scope, byte[] signature) {
222 		StringBuilder s = new StringBuilder();
223 		s.append(SCHEME).append("-").append(ALGORITHM).append(" "); //$NON-NLS-1$ //$NON-NLS-2$
224 		s.append("Credential=").append(bucketConfig.getAccessKey()).append("/") //$NON-NLS-1$//$NON-NLS-2$
225 				.append(scope).append(","); //$NON-NLS-1$
226 		s.append("SignedHeaders=").append(canonicalizedHeaderNames).append(","); //$NON-NLS-1$ //$NON-NLS-2$
227 		s.append("Signature=").append(toHex(signature)); //$NON-NLS-1$
228 		return s.toString();
229 	}
230 
231 	private static void addHostHeader(URL url,
232 			Map<String, String> headers) {
233 		StringBuilder hostHeader = new StringBuilder(url.getHost());
234 		int port = url.getPort();
235 		if (port > -1) {
236 			hostHeader.append(":").append(port); //$NON-NLS-1$
237 		}
238 		headers.put("Host", hostHeader.toString()); //$NON-NLS-1$
239 	}
240 
241 	private static String canonicalizeHeaderNames(
242 			Map<String, String> headers) {
243 		List<String> sortedHeaders = new ArrayList<>();
244 		sortedHeaders.addAll(headers.keySet());
245 		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
246 
247 		StringBuilder buffer = new StringBuilder();
248 		for (String header : sortedHeaders) {
249 			if (buffer.length() > 0)
250 				buffer.append(";"); //$NON-NLS-1$
251 			buffer.append(header.toLowerCase(Locale.ROOT));
252 		}
253 
254 		return buffer.toString();
255 	}
256 
257 	private static String canonicalizeHeaderString(
258 			Map<String, String> headers) {
259 		if (headers == null || headers.isEmpty()) {
260 			return ""; //$NON-NLS-1$
261 		}
262 
263 		List<String> sortedHeaders = new ArrayList<>();
264 		sortedHeaders.addAll(headers.keySet());
265 		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
266 
267 		StringBuilder buffer = new StringBuilder();
268 		for (String key : sortedHeaders) {
269 			buffer.append(
270 					key.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ") + ":" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
271 					+ headers.get(key).replaceAll("\\s+", " ")); //$NON-NLS-1$//$NON-NLS-2$
272 			buffer.append("\n"); //$NON-NLS-1$
273 		}
274 
275 		return buffer.toString();
276 	}
277 
278 	private static String dateStamp(Date now) {
279 		// TODO(ms) cache and reuse DateFormat instances
280 		SimpleDateFormat dateStampFormat = new SimpleDateFormat(
281 				DATE_STRING_FORMAT);
282 		dateStampFormat.setTimeZone(new SimpleTimeZone(0, UTC));
283 		String dateStamp = dateStampFormat.format(now);
284 		return dateStamp;
285 	}
286 
287 	private static String dateTimeStampISO8601(Date now) {
288 		// TODO(ms) cache and reuse DateFormat instances
289 		SimpleDateFormat dateTimeFormat = new SimpleDateFormat(
290 				ISO8601_BASIC_FORMAT);
291 		dateTimeFormat.setTimeZone(new SimpleTimeZone(0, UTC));
292 		String dateTimeStamp = dateTimeFormat.format(now);
293 		return dateTimeStamp;
294 	}
295 
296 	private static String scope(String region, String dateStamp) {
297 		String scope = String.format("%s/%s/%s/%s", dateStamp, region, S3, //$NON-NLS-1$
298 				TERMINATOR);
299 		return scope;
300 	}
301 
302 	private static String canonicalizeQueryString(
303 			Map<String, String> parameters) {
304 		if (parameters == null || parameters.isEmpty()) {
305 			return ""; //$NON-NLS-1$
306 		}
307 
308 		SortedMap<String, String> sorted = new TreeMap<>();
309 
310 		Iterator<Map.Entry<String, String>> pairs = parameters.entrySet()
311 				.iterator();
312 		while (pairs.hasNext()) {
313 			Map.Entry<String, String> pair = pairs.next();
314 			String key = pair.getKey();
315 			String value = pair.getValue();
316 			sorted.put(urlEncode(key, false), urlEncode(value, false));
317 		}
318 
319 		StringBuilder builder = new StringBuilder();
320 		pairs = sorted.entrySet().iterator();
321 		while (pairs.hasNext()) {
322 			Map.Entry<String, String> pair = pairs.next();
323 			builder.append(pair.getKey());
324 			builder.append("="); //$NON-NLS-1$
325 			builder.append(pair.getValue());
326 			if (pairs.hasNext()) {
327 				builder.append("&"); //$NON-NLS-1$
328 			}
329 		}
330 
331 		return builder.toString();
332 	}
333 
334 	private static String canonicalRequest(URL endpoint, String httpMethod,
335 			String queryParameters, String canonicalizedHeaderNames,
336 			String canonicalizedHeaders, String bodyHash) {
337 		return String.format("%s\n%s\n%s\n%s\n%s\n%s", //$NON-NLS-1$
338 				httpMethod, canonicalizeResourcePath(endpoint),
339 				queryParameters, canonicalizedHeaders, canonicalizedHeaderNames,
340 				bodyHash);
341 	}
342 
343 	private static String canonicalizeResourcePath(URL endpoint) {
344 		if (endpoint == null) {
345 			return "/"; //$NON-NLS-1$
346 		}
347 		String path = endpoint.getPath();
348 		if (path == null || path.isEmpty()) {
349 			return "/"; //$NON-NLS-1$
350 		}
351 
352 		String encodedPath = urlEncode(path, true);
353 		if (encodedPath.startsWith("/")) { //$NON-NLS-1$
354 			return encodedPath;
355 		} else {
356 			return "/" + encodedPath; //$NON-NLS-1$
357 		}
358 	}
359 
360 	private static byte[] hash(String s) {
361 		MessageDigest md = Constants.newMessageDigest();
362 		md.update(s.getBytes(StandardCharsets.UTF_8));
363 		return md.digest();
364 	}
365 
366 	private static byte[] sign(String stringData, byte[] key) {
367 		try {
368 			byte[] data = stringData.getBytes("UTF-8"); //$NON-NLS-1$
369 			Mac mac = Mac.getInstance(HMACSHA256);
370 			mac.init(new SecretKeySpec(key, HMACSHA256));
371 			return mac.doFinal(data);
372 		} catch (Exception e) {
373 			throw new RuntimeException(MessageFormat.format(
374 					LfsServerText.get().failedToCalcSignature, e.getMessage()),
375 					e);
376 		}
377 	}
378 
379 	private static String stringToSign(String scheme, String algorithm,
380 			String dateTime, String scope, String canonicalRequest) {
381 		return String.format("%s-%s\n%s\n%s\n%s", //$NON-NLS-1$
382 				scheme, algorithm, dateTime, scope,
383 				toHex(hash(canonicalRequest)));
384 	}
385 
386 	private static String toHex(byte[] bytes) {
387 		StringBuilder builder = new StringBuilder(2 * bytes.length);
388 		for (byte b : bytes) {
389 			builder.append(HEX.charAt((b & 0xF0) >> 4));
390 			builder.append(HEX.charAt(b & 0xF));
391 		}
392 		return builder.toString();
393 	}
394 
395 	private static String urlEncode(String url, boolean keepPathSlash) {
396 		String encoded;
397 		try {
398 			encoded = URLEncoder.encode(url, StandardCharsets.UTF_8.name());
399 		} catch (UnsupportedEncodingException e) {
400 			throw new RuntimeException(LfsServerText.get().unsupportedUtf8, e);
401 		}
402 		if (keepPathSlash) {
403 			encoded = encoded.replace("%2F", "/"); //$NON-NLS-1$ //$NON-NLS-2$
404 		}
405 		return encoded;
406 	}
407 
408 	private static byte[] createSignature(S3Config bucketConfig,
409 			String dateTimeStamp, String dateStamp,
410 			String scope, String canonicalRequest) {
411 		String stringToSign = stringToSign(SCHEME, ALGORITHM, dateTimeStamp,
412 				scope, canonicalRequest);
413 
414 		byte[] signature = (SCHEME + bucketConfig.getSecretKey()).getBytes();
415 		signature = sign(dateStamp, signature);
416 		signature = sign(bucketConfig.getRegion(), signature);
417 		signature = sign(S3, signature);
418 		signature = sign(TERMINATOR, signature);
419 		signature = sign(stringToSign, signature);
420 		return signature;
421 	}
422 }