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