1
2
3
4
5
6
7
8
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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86 public class AmazonS3 {
87 private static final Set<String> SIGNED_HEADERS;
88
89 private static final String AWS_API_V2 = "2";
90
91 private static final String AWS_API_V4 = "4";
92
93 private static final String AWS_S3_SERVICE_NAME = "s3";
94
95 private static final String HMAC = "HmacSHA1";
96
97 private static final String X_AMZ_ACL = "x-amz-acl";
98
99 private static final String X_AMZ_META = "x-amz-meta-";
100
101 static {
102 SIGNED_HEADERS = new HashSet<>();
103 SIGNED_HEADERS.add("content-type");
104 SIGNED_HEADERS.add("content-md5");
105 SIGNED_HEADERS.add("date");
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-");
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());
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 : "";
126 }
127
128 private static String httpNow() {
129 final String tz = "GMT";
130 final SimpleDateFormat fmt;
131 fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
132 fmt.setTimeZone(TimeZone.getTimeZone(tz));
133 return fmt.format(new Date()) + " " + tz;
134 }
135
136 private static MessageDigest newMD5() {
137 try {
138 return MessageDigest.getInstance("MD5");
139 } catch (NoSuchAlgorithmException e) {
140 throw new RuntimeException(JGitText.get().JRELacksMD5Implementation, e);
141 }
142 }
143
144
145 private final String awsApiSignatureVersion;
146
147
148 private final String publicKey;
149
150
151 private final SecretKeySpec secretKeySpec;
152
153
154 private final char[] secretKey;
155
156
157 private final ProxySelector proxySelector;
158
159
160 private final String acl;
161
162
163 final int maxAttempts;
164
165
166 private final WalkEncryption encryption;
167
168
169 private final File tmpDir;
170
171
172 private final String domain;
173
174
175 private final String region;
176
177
178 interface Keys {
179 String AWS_API_SIGNATURE_VERSION = "aws.api.signature.version";
180 String ACCESS_KEY = "accesskey";
181 String SECRET_KEY = "secretkey";
182 String PASSWORD = "password";
183 String CRYPTO_ALG = "crypto.algorithm";
184 String CRYPTO_VER = "crypto.version";
185 String ACL = "acl";
186 String DOMAIN = "domain";
187 String REGION = "region";
188 String HTTP_RETRY = "httpclient.retry-max";
189 String TMP_DIR = "tmpdir";
190 }
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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");
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");
263 if (StringUtils.equalsIgnoreCase("PRIVATE", pacl))
264 acl = "private";
265 else if (StringUtils.equalsIgnoreCase("PUBLIC", pacl))
266 acl = "public-read";
267 else if (StringUtils.equalsIgnoreCase("PUBLIC-READ", pacl))
268 acl = "public-read";
269 else if (StringUtils.equalsIgnoreCase("PUBLIC_READ", pacl))
270 acl = "public-read";
271 else
272 throw new IllegalArgumentException("Invalid acl: " + pacl);
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"));
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
290
291
292
293
294
295
296
297
298
299
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);
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
323
324
325
326
327
328
329
330 public InputStream decrypt(URLConnection u) throws IOException {
331 return encryption.decrypt(u.getInputStream());
332 }
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356 public List<String> list(String bucket, String prefix)
357 throws IOException {
358 if (prefix.length() > 0 && !prefix.endsWith("/"))
359 prefix += "/";
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
372
373
374
375
376
377
378
379
380
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);
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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419 public void put(String bucket, String key, byte[] data)
420 throws IOException {
421 if (encryption != WalkEncryption.NONE) {
422
423
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);
438 c.setRequestProperty("Content-Length", lenstr);
439 c.setRequestProperty("Content-MD5", md5str);
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
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
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);
522 c.setFixedLengthStreamingMode(len);
523 c.setRequestProperty("Content-MD5", md5str);
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)));
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");
627 c.setRequestProperty("Date", httpNow());
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"));
656 s.append('\n');
657
658 s.append(remove(sigHdr, "content-type"));
659 s.append('\n');
660
661 s.append(remove(sigHdr, "date"));
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);
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
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);
738 if (!entries.isEmpty())
739 args.put("marker", prefix + entries.get(entries.size() - 1).getName());
740
741 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
742 final HttpURLConnection c = open("GET", bucket, "", args);
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);
775 }
776 }
777 throw maxAttempts("Listing", prefix);
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)) {
785 data = new StringBuilder();
786 }
787 if ("Contents".equals(name)) {
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)) {
811 keyName = data.toString().substring(prefix.length());
812 } else if ("IsTruncated".equals(name)) {
813 truncated = StringUtils.equalsIgnoreCase("true", data.toString());
814 } else if ("LastModified".equals(name)) {
815 keyLastModified = Instant.parse(data.toString());
816 } else if ("Contents".equals(name)) {
817 entries.add(new KeyInfo(keyName, keyLastModified.getEpochSecond()));
818 }
819
820 data = null;
821 }
822 }
823 }