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