View Javadoc
1   /*
2    * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  
11  package org.eclipse.jgit.transport;
12  
13  import static java.nio.charset.StandardCharsets.UTF_8;
14  
15  import java.io.ByteArrayOutputStream;
16  import java.io.File;
17  import java.io.FileInputStream;
18  import java.io.FileNotFoundException;
19  import java.io.IOException;
20  import java.io.InputStream;
21  import java.io.OutputStream;
22  import java.net.HttpURLConnection;
23  import java.net.Proxy;
24  import java.net.ProxySelector;
25  import java.net.URL;
26  import java.net.URLConnection;
27  import java.security.DigestOutputStream;
28  import java.security.GeneralSecurityException;
29  import java.security.InvalidKeyException;
30  import java.security.MessageDigest;
31  import java.security.NoSuchAlgorithmException;
32  import java.text.MessageFormat;
33  import java.text.SimpleDateFormat;
34  import java.time.Instant;
35  import java.util.ArrayList;
36  import java.util.Collections;
37  import java.util.Comparator;
38  import java.util.Date;
39  import java.util.HashSet;
40  import java.util.Iterator;
41  import java.util.List;
42  import java.util.Locale;
43  import java.util.Map;
44  import java.util.Properties;
45  import java.util.Set;
46  import java.util.SortedMap;
47  import java.util.TimeZone;
48  import java.util.TreeMap;
49  import java.util.stream.Collectors;
50  
51  import javax.crypto.Mac;
52  import javax.crypto.spec.SecretKeySpec;
53  import javax.xml.parsers.ParserConfigurationException;
54  import javax.xml.parsers.SAXParserFactory;
55  
56  import org.eclipse.jgit.internal.JGitText;
57  import org.eclipse.jgit.lib.Constants;
58  import org.eclipse.jgit.lib.NullProgressMonitor;
59  import org.eclipse.jgit.lib.ProgressMonitor;
60  import org.eclipse.jgit.util.Base64;
61  import org.eclipse.jgit.util.HttpSupport;
62  import org.eclipse.jgit.util.StringUtils;
63  import org.eclipse.jgit.util.TemporaryBuffer;
64  import org.xml.sax.Attributes;
65  import org.xml.sax.InputSource;
66  import org.xml.sax.SAXException;
67  import org.xml.sax.XMLReader;
68  import org.xml.sax.helpers.DefaultHandler;
69  
70  /**
71   * A simple HTTP REST client for the Amazon S3 service.
72   * <p>
73   * This client uses the REST API to communicate with the Amazon S3 servers and
74   * read or write content through a bucket that the user has access to. It is a
75   * very lightweight implementation of the S3 API and therefore does not have all
76   * of the bells and whistles of popular client implementations.
77   * <p>
78   * Authentication is always performed using the user's AWSAccessKeyId and their
79   * private AWSSecretAccessKey.
80   * <p>
81   * Optional client-side encryption may be enabled if requested. The format is
82   * compatible with <a href="http://jets3t.s3.amazonaws.com/index.html">jets3t</a>,
83   * a popular Java based Amazon S3 client library. Enabling encryption can hide
84   * sensitive data from the operators of the S3 service.
85   */
86  public class AmazonS3 {
87  	private static final Set<String> SIGNED_HEADERS;
88  
89  	private static final String AWS_API_V2 = "2"; //$NON-NLS-1$
90  
91  	private static final String AWS_API_V4 = "4"; //$NON-NLS-1$
92  
93  	private static final String AWS_S3_SERVICE_NAME = "s3"; //$NON-NLS-1$
94  
95  	private static final String HMAC = "HmacSHA1"; //$NON-NLS-1$
96  
97  	private static final String X_AMZ_ACL = "x-amz-acl"; //$NON-NLS-1$
98  
99  	private static final String X_AMZ_META = "x-amz-meta-"; //$NON-NLS-1$
100 
101 	static {
102 		SIGNED_HEADERS = new HashSet<>();
103 		SIGNED_HEADERS.add("content-type"); //$NON-NLS-1$
104 		SIGNED_HEADERS.add("content-md5"); //$NON-NLS-1$
105 		SIGNED_HEADERS.add("date"); //$NON-NLS-1$
106 	}
107 
108 	private static boolean isSignedHeader(String name) {
109 		final String nameLC = StringUtils.toLowerCase(name);
110 		return SIGNED_HEADERS.contains(nameLC) || nameLC.startsWith("x-amz-"); //$NON-NLS-1$
111 	}
112 
113 	private static String toCleanString(List<String> list) {
114 		final StringBuilder s = new StringBuilder();
115 		for (String v : list) {
116 			if (s.length() > 0)
117 				s.append(',');
118 			s.append(v.replace("\n", "").trim()); //$NON-NLS-1$ //$NON-NLS-2$
119 		}
120 		return s.toString();
121 	}
122 
123 	private static String remove(Map<String, String> m, String k) {
124 		final String r = m.remove(k);
125 		return r != null ? r : ""; //$NON-NLS-1$
126 	}
127 
128 	private static String httpNow() {
129 		final String tz = "GMT"; //$NON-NLS-1$
130 		final SimpleDateFormat fmt;
131 		fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US); //$NON-NLS-1$
132 		fmt.setTimeZone(TimeZone.getTimeZone(tz));
133 		return fmt.format(new Date()) + " " + tz; //$NON-NLS-1$
134 	}
135 
136 	private static MessageDigest newMD5() {
137 		try {
138 			return MessageDigest.getInstance("MD5"); //$NON-NLS-1$
139 		} catch (NoSuchAlgorithmException e) {
140 			throw new RuntimeException(JGitText.get().JRELacksMD5Implementation, e);
141 		}
142 	}
143 
144 	/** AWS API Signature Version. */
145 	private final String awsApiSignatureVersion;
146 
147 	/** AWSAccessKeyId, public string that identifies the user's account. */
148 	private final String publicKey;
149 
150 	/** Decoded form of the private AWSSecretAccessKey, to sign requests. */
151 	private final SecretKeySpec secretKeySpec;
152 
153 	/** AWSSecretAccessKey, private string used to access a user's account. */
154 	private final char[] secretKey; // store as char[] for security
155 
156 	/** Our HTTP proxy support, in case we are behind a firewall. */
157 	private final ProxySelector proxySelector;
158 
159 	/** ACL to apply to created objects. */
160 	private final String acl;
161 
162 	/** Maximum number of times to try an operation. */
163 	final int maxAttempts;
164 
165 	/** Encryption algorithm, may be a null instance that provides pass-through. */
166 	private final WalkEncryption encryption;
167 
168 	/** Directory for locally buffered content. */
169 	private final File tmpDir;
170 
171 	/** S3 Bucket Domain. */
172 	private final String domain;
173 
174 	/** S3 Region. */
175 	private final String region;
176 
177 	/** Property names used in amazon connection configuration file. */
178 	interface Keys {
179 		String AWS_API_SIGNATURE_VERSION = "aws.api.signature.version"; //$NON-NLS-1$
180 		String ACCESS_KEY = "accesskey"; //$NON-NLS-1$
181 		String SECRET_KEY = "secretkey"; //$NON-NLS-1$
182 		String PASSWORD = "password"; //$NON-NLS-1$
183 		String CRYPTO_ALG = "crypto.algorithm"; //$NON-NLS-1$
184 		String CRYPTO_VER = "crypto.version"; //$NON-NLS-1$
185 		String ACL = "acl"; //$NON-NLS-1$
186 		String DOMAIN = "domain"; //$NON-NLS-1$
187 		String REGION = "region"; //$NON-NLS-1$
188 		String HTTP_RETRY = "httpclient.retry-max"; //$NON-NLS-1$
189 		String TMP_DIR = "tmpdir"; //$NON-NLS-1$
190 	}
191 
192 	/**
193 	 * Create a new S3 client for the supplied user information.
194 	 * <p>
195 	 * The connection properties are a subset of those supported by the popular
196 	 * <a href="http://jets3t.s3.amazonaws.com/index.html">jets3t</a> library.
197 	 * For example:
198 	 *
199 	 * <pre>
200 	 * # AWS API signature version, must be one of:
201 	 * #   2 - deprecated (not supported in all AWS regions)
202 	 * #   4 - latest (supported in all AWS regions)
203 	 * # Defaults to 2.
204 	 * aws.api.signature.version: 4
205 	 *
206 	 * # AWS Access and Secret Keys (required)
207 	 * accesskey: &lt;YourAWSAccessKey&gt;
208 	 * secretkey: &lt;YourAWSSecretKey&gt;
209 	 *
210 	 * # Access Control List setting to apply to uploads, must be one of:
211 	 * # PRIVATE, PUBLIC_READ (defaults to PRIVATE).
212 	 * acl: PRIVATE
213 	 *
214 	 * # S3 Domain
215 	 * # AWS S3 Region Domain (defaults to s3.amazonaws.com)
216 	 * domain: s3.amazonaws.com
217 	 *
218 	 * # AWS S3 Region (required if aws.api.signature.version = 4)
219 	 * region: us-west-2
220 	 *
221 	 * # Number of times to retry after internal error from S3.
222 	 * httpclient.retry-max: 3
223 	 *
224 	 * # End-to-end encryption (hides content from S3 owners)
225 	 * password: &lt;encryption pass-phrase&gt;
226 	 * crypto.algorithm: PBEWithMD5AndDES
227 	 * </pre>
228 	 *
229 	 * @param props
230 	 *            connection properties.
231 	 */
232 	public AmazonS3(final Properties props) {
233 		awsApiSignatureVersion = props
234 				.getProperty(Keys.AWS_API_SIGNATURE_VERSION, AWS_API_V2);
235 		if (awsApiSignatureVersion.equals(AWS_API_V4)) {
236 			region = props.getProperty(Keys.REGION);
237 			if (region == null) {
238 				throw new IllegalArgumentException(
239 						JGitText.get().missingAwsRegion);
240 			}
241 		} else if (awsApiSignatureVersion.equals(AWS_API_V2)) {
242 			region = null;
243 		} else {
244 			throw new IllegalArgumentException(MessageFormat.format(
245 					JGitText.get().invalidAwsApiSignatureVersion,
246 					awsApiSignatureVersion));
247 		}
248 
249 		domain = props.getProperty(Keys.DOMAIN, "s3.amazonaws.com"); //$NON-NLS-1$
250 
251 		publicKey = props.getProperty(Keys.ACCESS_KEY);
252 		if (publicKey == null)
253 			throw new IllegalArgumentException(JGitText.get().missingAccesskey);
254 
255 		final String secretKeyStr = props.getProperty(Keys.SECRET_KEY);
256 		if (secretKeyStr == null) {
257 			throw new IllegalArgumentException(JGitText.get().missingSecretkey);
258 		}
259 		secretKeySpec = new SecretKeySpec(Constants.encodeASCII(secretKeyStr), HMAC);
260 		secretKey = secretKeyStr.toCharArray();
261 
262 		final String pacl = props.getProperty(Keys.ACL, "PRIVATE"); //$NON-NLS-1$
263 		if (StringUtils.equalsIgnoreCase("PRIVATE", pacl)) //$NON-NLS-1$
264 			acl = "private"; //$NON-NLS-1$
265 		else if (StringUtils.equalsIgnoreCase("PUBLIC", pacl)) //$NON-NLS-1$
266 			acl = "public-read"; //$NON-NLS-1$
267 		else if (StringUtils.equalsIgnoreCase("PUBLIC-READ", pacl)) //$NON-NLS-1$
268 			acl = "public-read"; //$NON-NLS-1$
269 		else if (StringUtils.equalsIgnoreCase("PUBLIC_READ", pacl)) //$NON-NLS-1$
270 			acl = "public-read"; //$NON-NLS-1$
271 		else
272 			throw new IllegalArgumentException("Invalid acl: " + pacl); //$NON-NLS-1$
273 
274 		try {
275 			encryption = WalkEncryption.instance(props);
276 		} catch (GeneralSecurityException e) {
277 			throw new IllegalArgumentException(JGitText.get().invalidEncryption, e);
278 		}
279 
280 		maxAttempts = Integer
281 				.parseInt(props.getProperty(Keys.HTTP_RETRY, "3")); //$NON-NLS-1$
282 		proxySelector = ProxySelector.getDefault();
283 
284 		String tmp = props.getProperty(Keys.TMP_DIR);
285 		tmpDir = tmp != null && tmp.length() > 0 ? new File(tmp) : null;
286 	}
287 
288 	/**
289 	 * Get the content of a bucket object.
290 	 *
291 	 * @param bucket
292 	 *            name of the bucket storing the object.
293 	 * @param key
294 	 *            key of the object within its bucket.
295 	 * @return connection to stream the content of the object. The request
296 	 *         properties of the connection may not be modified by the caller as
297 	 *         the request parameters have already been signed.
298 	 * @throws java.io.IOException
299 	 *             sending the request was not possible.
300 	 */
301 	public URLConnection get(String bucket, String key)
302 			throws IOException {
303 		for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
304 			final HttpURLConnection c = open("GET", bucket, key); //$NON-NLS-1$
305 			authorize(c, Collections.emptyMap(), 0, null);
306 			switch (HttpSupport.response(c)) {
307 			case HttpURLConnection.HTTP_OK:
308 				encryption.validate(c, X_AMZ_META);
309 				return c;
310 			case HttpURLConnection.HTTP_NOT_FOUND:
311 				throw new FileNotFoundException(key);
312 			case HttpURLConnection.HTTP_INTERNAL_ERROR:
313 				continue;
314 			default:
315 				throw error(JGitText.get().s3ActionReading, key, c);
316 			}
317 		}
318 		throw maxAttempts(JGitText.get().s3ActionReading, key);
319 	}
320 
321 	/**
322 	 * Decrypt an input stream from {@link #get(String, String)}.
323 	 *
324 	 * @param u
325 	 *            connection previously created by {@link #get(String, String)}}.
326 	 * @return stream to read plain text from.
327 	 * @throws java.io.IOException
328 	 *             decryption could not be configured.
329 	 */
330 	public InputStream decrypt(URLConnection u) throws IOException {
331 		return encryption.decrypt(u.getInputStream());
332 	}
333 
334 	/**
335 	 * List the names of keys available within a bucket.
336 	 * <p>
337 	 * This method is primarily meant for obtaining a "recursive directory
338 	 * listing" rooted under the specified bucket and prefix location.
339 	 * It returns the keys sorted in reverse order of LastModified time
340 	 * (freshest keys first).
341 	 *
342 	 * @param bucket
343 	 *            name of the bucket whose objects should be listed.
344 	 * @param prefix
345 	 *            common prefix to filter the results by. Must not be null.
346 	 *            Supplying the empty string will list all keys in the bucket.
347 	 *            Supplying a non-empty string will act as though a trailing '/'
348 	 *            appears in prefix, even if it does not.
349 	 * @return list of keys starting with <code>prefix</code>, after removing
350 	 *         <code>prefix</code> (or <code>prefix + "/"</code>)from all
351 	 *         of them.
352 	 * @throws java.io.IOException
353 	 *             sending the request was not possible, or the response XML
354 	 *             document could not be parsed properly.
355 	 */
356 	public List<String> list(String bucket, String prefix)
357 			throws IOException {
358 		if (prefix.length() > 0 && !prefix.endsWith("/")) //$NON-NLS-1$
359 			prefix += "/"; //$NON-NLS-1$
360 		final ListParser lp = new ListParser(bucket, prefix);
361 		do {
362 			lp.list();
363 		} while (lp.truncated);
364 
365 		Comparator<KeyInfo> comparator = Comparator.comparingLong(KeyInfo::getLastModifiedSecs);
366 		return lp.entries.stream().sorted(comparator.reversed())
367 			.map(KeyInfo::getName).collect(Collectors.toList());
368 	}
369 
370 	/**
371 	 * Delete a single object.
372 	 * <p>
373 	 * Deletion always succeeds, even if the object does not exist.
374 	 *
375 	 * @param bucket
376 	 *            name of the bucket storing the object.
377 	 * @param key
378 	 *            key of the object within its bucket.
379 	 * @throws java.io.IOException
380 	 *             deletion failed due to communications error.
381 	 */
382 	public void delete(String bucket, String key)
383 			throws IOException {
384 		for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
385 			final HttpURLConnection c = open("DELETE", bucket, key); //$NON-NLS-1$
386 			authorize(c, Collections.emptyMap(), 0, null);
387 			switch (HttpSupport.response(c)) {
388 			case HttpURLConnection.HTTP_NO_CONTENT:
389 				return;
390 			case HttpURLConnection.HTTP_INTERNAL_ERROR:
391 				continue;
392 			default:
393 				throw error(JGitText.get().s3ActionDeletion, key, c);
394 			}
395 		}
396 		throw maxAttempts(JGitText.get().s3ActionDeletion, key);
397 	}
398 
399 	/**
400 	 * Atomically create or replace a single small object.
401 	 * <p>
402 	 * This form is only suitable for smaller contents, where the caller can
403 	 * reasonable fit the entire thing into memory.
404 	 * <p>
405 	 * End-to-end data integrity is assured by internally computing the MD5
406 	 * checksum of the supplied data and transmitting the checksum along with
407 	 * the data itself.
408 	 *
409 	 * @param bucket
410 	 *            name of the bucket storing the object.
411 	 * @param key
412 	 *            key of the object within its bucket.
413 	 * @param data
414 	 *            new data content for the object. Must not be null. Zero length
415 	 *            array will create a zero length object.
416 	 * @throws java.io.IOException
417 	 *             creation/updating failed due to communications error.
418 	 */
419 	public void put(String bucket, String key, byte[] data)
420 			throws IOException {
421 		if (encryption != WalkEncryption.NONE) {
422 			// We have to copy to produce the cipher text anyway so use
423 			// the large object code path as it supports that behavior.
424 			//
425 			try (OutputStream os = beginPut(bucket, key, null, null)) {
426 				os.write(data);
427 			}
428 			return;
429 		}
430 
431 		final String md5str = Base64.encodeBytes(newMD5().digest(data));
432 		final String bodyHash = awsApiSignatureVersion.equals(AWS_API_V4)
433 				? AwsRequestSignerV4.calculateBodyHash(data)
434 				: null;
435 		final String lenstr = String.valueOf(data.length);
436 		for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
437 			final HttpURLConnection c = open("PUT", bucket, key); //$NON-NLS-1$
438 			c.setRequestProperty("Content-Length", lenstr); //$NON-NLS-1$
439 			c.setRequestProperty("Content-MD5", md5str); //$NON-NLS-1$
440 			c.setRequestProperty(X_AMZ_ACL, acl);
441 			authorize(c, Collections.emptyMap(), data.length, bodyHash);
442 			c.setDoOutput(true);
443 			c.setFixedLengthStreamingMode(data.length);
444 			try (OutputStream os = c.getOutputStream()) {
445 				os.write(data);
446 			}
447 
448 			switch (HttpSupport.response(c)) {
449 			case HttpURLConnection.HTTP_OK:
450 				return;
451 			case HttpURLConnection.HTTP_INTERNAL_ERROR:
452 				continue;
453 			default:
454 				throw error(JGitText.get().s3ActionWriting, key, c);
455 			}
456 		}
457 		throw maxAttempts(JGitText.get().s3ActionWriting, key);
458 	}
459 
460 	/**
461 	 * Atomically create or replace a single large object.
462 	 * <p>
463 	 * Initially the returned output stream buffers data into memory, but if the
464 	 * total number of written bytes starts to exceed an internal limit the data
465 	 * is spooled to a temporary file on the local drive.
466 	 * <p>
467 	 * Network transmission is attempted only when <code>close()</code> gets
468 	 * called at the end of output. Closing the returned stream can therefore
469 	 * take significant time, especially if the written content is very large.
470 	 * <p>
471 	 * End-to-end data integrity is assured by internally computing the MD5
472 	 * checksum of the supplied data and transmitting the checksum along with
473 	 * the data itself.
474 	 *
475 	 * @param bucket
476 	 *            name of the bucket storing the object.
477 	 * @param key
478 	 *            key of the object within its bucket.
479 	 * @param monitor
480 	 *            (optional) progress monitor to post upload completion to
481 	 *            during the stream's close method.
482 	 * @param monitorTask
483 	 *            (optional) task name to display during the close method.
484 	 * @return a stream which accepts the new data, and transmits once closed.
485 	 * @throws java.io.IOException
486 	 *             if encryption was enabled it could not be configured.
487 	 */
488 	public OutputStream beginPut(final String bucket, final String key,
489 			final ProgressMonitor monitor, final String monitorTask)
490 			throws IOException {
491 		final MessageDigest md5 = newMD5();
492 		final TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(tmpDir) {
493 			@Override
494 			public void close() throws IOException {
495 				super.close();
496 				try {
497 					putImpl(bucket, key, md5.digest(), this, monitor,
498 							monitorTask);
499 				} finally {
500 					destroy();
501 				}
502 			}
503 		};
504 		return encryption.encrypt(new DigestOutputStream(buffer, md5));
505 	}
506 
507 	void putImpl(final String bucket, final String key,
508 			final byte[] csum, final TemporaryBuffer buf,
509 			ProgressMonitor monitor, String monitorTask) throws IOException {
510 		if (monitor == null)
511 			monitor = NullProgressMonitor.INSTANCE;
512 		if (monitorTask == null)
513 			monitorTask = MessageFormat.format(JGitText.get().progressMonUploading, key);
514 
515 		final String md5str = Base64.encodeBytes(csum);
516 		final String bodyHash = awsApiSignatureVersion.equals(AWS_API_V4)
517 				? AwsRequestSignerV4.calculateBodyHash(buf.toByteArray())
518 				: null;
519 		final long len = buf.length();
520 		for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
521 			final HttpURLConnection c = open("PUT", bucket, key); //$NON-NLS-1$
522 			c.setFixedLengthStreamingMode(len);
523 			c.setRequestProperty("Content-MD5", md5str); //$NON-NLS-1$
524 			c.setRequestProperty(X_AMZ_ACL, acl);
525 			encryption.request(c, X_AMZ_META);
526 			authorize(c, Collections.emptyMap(), len, bodyHash);
527 			c.setDoOutput(true);
528 			monitor.beginTask(monitorTask, (int) (len / 1024));
529 			try (OutputStream os = c.getOutputStream()) {
530 				buf.writeTo(os, monitor);
531 			} finally {
532 				monitor.endTask();
533 			}
534 
535 			switch (HttpSupport.response(c)) {
536 			case HttpURLConnection.HTTP_OK:
537 				return;
538 			case HttpURLConnection.HTTP_INTERNAL_ERROR:
539 				continue;
540 			default:
541 				throw error(JGitText.get().s3ActionWriting, key, c);
542 			}
543 		}
544 		throw maxAttempts(JGitText.get().s3ActionWriting, key);
545 	}
546 
547 	IOException error(final String action, final String key,
548 			final HttpURLConnection c) throws IOException {
549 		final IOException err = new IOException(MessageFormat.format(
550 				JGitText.get().amazonS3ActionFailed, action, key,
551 				Integer.valueOf(HttpSupport.response(c)),
552 				c.getResponseMessage()));
553 		if (c.getErrorStream() == null) {
554 			return err;
555 		}
556 
557 		try (InputStream errorStream = c.getErrorStream()) {
558 			final ByteArrayOutputStream b = new ByteArrayOutputStream();
559 			byte[] buf = new byte[2048];
560 			for (;;) {
561 				final int n = errorStream.read(buf);
562 				if (n < 0) {
563 					break;
564 				}
565 				if (n > 0) {
566 					b.write(buf, 0, n);
567 				}
568 			}
569 			buf = b.toByteArray();
570 			if (buf.length > 0) {
571 				err.initCause(new IOException("\n" + new String(buf, UTF_8))); //$NON-NLS-1$
572 			}
573 		}
574 		return err;
575 	}
576 
577 	IOException maxAttempts(String action, String key) {
578 		return new IOException(MessageFormat.format(
579 				JGitText.get().amazonS3ActionFailedGivingUp, action, key,
580 				Integer.valueOf(maxAttempts)));
581 	}
582 
583 	private HttpURLConnection open(final String method, final String bucket,
584 			final String key) throws IOException {
585 		final Map<String, String> noArgs = Collections.emptyMap();
586 		return open(method, bucket, key, noArgs);
587 	}
588 
589 	HttpURLConnection open(final String method, final String bucket,
590 			final String key, final Map<String, String> args)
591 			throws IOException {
592 		final StringBuilder urlstr = new StringBuilder();
593 		urlstr.append("http://"); //$NON-NLS-1$
594 		urlstr.append(bucket);
595 		urlstr.append('.');
596 		urlstr.append(domain);
597 		urlstr.append('/');
598 		if (key.length() > 0) {
599 			if (awsApiSignatureVersion.equals(AWS_API_V2)) {
600 				HttpSupport.encode(urlstr, key);
601 			} else if (awsApiSignatureVersion.equals(AWS_API_V4)) {
602 				urlstr.append(key);
603 			}
604 		}
605 		if (!args.isEmpty()) {
606 			final Iterator<Map.Entry<String, String>> i;
607 
608 			urlstr.append('?');
609 			i = args.entrySet().iterator();
610 			while (i.hasNext()) {
611 				final Map.Entry<String, String> e = i.next();
612 				urlstr.append(e.getKey());
613 				urlstr.append('=');
614 				HttpSupport.encode(urlstr, e.getValue());
615 				if (i.hasNext())
616 					urlstr.append('&');
617 			}
618 		}
619 
620 		final URL url = new URL(urlstr.toString());
621 		final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
622 		final HttpURLConnection c;
623 
624 		c = (HttpURLConnection) url.openConnection(proxy);
625 		c.setRequestMethod(method);
626 		c.setRequestProperty("User-Agent", "jgit/1.0"); //$NON-NLS-1$ //$NON-NLS-2$
627 		c.setRequestProperty("Date", httpNow()); //$NON-NLS-1$
628 		return c;
629 	}
630 
631 	void authorize(HttpURLConnection httpURLConnection,
632 			Map<String, String> queryParams, long contentLength,
633 			final String bodyHash) throws IOException {
634 		if (awsApiSignatureVersion.equals(AWS_API_V2)) {
635 			authorizeV2(httpURLConnection);
636 		} else if (awsApiSignatureVersion.equals(AWS_API_V4)) {
637 			AwsRequestSignerV4.sign(httpURLConnection, queryParams, contentLength, bodyHash, AWS_S3_SERVICE_NAME,
638 					region, publicKey, secretKey);
639 		}
640 	}
641 
642 	void authorizeV2(HttpURLConnection c) throws IOException {
643 		final Map<String, List<String>> reqHdr = c.getRequestProperties();
644 		final SortedMap<String, String> sigHdr = new TreeMap<>();
645 		for (Map.Entry<String, List<String>> entry : reqHdr.entrySet()) {
646 			final String hdr = entry.getKey();
647 			if (isSignedHeader(hdr))
648 				sigHdr.put(StringUtils.toLowerCase(hdr), toCleanString(entry.getValue()));
649 		}
650 
651 		final StringBuilder s = new StringBuilder();
652 		s.append(c.getRequestMethod());
653 		s.append('\n');
654 
655 		s.append(remove(sigHdr, "content-md5")); //$NON-NLS-1$
656 		s.append('\n');
657 
658 		s.append(remove(sigHdr, "content-type")); //$NON-NLS-1$
659 		s.append('\n');
660 
661 		s.append(remove(sigHdr, "date")); //$NON-NLS-1$
662 		s.append('\n');
663 
664 		for (Map.Entry<String, String> e : sigHdr.entrySet()) {
665 			s.append(e.getKey());
666 			s.append(':');
667 			s.append(e.getValue());
668 			s.append('\n');
669 		}
670 
671 		final String host = c.getURL().getHost();
672 		s.append('/');
673 		s.append(host.substring(0, host.length() - domain.length() - 1));
674 		s.append(c.getURL().getPath());
675 
676 		final String sec;
677 		try {
678 			final Mac m = Mac.getInstance(HMAC);
679 			m.init(secretKeySpec);
680 			sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes(UTF_8)));
681 		} catch (NoSuchAlgorithmException e) {
682 			throw new IOException(MessageFormat.format(JGitText.get().noHMACsupport, HMAC, e.getMessage()));
683 		} catch (InvalidKeyException e) {
684 			throw new IOException(MessageFormat.format(JGitText.get().invalidKey, e.getMessage()));
685 		}
686 		c.setRequestProperty("Authorization", "AWS " + publicKey + ":" + sec); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
687 	}
688 
689 	static Properties properties(File authFile)
690 			throws FileNotFoundException, IOException {
691 		final Properties p = new Properties();
692 		try (FileInputStream in = new FileInputStream(authFile)) {
693 			p.load(in);
694 		}
695 		return p;
696 	}
697 
698 	/**
699 	 * KeyInfo enables sorting of keys by lastModified time
700 	 */
701 	private static final class KeyInfo {
702 		private final String name;
703 		private final long lastModifiedSecs;
704 		public KeyInfo(String aname, long lsecs) {
705 			name = aname;
706 			lastModifiedSecs = lsecs;
707 		}
708 		public String getName() {
709 			return name;
710 		}
711 		public long getLastModifiedSecs() {
712 			return lastModifiedSecs;
713 		}
714 	}
715 
716 	private final class ListParser extends DefaultHandler {
717 		final List<KeyInfo> entries = new ArrayList<>();
718 
719 		private final String bucket;
720 
721 		private final String prefix;
722 
723 		boolean truncated;
724 
725 		private StringBuilder data;
726 		private String keyName;
727 		private Instant keyLastModified;
728 
729 		ListParser(String bn, String p) {
730 			bucket = bn;
731 			prefix = p;
732 		}
733 
734 		void list() throws IOException {
735 			final Map<String, String> args = new TreeMap<>();
736 			if (prefix.length() > 0)
737 				args.put("prefix", prefix); //$NON-NLS-1$
738 			if (!entries.isEmpty())
739 				args.put("marker", prefix + entries.get(entries.size() - 1).getName()); //$NON-NLS-1$
740 
741 			for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
742 				final HttpURLConnection c = open("GET", bucket, "", args); //$NON-NLS-1$ //$NON-NLS-2$
743 				authorize(c, args, 0, null);
744 				switch (HttpSupport.response(c)) {
745 				case HttpURLConnection.HTTP_OK:
746 					truncated = false;
747 					data = null;
748 					keyName = null;
749 					keyLastModified = null;
750 
751 					final XMLReader xr;
752 					try {
753 						xr = SAXParserFactory.newInstance().newSAXParser()
754 								.getXMLReader();
755 					} catch (SAXException | ParserConfigurationException e) {
756 						throw new IOException(
757 								JGitText.get().noXMLParserAvailable, e);
758 					}
759 					xr.setContentHandler(this);
760 					try (InputStream in = c.getInputStream()) {
761 						xr.parse(new InputSource(in));
762 					} catch (SAXException parsingError) {
763 						throw new IOException(
764 								MessageFormat.format(
765 										JGitText.get().errorListing, prefix),
766 								parsingError);
767 					}
768 					return;
769 
770 				case HttpURLConnection.HTTP_INTERNAL_ERROR:
771 					continue;
772 
773 				default:
774 					throw AmazonS3.this.error("Listing", prefix, c); //$NON-NLS-1$
775 				}
776 			}
777 			throw maxAttempts("Listing", prefix); //$NON-NLS-1$
778 		}
779 
780 		@Override
781 		public void startElement(final String uri, final String name,
782 				final String qName, final Attributes attributes)
783 				throws SAXException {
784 			if ("Key".equals(name) || "IsTruncated".equals(name) || "LastModified".equals(name)) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
785 				data = new StringBuilder();
786 			}
787 			if ("Contents".equals(name)) { //$NON-NLS-1$
788 				keyName = null;
789 				keyLastModified = null;
790 			}
791 		}
792 
793 		@Override
794 		public void ignorableWhitespace(final char[] ch, final int s,
795 				final int n) throws SAXException {
796 			if (data != null)
797 				data.append(ch, s, n);
798 		}
799 
800 		@Override
801 		public void characters(char[] ch, int s, int n)
802 				throws SAXException {
803 			if (data != null)
804 				data.append(ch, s, n);
805 		}
806 
807 		@Override
808 		public void endElement(final String uri, final String name,
809 				final String qName) throws SAXException {
810 			if ("Key".equals(name))  { //$NON-NLS-1$
811 				keyName = data.toString().substring(prefix.length());
812 			} else if ("IsTruncated".equals(name)) { //$NON-NLS-1$
813 				truncated = StringUtils.equalsIgnoreCase("true", data.toString()); //$NON-NLS-1$
814 			} else if ("LastModified".equals(name)) { //$NON-NLS-1$
815 				keyLastModified = Instant.parse(data.toString());
816 			} else if ("Contents".equals(name)) { //$NON-NLS-1$
817 				entries.add(new KeyInfo(keyName, keyLastModified.getEpochSecond()));
818 			}
819 
820 			data = null;
821 		}
822 	}
823 }