Skip to content

Commit

Permalink
start working on AWS signing
Browse files Browse the repository at this point in the history
  • Loading branch information
ryber committed Feb 28, 2024
1 parent 898366b commit 9f044f0
Show file tree
Hide file tree
Showing 6 changed files with 394 additions and 0 deletions.
224 changes: 224 additions & 0 deletions unirest/src/main/java/kong/unirest/core/AwsSignerV4.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package kong.unirest.core;

import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class AwsSignerV4 implements Signer {

@Override
public void sign(HttpRequest<?> request) {

}

public static class CanonicalRequest {
private final HttpRequest request;
private final URI url;
private final MultiHashMap<String, String> signingHeaders;

public CanonicalRequest(HttpRequest request){
this.request = request;
this.url = URI.create(request.getUrl());
this.signingHeaders = processHeaders(request);
}

private MultiHashMap<String, String> processHeaders(HttpRequest request) {
var headers = new MultiHashMap<String, String>();
request.getHeaders().all().forEach(h -> {
var key = h.getName().toLowerCase();
if(key.startsWith("x-amz") || key.equals("host")) {
headers.add(key, h.getValue().trim());
}
});
if(!headers.containsKey("host")){
headers.add("host", url.getHost());
}
return headers;
}

/**
* HTTPMethod is one of the HTTP methods, for example GET, PUT, HEAD, and DELETE.
* example: GET
*
* @return header value as string
*/
public String getHttpMethod() {
return request.getHttpMethod().name();
}

/**
* Is the URI-encoded version of the absolute path component
* of the URI—everything starting with the "/" that follows
* the domain name and up to the end of the string or to the
* question mark character ('?') if you have query string parameters.
*<p>
* The URI in the following example, /examplebucket/myphoto.jpg,
* is the absolute path and you don't encode the "/" in the absolute path:
* example: http://s3.amazonaws.com/examplebucket/myphoto.jpg
*
* @return the uri as a string
*/
public String getCanonicalUri() {
String path = Util.isNullOrEmpty(url.getPath()) ? "/" : url.getPath();
return URLEncoder.encode(url.getScheme() + "://" + url.getHost() + path, StandardCharsets.UTF_8);
}

/**
* Specifies the URI-encoded query string parameters.
* You URI-encode name and values individually.
* You must also sort the parameters in the canonical query string
* alphabetically by key name. The sorting occurs after encoding.
* <p>
* The query string in http://s3.amazonaws.com/examplebucket?prefix=somePrefix&marker=someMarker&max-keys=20
* is prefix=somePrefix&marker=someMarker&max-keys=20:
* <p>
* The canonical query string is as follows (line breaks are added to this example for readability):
* UriEncode("marker")+"="+UriEncode("someMarker")+"&"+
* UriEncode("max-keys")+"="+UriEncode("20") + "&" +
* UriEncode("prefix")+"="+UriEncode("somePrefix")
* <p>
* When a request targets a subresource, the corresponding query
* parameter value will be an empty string (""). For example,
* the following URI identifies the ACL subresource on the examplebucket bucket:
* http://s3.amazonaws.com/examplebucket?acl
* <p>
* The CanonicalQueryString in this case is as follows:
* <p>
* UriEncode("acl") + "=" + ""
* <p>
* If the URI does not include a '?', there is no query string in the request,
* and you set the canonical query string to an empty string ("").
* You will still need to include the "\n".
*
* @return the query string
*/
public String getCanonicalQueryString() {
var uri = QueryParams.fromURI(request.getUrl());
return uri.getQueryParams()
.stream()
.sorted(Comparator.comparing(QueryParams.NameValuePair::getName))
.map(nv -> nv.toString())
.collect(Collectors.joining("&"));
}

/**
* CanonicalHeaders is a list of request headers with their values.
* Individual header name and value pairs are separated by the newline character ("\n").
* Header names must be in lowercase. You must sort the header names
* alphabetically to construct the string, as shown in the following example:
* <code>
* Lowercase(<HeaderName1>)+":"+Trim(<value>)+"\n"
* Lowercase(<HeaderName2>)+":"+Trim(<value>)+"\n"
* ...
* Lowercase(<HeaderNameN>)+":"+Trim(<value>)+"\n"
* </code>
*The Lowercase() and Trim() functions used in this example are described in the preceding section.
*<p>
* The CanonicalHeaders list must include the following:
* - HTTP host header.
* - If the Content-Type header is present in the request, you must add it to the CanonicalHeaders list.
* - Any x-amz-* headers that you plan to include in your request must also be added.
* For example, if you are using temporary security credentials,
* you need to include x-amz-security-token in your request.
* You must add this header in the list of CanonicalHeaders.
* <p>
* NOTE: The x-amz-content-sha256 header is required for all AWS Signature Version 4 requests.
* It provides a hash of the request payload.
* If there is no payload, you must provide the hash of an empty string.
*<p>
* The following is an example CanonicalHeaders string. The header names are in lowercase and sorted.
* <code>
* host:s3.amazonaws.com
* x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
* x-amz-date:20130708T220855Z
* </code>
*
* NOTE:
* For the purpose of calculating an authorization signature, only the host
* and any x-amz-* headers are required; however, in order to prevent data tampering,
* you should consider including all the headers in the signature calculation.
*
* @return the header string
*/
public String getCanonicalHeaders() {
return signingHeaders.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.map(this::renderHeader)
.collect(Collectors.joining("\n"));
}

/**
* SignedHeaders is an alphabetically sorted, semicolon-separated list
* of lowercase request header names. The request headers in the list
* are the same headers that you included in the CanonicalHeaders string.
* <p>
* For example, for the previous example, the value of SignedHeaders
* would be as follows: host;x-amz-content-sha256;x-amz-date
*
* @return the signed headers
*/
public String getSignedHeaders() {
return signingHeaders.keySet().stream().sorted().collect(Collectors.joining(";"));
}

private String renderHeader(Map.Entry<String, Set<String>> header) {
return header.getKey() + ":" + header
.getValue()
.stream()
.map(v -> Util.nullToEmpty(v).trim())
.sorted()
.collect(Collectors.joining(","));
}

/**
* HashedPayload is the hexadecimal value of the SHA256 hash
* of the request payload.
*<code>Hex(SHA256Hash({payload})</code>
*<p>
* If there is no payload in the request, you compute a hash
* of the empty string as follows:
* <code>>Hex(SHA256Hash(""))</code>
* The hash returns the following value:
* e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
* <p>
* For example, when you upload an object by using a
* PUT request, you provide object data in the body.
* When you retrieve an object by using a GET request,
* you compute the empty string hash.
*
* @return the hashed payload
*/
public String getHashedPayload() {
return sha256((String)request.getBody().orElse(""));
}

private String sha256(String o){
try {
var digest = MessageDigest.getInstance("SHA-256");
var encodedhash = digest.digest(
o.getBytes(StandardCharsets.UTF_8));
return bytesToHex(encodedhash);
}catch (NoSuchAlgorithmException e){
throw new UnirestException(e);
}
}

private static final byte[] HEX_ARRAY = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII);
private static String bytesToHex(byte[] bytes) {
byte[] hexChars = new byte[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars, StandardCharsets.UTF_8);
}
}
}
20 changes: 20 additions & 0 deletions unirest/src/main/java/kong/unirest/core/MultiHashMap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kong.unirest.core;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;

