Skip to content

Commit

Permalink
(storage) Add V4 signing support
Browse files Browse the repository at this point in the history
  • Loading branch information
JesseLovelace committed Mar 18, 2019
1 parent ab931d8 commit 4e30cdc
Show file tree
Hide file tree
Showing 7 changed files with 470 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,17 @@
public class CanonicalExtensionHeadersSerializer {

private static final char HEADER_SEPARATOR = ':';
private static final char HEADER_NAME_SEPARATOR = ';';

public StringBuilder serialize(Map<String, String> canonicalizedExtensionHeaders) {
public StringBuilder serialize(Map<String, String> canonicalizedExtensionHeaders, boolean isV4) {

StringBuilder serializedHeaders = new StringBuilder();

if (canonicalizedExtensionHeaders == null || canonicalizedExtensionHeaders.isEmpty()) {

return serializedHeaders;
}

// Make all custom header names lowercase.
Map<String, String> lowercaseHeaders = new HashMap<>();
for (String headerName : new ArrayList<>(canonicalizedExtensionHeaders.keySet())) {

String lowercaseHeaderName = headerName.toLowerCase();

// If present, remove the x-goog-encryption-key and x-goog-encryption-key-sha256 headers.
if ("x-goog-encryption-key".equals(lowercaseHeaderName)
|| "x-goog-encryption-key-sha256".equals(lowercaseHeaderName)) {

continue;
}

lowercaseHeaders.put(lowercaseHeaderName, canonicalizedExtensionHeaders.get(headerName));
}
Map<String, String> lowercaseHeaders = getLowercaseHeaders(canonicalizedExtensionHeaders, isV4);

// Sort all custom headers by header name using a lexicographical sort by code point value.
List<String> sortedHeaderNames = new ArrayList<>(lowercaseHeaders.keySet());
Expand All @@ -81,4 +67,56 @@ public StringBuilder serialize(Map<String, String> canonicalizedExtensionHeaders
// Concatenate all custom headers
return serializedHeaders;
}

public StringBuilder serialize(Map<String, String> canonicalizedExtensionHeaders) {
return serialize(canonicalizedExtensionHeaders, false);
}

public StringBuilder serializeHeaderNames(
Map<String, String> canonicalizedExtensionHeaders, boolean isV4) {
StringBuilder serializedHeaders = new StringBuilder();

if (canonicalizedExtensionHeaders == null || canonicalizedExtensionHeaders.isEmpty()) {
return serializedHeaders;
}

Map<String, String> lowercaseHeaders = getLowercaseHeaders(canonicalizedExtensionHeaders, isV4);

List<String> sortedHeaderNames = new ArrayList<>(lowercaseHeaders.keySet());
Collections.sort(sortedHeaderNames);

for (String headerName : sortedHeaderNames) {
serializedHeaders.append(headerName).append(HEADER_NAME_SEPARATOR);
}

serializedHeaders.setLength(serializedHeaders.length() - 1); // remove trailing semicolon

return serializedHeaders;
}

public StringBuilder serializeHeaderNames(Map<String, String> canonicalizedExtentionHeaders) {
return serializeHeaderNames(canonicalizedExtentionHeaders, true);
}

private Map<String, String> getLowercaseHeaders(
Map<String, String> canonicalizedExtensionHeaders, boolean isV4) {
// Make all custom header names lowercase.
Map<String, String> lowercaseHeaders = new HashMap<>();
for (String headerName : new ArrayList<>(canonicalizedExtensionHeaders.keySet())) {

String lowercaseHeaderName = headerName.toLowerCase();

// If present, remove the x-goog-encryption-key and x-goog-encryption-key-sha256 headers.
if ("x-goog-encryption-key".equals(lowercaseHeaderName)
|| "x-goog-encryption-key-sha256".equals(lowercaseHeaderName)
|| (isV4 && "x-goog-encryption-algorithm".equals(lowercaseHeaderName))) {

continue;
}

lowercaseHeaders.put(lowercaseHeaderName, canonicalizedExtensionHeaders.get(headerName));
}

return lowercaseHeaders;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@

package com.google.cloud.storage;

import com.google.common.collect.ImmutableMap;
import com.google.common.hash.Hashing;
import com.google.common.net.UrlEscapers;

import static com.google.common.base.Preconditions.checkArgument;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;

/**
Expand All @@ -31,30 +38,64 @@
public class SignatureInfo {

public static final char COMPONENT_SEPARATOR = '\n';
public static final String GOOG4_RSA_SHA256 = "GOOG4-RSA-SHA256";
public static final String SCOPE = "/auto/storage/goog4_request";

private final HttpMethod httpVerb;
private final String contentMd5;
private final String contentType;
private final long expiration;
private final Map<String, String> canonicalizedExtensionHeaders;
private final URI canonicalizedResource;
private final Storage.SignUrlOption.SignatureVersion signatureVersion;
private final String accountEmail;
private final long timestamp;

private final String yearMonthDay;
private final String exactDate;

private SignatureInfo(Builder builder) {
this.httpVerb = builder.httpVerb;
this.contentMd5 = builder.contentMd5;
this.contentType = builder.contentType;
this.expiration = builder.expiration;
this.canonicalizedExtensionHeaders = builder.canonicalizedExtensionHeaders;
this.canonicalizedResource = builder.canonicalizedResource;
this.signatureVersion = builder.signatureVersion;
this.accountEmail = builder.accountEmail;
this.timestamp = builder.timestamp;

if (Storage.SignUrlOption.SignatureVersion.V4.equals(signatureVersion)
&& !builder.canonicalizedExtensionHeaders.containsKey("host")) {
canonicalizedExtensionHeaders =
new ImmutableMap.Builder<String, String>()
.putAll(builder.canonicalizedExtensionHeaders)
.put("host", "storage.googleapis.com")
.build();
} else {
canonicalizedExtensionHeaders = builder.canonicalizedExtensionHeaders;
}

Date date = new Date(timestamp);

yearMonthDay = new SimpleDateFormat("yyyyMMdd").format(date);
exactDate = new SimpleDateFormat("yyyyMMdd'T'hhmmss'Z'").format(date);
}

/**
* Constructs payload to be signed.
*
* @return paylod to sign
* @return payload to sign
* @see <a href="https://cloud.google.com/storage/docs/access-control#Signed-URLs">Signed URLs</a>
*/
public String constructUnsignedPayload() {
// TODO reverse order when V4 becomes default
if (Storage.SignUrlOption.SignatureVersion.V4.equals(signatureVersion)) {
return constructV4UnsignedPayload();
}
return constructV2UnsignedPayload();
}

private String constructV2UnsignedPayload() {
StringBuilder payload = new StringBuilder();

payload.append(httpVerb.name()).append(COMPONENT_SEPARATOR);
Expand All @@ -80,6 +121,66 @@ public String constructUnsignedPayload() {
return payload.toString();
}

private String constructV4UnsignedPayload() {
StringBuilder payload = new StringBuilder();

payload.append(GOOG4_RSA_SHA256).append(COMPONENT_SEPARATOR);

payload.append(exactDate).append(COMPONENT_SEPARATOR);

payload.append(yearMonthDay).append(SCOPE).append(COMPONENT_SEPARATOR);

payload.append(constructV4CanonicalRequestHash());

return payload.toString();
}

private String constructV4CanonicalRequestHash() {
StringBuilder canonicalRequest = new StringBuilder();

CanonicalExtensionHeadersSerializer serializer = new CanonicalExtensionHeadersSerializer();

canonicalRequest.append(httpVerb.name()).append(COMPONENT_SEPARATOR);

canonicalRequest.append(canonicalizedResource).append(COMPONENT_SEPARATOR);

canonicalRequest.append(constructV4QueryString()).append(COMPONENT_SEPARATOR);

canonicalRequest
.append(serializer.serialize(canonicalizedExtensionHeaders, true))
.append(COMPONENT_SEPARATOR);

canonicalRequest
.append(serializer.serializeHeaderNames(canonicalizedExtensionHeaders))
.append(COMPONENT_SEPARATOR);

canonicalRequest.append("UNSIGNED-PAYLOAD");
return Hashing.sha256()
.hashString(canonicalRequest.toString(), StandardCharsets.UTF_8)
.toString();
}

public String constructV4QueryString() {
StringBuilder signedHeaders =
new CanonicalExtensionHeadersSerializer()
.serializeHeaderNames(canonicalizedExtensionHeaders);

StringBuilder queryString = new StringBuilder();
queryString.append("X-Goog-Algorithm=").append(GOOG4_RSA_SHA256).append("&");
queryString.append(
"X-Goog-Credential="
+ UrlEscapers.urlFormParameterEscaper()
.escape(accountEmail + "/" + yearMonthDay + SCOPE)
+ "&");
queryString.append("X-Goog-Date=" + exactDate + "&");
queryString.append("X-Goog-Expires=" + expiration + "&");
queryString.append(
"X-Goog-SignedHeaders="
+ UrlEscapers.urlFormParameterEscaper().escape(signedHeaders.toString()));

return queryString.toString();
}

public HttpMethod getHttpVerb() {
return httpVerb;
}
Expand All @@ -104,6 +205,18 @@ public URI getCanonicalizedResource() {
return canonicalizedResource;
}

public Storage.SignUrlOption.SignatureVersion getSignatureVersion() {
return signatureVersion;
}

public long getTimestamp() {
return timestamp;
}

public String getAccountEmail() {
return accountEmail;
}

public static final class Builder {

private final HttpMethod httpVerb;
Expand All @@ -112,6 +225,9 @@ public static final class Builder {
private final long expiration;
private Map<String, String> canonicalizedExtensionHeaders;
private final URI canonicalizedResource;
private Storage.SignUrlOption.SignatureVersion signatureVersion;
private String accountEmail;
private long timestamp;

/**
* Constructs builder.
Expand All @@ -134,6 +250,9 @@ public Builder(SignatureInfo signatureInfo) {
this.expiration = signatureInfo.expiration;
this.canonicalizedExtensionHeaders = signatureInfo.canonicalizedExtensionHeaders;
this.canonicalizedResource = signatureInfo.canonicalizedResource;
this.signatureVersion = signatureInfo.signatureVersion;
this.accountEmail = signatureInfo.accountEmail;
this.timestamp = signatureInfo.timestamp;
}

public Builder setContentMd5(String contentMd5) {
Expand All @@ -155,12 +274,37 @@ public Builder setCanonicalizedExtensionHeaders(
return this;
}

public Builder setSignatureVersion(Storage.SignUrlOption.SignatureVersion signatureVersion) {
this.signatureVersion = signatureVersion;

return this;
}

public Builder setAccountEmail(String accountEmail) {
this.accountEmail = accountEmail;

return this;
}

public Builder setTimestamp(long timestamp) {
this.timestamp = timestamp;

return this;
}

/** Creates an {@code SignatureInfo} object from this builder. */
public SignatureInfo build() {
checkArgument(httpVerb != null, "Required HTTP method");
checkArgument(canonicalizedResource != null, "Required canonicalized resource");
checkArgument(expiration >= 0, "Expiration must be greater than or equal to zero");

if (Storage.SignUrlOption.SignatureVersion.V4.equals(signatureVersion)) {
checkArgument(accountEmail != null, "Account email required to use V4 signing");
checkArgument(timestamp > 0, "Timestamp required to use V4 signing");
checkArgument(
expiration <= 604800000, "Expiration can't be longer than 7 days to use V4 signing");
}

return new SignatureInfo(this);
}
}
Expand Down
Loading

1 comment on commit 4e30cdc

@khakha010
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -0,0 +1,114 @@
// Assumed constant for all tests:
// - email: [email protected]
// - project: dummy-project-id
[
{
"description": "Simple GET",
"bucket": "test-bucket",
"object": "test-object",
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host&X-Goog-Signature=95e6a13d43a1d1962e667f17397f2b80ac9bdd1669210d5e08e0135df9dff4e56113485dbe429ca2266487b9d1796ebdee2d7cf682a6ef3bb9fbb4c351686fba90d7b621cf1c4eb1fdf126460dd25fa0837dfdde0a9fd98662ce60844c458448fb2b352c203d9969cb74efa4bdb742287744a4f2308afa4af0e0773f55e32e92973619249214b97283b2daa14195244444e33f938138d1e5f561088ce8011f4986dda33a556412594db7c12fc40e1ff3f1bedeb7a42f5bcda0b9567f17f65855f65071fabb88ea12371877f3f77f10e1466fff6ff6973b74a933322ff0949ce357e20abe96c3dd5cfab42c9c83e740a4d32b9e11e146f0eb3404d2e975896f74"
},

{
"description": "Simple PUT",
"bucket": "test-bucket",
"object": "test-object",
"method": "PUT",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host&X-Goog-Signature=8adff1d4285739e31aa68e73767a46bc5511fde377497dbe08481bf5ceb34e29cc9a59921748d8ec3dd4085b7e9b7772a952afedfcdaecb3ae8352275b8b7c867f204e3db85076220a3127a8a9589302fc1181eae13b9b7fe41109ec8cdc93c1e8bac2d7a0cc32a109ca02d06957211326563ab3d3e678a0ba296e298b5fc5e14593c99d444c94724cc4be97015dbff1dca377b508fa0cb7169195de98d0e4ac96c42b918d28c8d92d33e1bd125ce0fb3cd7ad2c45dae65c22628378f6584971b8bf3945b26f2611eb651e9b6a8648970c1ecf386bb71327b082e7296c4e1ee2fc0bdd8983da80af375c817fb1ad491d0bc22c0f51dba0d66e2cffbc90803e47"
},

{
"description": "POST for resumable uploads",
"bucket": "test-bucket",
"object": "test-object",
"method": "POST",
"expiration": 10,
"headers": {
"x-goog-resumable": [ "start" ]
},
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host%3Bx-goog-resumable&X-Goog-Signature=4a6d39b23343cedf4c30782aed4b384001828c79ffa3a080a481ea01a640dea0a0ceb58d67a12cef3b243c3f036bb3799c6ee88e8db3eaf7d0bdd4b70a228d0736e07eaa1ee076aff5c6ce09dff1f1f03a0d8ead0d2893408dd3604fdabff553aa6d7af2da67cdba6790006a70240f96717b98f1a6ccb24f00940749599be7ef72aaa5358db63ddd54b2de9e2d6d6a586eac4fe25f36d86fc6ab150418e9c6fa01b732cded226c6d62fc95b72473a4cc55a8257482583fe66d9ab6ede909eb41516a8690946c3e87b0f2052eb0e97e012a14b2f721c42e6e19b8a1cd5658ea36264f10b9b1ada66b8ed5bf7ed7d1708377ac6e5fe608ae361fb594d2e5b24c54"
},

{
"description": "Vary expiration and timestamp",
"bucket": "test-bucket",
"object": "test-object",
"method": "GET",
"expiration": 20,
"timestamp": "20190301T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190301%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190301T090000Z&X-Goog-Expires=20&X-Goog-SignedHeaders=host&X-Goog-Signature=9669ed5b10664dc594c758296580662912cf4bcc5a4ba0b6bf055bcbf6f34eed7bdad664f534962174a924741a0c273a4f67bc1847cef20192a6beab44223bd9d4fbbd749c407b79997598c30f82ddc269ff47ec09fa3afe74e00616d438df0d96a7d8ad0adacfad1dc3286f864d924fe919fb0dce45d3d975c5afe8e13af2db9cc37ba77835f92f7669b61e94c6d562196c1274529e76cfff1564cc2cad7d5387dc8e12f7a5dfd925685fe92c30b43709eee29fa2f66067472cee5423d1a3a4182fe8cea75c9329d181dc6acad7c393cd04f8bf5bc0515127d8ebd65d80c08e19ad03316053ea60033fd1b1fd85a69c576415da3bf0a3718d9ea6d03e0d66f0"
},

{
"description": "Vary bucket and object",
"bucket": "test-bucket2",
"object": "test-object2",
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket2/test-object2?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host&X-Goog-Signature=36e3d58dfd3ec1d2dd2f24b5ee372a71e811ffaa2162a2b871d26728d0354270bc116face87127532969c4a3967ed05b7309af741e19c7202f3167aa8c2ac420b61417d6451442bb91d7c822cd17be8783f01e05372769c88913561d27e6660dd8259f0081a71f831be6c50283626cbf04494ac10c394b29bb3bce74ab91548f58a37118a452693cf0483d77561fc9cac8f1765d2c724994cca46a83517a10157ee0347a233a2aaeae6e6ab5e204ff8fc5f54f90a3efdb8301d9fff5475d58cd05b181affd657f48203f4fb133c3a3d355b8eefbd10d5a0a5fd70d06e9515460ad74e22334b2cba4b29cae4f6f285cdb92d8f3126d7a1479ca3bdb69c207d860"
},

{
"description": "Simple headers",
"bucket": "test-bucket",
"object": "test-object",
"headers": {
"foo": [ "foo-value" ],
"BAR": [ "BAR-value" ]
},
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=bar%3Bfoo%3Bhost&X-Goog-Signature=68ecd3b008328ed30d91e2fe37444ed7b9b03f28ed4424555b5161980531ef87db1c3a5bc0265aad5640af30f96014c94fb2dba7479c41bfe1c020eb90c0c6d387d4dd09d4a5df8b60ea50eb6b01cdd786a1e37020f5f95eb8f9b6cd3f65a1f8a8a65c9fcb61ea662959efd9cd73b683f8d8804ef4d6d9b2852419b013368842731359d7f9e6d1139032ceca75d5e67cee5fd0192ea2125e5f2955d38d3d50cf116f3a52e6a62de77f6207f5b95aaa1d7d0f8a46de89ea72e7ea30f21286318d7eba0142232b0deb3a1dc9e1e812a981c66b5ffda3c6b01a8a9d113155792309fd53a3acfd054ca7776e8eec28c26480cd1e3c812f67f91d14217f39a606669d"
},

{
"description": "Headers should be trimmed",
"bucket": "test-bucket",
"object": "test-object",
"headers": {
"leading": [ " xyz" ],
"trailing": [ "abc " ],
"collapsed": [ "abc def" ]
},
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=collapsed%3Bhost%3Bleading%3Btrailing&X-Goog-Signature=1839511d6238d9ac2bbcbba8b23515b3757db35dfa7b8f9bc4b8b4aa270224df747c812526f1a3bcf294d67ed84cd14e074c36bc090e0a542782934a7c925af4a5ea68123e97533704ce8b08ccdf5fe6b412f89c9fc4de243e29abdb098382c5672188ee3f6fef7131413e252c78e7a35658825ad842a50609e9cc463731e17284ff7a14824c989f87cef22fb99dfec20cfeed69d8b3a08f00b43b8284eecd535e50e982b05cd74c5750cd5f986cfc21a2a05f7f3ab7fc31bd684ed1b823b64d29281e923fc6580c49005552ca19c253de087d9d2df881144e44eda40965cfdb4889bf3a35553c9809f4ed20b8355be481b92b9618952b6a04f3017b36053e15"
},

// Headers associated with customer-supplied encryption keys should not be included in the signature
{
"description": "Customer-supplied encryption key",
"bucket": "test-bucket",
"object": "test-object",
"headers":
{
"X-Goog-Encryption-Key": [ "ignored" ],
"X-Goog-Encryption-Key-Sha256": [ "ignored" ],
"X-Goog-Encryption-Algorithm": [ "ignored" ]
},
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/test-object?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host&X-Goog-Signature=95e6a13d43a1d1962e667f17397f2b80ac9bdd1669210d5e08e0135df9dff4e56113485dbe429ca2266487b9d1796ebdee2d7cf682a6ef3bb9fbb4c351686fba90d7b621cf1c4eb1fdf126460dd25fa0837dfdde0a9fd98662ce60844c458448fb2b352c203d9969cb74efa4bdb742287744a4f2308afa4af0e0773f55e32e92973619249214b97283b2daa14195244444e33f938138d1e5f561088ce8011f4986dda33a556412594db7c12fc40e1ff3f1bedeb7a42f5bcda0b9567f17f65855f65071fabb88ea12371877f3f77f10e1466fff6ff6973b74a933322ff0949ce357e20abe96c3dd5cfab42c9c83e740a4d32b9e11e146f0eb3404d2e975896f74"
},

{
"description": "List Objects",
"bucket": "test-bucket",
"object": "",
"method": "GET",
"expiration": 10,
"timestamp": "20190201T090000Z",
"expectedUrl": "https://storage.googleapis.com/test-bucket/?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com%2F20190201%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20190201T090000Z&X-Goog-Expires=10&X-Goog-SignedHeaders=host&X-Goog-Signature=2a1d342f11ddf0c90c669b9ba89ab5099f94049a86351cacbc85845fd5a8b31e1f9c8d484926c19fbd6930da6c8d3049ca8ebcfeefb7b02e53137755d36f97baab479414528b2802f10d94541facb888edf886d91ba124e60cb3801464f61aadc575fc921c99cf8c52e281f7bc0d3e740f529201c469c8e52775b6433687e0c0dca1c6b874614c3c3d09599be1e192c40ad6827416e387bf6e88a5f501f1d8225bce498d134599d0dfe30c9c833c244d3f90cf9595b9f8175658b788ee5c4a90b575fde5e83c645772250c7098373ca754b39d0fc1ebca2f50261a015931541c9827920eba67a1c41613853a1bd23299a1f9f5d583c0feb05ea2f792ba390d27"
}

]

Please sign in to comment.