class MultiHashMap<K, V> extends HashMap<K, Set<V>> {
public void add(K key, V value){
compute(key, (k,v) -> {
if(v == null){
var set = new HashSet<V>();
set.add(value);
return set;
} else {
v.add(value);
return v;
}
});
}
}
10 changes: 10 additions & 0 deletions unirest/src/main/java/kong/unirest/core/QueryParams.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
package kong.unirest.core;

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;

Expand Down Expand Up @@ -92,5 +94,13 @@ public String getName() {
public String getValue() {
return value;
}

@Override
public String toString() {
return (URLEncoder.encode(getName(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(getValue(), StandardCharsets.UTF_8))
.replace("+","%20");
}
}
}
6 changes: 6 additions & 0 deletions unirest/src/main/java/kong/unirest/core/Signer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kong.unirest.core;

@FunctionalInterface
public interface Signer {
void sign(HttpRequest<?> request);
}
118 changes: 118 additions & 0 deletions unirest/src/test/java/kong/unirest/core/AwsSignerV4Test.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package kong.unirest.core;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;


class AwsSignerV4Test {

@Test
void getMethod() {
var sig = getSig(Unirest.get("http://foo.com"));
assertEquals("GET", sig.getHttpMethod());
}

@Test
void getCanonicalUri_noPath() {
var sig = getSig(Unirest.get("http://foo.com?fruit=apples"));

assertEquals("http%3A%2F%2Ffoo.com%2F", sig.getCanonicalUri());
}

@Test
void getCanonicalUri_withPath() {
var sig = getSig(Unirest.get("http://foo.com/bar/baz?fruit=apples"));

assertEquals("http%3A%2F%2Ffoo.com%2Fbar%2Fbaz", sig.getCanonicalUri());
}

@Test
void canonicalQueryString_empty() {
var req = Unirest.get("http://foo.com/bar/baz");
var sig = getSig(req);
assertEquals("", sig.getCanonicalQueryString());
}

@Test
void canonicalQueryString_One() {
var req = Unirest.get("http://foo.com/bar/baz")
.queryString("fruit", "apples");
var sig = getSig(req);

assertEquals("fruit=apples", sig.getCanonicalQueryString());
}

@Test
void canonicalQueryString_Two() {
var req = Unirest.get("http://foo.com/bar/baz")
.queryString("tool", "hammer")
.queryString("fruit", "apples");

var sig = getSig(req);

assertEquals("fruit=apples&tool=hammer", sig.getCanonicalQueryString());
}

@Test
void canonicalQueryString_WithEncoding() {
var req = Unirest.get("http://foo.com/bar/baz")
.queryString("to ol", "ham mer")
.queryString("fruit", "app+les");

var sig = getSig(req);

assertEquals("fruit=app%2Bles&to%20ol=ham%20mer", sig.getCanonicalQueryString());
}

@Test
void canonicalHeadersDefaults() {
var req = Unirest.get("http://foo.com/bar/baz");

var sig = getSig(req);

assertEquals("host:foo.com", sig.getCanonicalHeaders());
}

@Test
void canonicalHeadersWithAwsHeaders() {
var req = Unirest.get("http://foo.com/bar/baz")
.header("x-amz-beta", " monkeys")
.header("x-AMZ-alpha", "cheese")
.header("x-amz-alpha", "lol")
.header("something", "else");

var sig = getSig(req);

assertEquals("host:foo.com\n" +
"x-amz-alpha:cheese,lol\n" +
"x-amz-beta:monkeys", sig.getCanonicalHeaders());
}

@Test
void signedHeaders() {
var req = Unirest.get("http://foo.com/bar/baz")
.header("x-amz-zulu", " monkeys")
.header("x-AMZ-alpha", "cheese")
.header("x-amz-alpha", "lol")
.header("something", "else");

var sig = getSig(req);

assertEquals("host;x-amz-alpha;x-amz-zulu", sig.getSignedHeaders());
}

@Test
void hashedPayload_get() {
var req = Unirest.get("http://foo.com/bar/baz");

var sig = getSig(req);

assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
,sig.getHashedPayload());
}

private static AwsSignerV4.CanonicalRequest getSig(GetRequest req) {
return new AwsSignerV4.CanonicalRequest(req);
}
}
Loading

0 comments on commit 9f044f0

Please sign in to comment.