Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public V4SignerTest(StorageFixture fixture)
public async Task GetTest() => await _fixture.FinishDelayTest(GetTestName());
private void GetTest_InitDelayTest() => GetTest_Common(_fixture, Signer);

[Fact]
[Fact(Skip = "Currently only works with lower-case x-goog headers; fix is on its way")]
public async Task GetBucketTest() => await _fixture.FinishDelayTest(GetTestName());
private void GetBucketTest_InitDelayTest() => GetBucketTest_Common(_fixture, Signer);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="UrlSignerV4TestData.json" />
<EmbeddedResource Include="UrlSignerV4TestAccount.json" />
<Compile Update="UrlSignerTest.*.cs">
<DependentUpon>UrlSignerTest.cs</DependentUpon>
</Compile>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@
// limitations under the License.

using Google.Api.Gax.Testing;
using Google.Apis.Auth.OAuth2;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using Xunit;

namespace Google.Cloud.Storage.V1.Tests
Expand All @@ -28,6 +33,13 @@ public partial class UrlSignerTest
/// </summary>
public class V4SignerTest
{
private static readonly GoogleCredential s_testCredential = GoogleCredential.FromJson(ReadTextResource("UrlSignerV4TestAccount.json"));
private static readonly Dictionary<string, HttpMethod> s_methods = new Dictionary<string, HttpMethod>
{
{ "GET", HttpMethod.Get },
{ "POST", HttpMethod.Post }
};

// The data in this test is from an example in the Ruby implementation.
[Fact]
public void SampleRequest()
Expand All @@ -45,12 +57,12 @@ public void SampleRequest()
var uriString = signer.Sign(bucket, obj, expiry, HttpMethod.Get);
var parameters = ExtractQueryParameters(uriString);

Assert.Equal("GOOG4-RSA-SHA256", parameters["x-goog-algorithm"]);
Assert.Equal("test-account%40spec-test-ruby-samples.iam.gserviceaccount.com%2F20181119%2Fauto%2Fgcs%2Fgoog4_request", parameters["x-goog-credential"]);
Assert.Equal("20181119T055654Z", parameters["x-goog-date"]);
Assert.Equal("3600", parameters["x-goog-expires"]);
Assert.Equal("host", parameters["x-goog-signedheaders"]);
Assert.Equal("GOOG4-RSA-SHA256", parameters["X-Goog-Algorithm"]);
Assert.Equal("test-account%40spec-test-ruby-samples.iam.gserviceaccount.com%2F20181119%2Fauto%2Fstorage%2Fgoog4_request", parameters["X-Goog-Credential"]);
Assert.Equal("20181119T055654Z", parameters["X-Goog-Date"]);
Assert.Equal("3600", parameters["X-Goog-Expires"]);
Assert.Equal("host", parameters["X-Goog-SignedHeaders"]);

// No check for the exact signature.
}

Expand All @@ -64,6 +76,70 @@ private static Dictionary<string, string> ExtractQueryParameters(string uriStrin
.Select(kvp => kvp.Split(new[] { '=' }, 2))
.ToDictionary(bits => bits[0], bits => bits[1]);
}

public static IEnumerable<object[]> JsonTestData = JsonConvert.DeserializeObject<List<JsonTest>>(ReadTextResource("UrlSignerV4TestData.json"))
.Select(test => new object[] { test })
.ToList();

[Theory, MemberData(nameof(JsonTestData))]
public void JsonSourceTest(JsonTest test)
{
var timestamp = DateTime.ParseExact(
test.Timestamp,
"yyyyMMdd'T'HHmmss'Z'",
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var clock = new FakeClock(timestamp);
var signer = UrlSigner.FromServiceAccountCredential((ServiceAccountCredential) s_testCredential.UnderlyingCredential)
.WithSigningVersion(SigningVersion.V4)
.WithClock(clock);

var actualUrl = signer.Sign(test.Bucket, test.Object,
duration: TimeSpan.FromSeconds(test.Expiration),
requestMethod: s_methods[test.Method],
requestHeaders: test.Headers?.ToDictionary(kvp => kvp.Key, kvp => (IEnumerable<string>) kvp.Value),
contentHeaders: null);
Assert.Equal(test.ExpectedUrl, actualUrl);
}

private static string ReadTextResource(string name)
{
var typeInfo = typeof(UrlSignerTest).GetTypeInfo();

using (var reader = new StreamReader(typeInfo.Assembly.GetManifestResourceStream($"{typeInfo.Namespace}.{name}")))
{
return reader.ReadToEnd();
}
}
}

public class JsonTest
{
[JsonProperty("description")]
public string Description { get; set; }

[JsonProperty("bucket")]
public string Bucket { get; set; }

[JsonProperty("object")]
public string Object { get; set; }

[JsonProperty("method")]
public string Method { get; set; }

[JsonProperty("expiration")]
public int Expiration { get; set; }

[JsonProperty("headers")]
public Dictionary<string, string[]> Headers { get; set; }

[JsonProperty("timestamp")]
public string Timestamp { get; set; }

[JsonProperty("expectedUrl")]
public string ExpectedUrl { get; set; }

public override string ToString() => Description;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// This is a dummy service account JSON file that is inactive. It's fine for it to be public.
{
"type": "service_account",
"project_id": "dummy-project-id",
"private_key_id": "ffffffffffffffffffffffffffffffffffffffff",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCsPzMirIottfQ2\nryjQmPWocSEeGo7f7Q4/tMQXHlXFzo93AGgU2t+clEj9L5loNhLVq+vk+qmnyDz5\nQ04y8jVWyMYzzGNNrGRW/yaYqnqlKZCy1O3bmnNjV7EDbC/jE1ZLBY0U3HaSHfn6\nS9ND8MXdgD0/ulRTWwq6vU8/w6i5tYsU7n2LLlQTl1fQ7/emO9nYcCFJezHZVa0H\nmeWsdHwWsok0skwQYQNIzP3JF9BpR5gJT2gNge6KopDesJeLoLzaX7cUnDn+CAnn\nLuLDwwSsIVKyVxhBFsFXPplgpaQRwmGzwEbf/Xpt9qo26w2UMgn30jsOaKlSeAX8\ncS6ViF+tAgMBAAECggEACKRuJCP8leEOhQziUx8Nmls8wmYqO4WJJLyk5xUMUC22\nSI4CauN1e0V8aQmxnIc0CDkFT7qc9xBmsMoF+yvobbeKrFApvlyzNyM7tEa/exh8\nDGD/IzjbZ8VfWhDcUTwn5QE9DCoon9m1sG+MBNlokB3OVOt8LieAAREdEBG43kJu\nyQTOkY9BGR2AY1FnAl2VZ/jhNDyrme3tp1sW1BJrawzR7Ujo8DzlVcS2geKA9at7\n55ua5GbHz3hfzFgjVXDfnkWzId6aHypUyqHrSn1SqGEbyXTaleKTc6Pgv0PgkJjG\nhZazWWdSuf1T5Xbs0OhAK9qraoAzT6cXXvMEvvPt6QKBgQDXcZKqJAOnGEU4b9+v\nOdoh+nssdrIOBNMu1m8mYbUVYS1aakc1iDGIIWNM3qAwbG+yNEIi2xi80a2RMw2T\n9RyCNB7yqCXXVKLBiwg9FbKMai6Vpk2bWIrzahM9on7AhCax/X2AeOp+UyYhFEy6\nUFG4aHb8THscL7b515ukSuKb5QKBgQDMq+9PuaB0eHsrmL6q4vHNi3MLgijGg/zu\nAXaPygSYAwYW8KglcuLZPvWrL6OG0+CrfmaWTLsyIZO4Uhdj7MLvX6yK7IMnagvk\nL3xjgxSklEHJAwi5wFeJ8ai/1MIuCn8p2re3CbwISKpvf7Sgs/W4196P4vKvTiAz\njcTiSYFIKQKBgCjMpkS4O0TakMlGTmsFnqyOneLmu4NyIHgfPb9cA4n/9DHKLKAT\noaWxBPgatOVWs7RgtyGYsk+XubHkpC6f3X0+15mGhFwJ+CSE6tN+l2iF9zp52vqP\nQwkjzm7+pdhZbmaIpcq9m1K+9lqPWJRz/3XXuqi+5xWIZ7NaxGvRjqaNAoGAdK2b\nutZ2y48XoI3uPFsuP+A8kJX+CtWZrlE1NtmS7tnicdd19AtfmTuUL6fz0FwfW4Su\nlQZfPT/5B339CaEiq/Xd1kDor+J7rvUHM2+5p+1A54gMRGCLRv92FQ4EON0RC1o9\nm2I4SHysdO3XmjmdXmfp4BsgAKJIJzutvtbqlakCgYB+Cb10z37NJJ+WgjDt+yT2\nyUNH17EAYgWXryfRgTyi2POHuJitd64Xzuy6oBVs3wVveYFM6PIKXlj8/DahYX5I\nR2WIzoCNLL3bEZ+nC6Jofpb4kspoAeRporj29SgesK6QBYWHWX2H645RkRGYGpDo\n51gjy9m/hSNqBbH2zmh04A==\n-----END PRIVATE KEY-----\n",
"client_email": "test-iam-credentials@dummy-project-id.iam.gserviceaccount.com",
"client_id": "000000000000000000000",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-iam-credentials%40dummy-project-id.iam.gserviceaccount.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Assumed constant for all tests:
// - email: test-iam-credentials@dummy-project-id.iam.gserviceaccount.com
// - project: dummy-project-id
// - algorithm: GOOG4-RSA-SHA256
[
{
"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"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public string Sign(
IClock clock)
{
var state = new SigningState(bucket, objectName, expiration, requestMethod, requestHeaders, contentHeaders, blobSigner);
var signature = blobSigner.CreateSignature(state.blobToSign);
var signature = blobSigner.CreateSignature(state._blobToSign);
return state.GetResult(signature);
}

Expand All @@ -56,19 +56,90 @@ public async Task<string> SignAsync(
CancellationToken cancellationToken)
{
var state = new SigningState(bucket, objectName, expiration, requestMethod, requestHeaders, contentHeaders, blobSigner);
var signature = await blobSigner.CreateSignatureAsync(state.blobToSign, cancellationToken).ConfigureAwait(false);
var signature = await blobSigner.CreateSignatureAsync(state._blobToSign, cancellationToken).ConfigureAwait(false);
return state.GetResult(signature);
}

private static SortedDictionary<string, StringBuilder> GetExtensionHeaders(
Dictionary<string, IEnumerable<string>> requestHeaders,
Dictionary<string, IEnumerable<string>> contentHeaders)
{
// These docs indicate how to include extension headers in the signature, but they're not exactly
// correct (values must be trimmed, newlines are replaced with empty strings, not whitespace, and
// values are concatenated with ", " instead of ",", but not when joining request and content headers).
// https://cloud.google.com/storage/docs/access-control/signed-urls#about-canonical-extension-headers
var extensionHeaders = new SortedDictionary<string, StringBuilder>();

if (requestHeaders != null)
{
PopulateExtensionHeaders(requestHeaders, extensionHeaders);
}

if (contentHeaders != null)
{
PopulateExtensionHeaders(
contentHeaders,
extensionHeaders,
keysToExcludeSpaceInNextValueSeparator: new HashSet<string>(extensionHeaders.Keys));
}

return extensionHeaders;
}

private static void PopulateExtensionHeaders(
Dictionary<string, IEnumerable<string>> headers,
SortedDictionary<string, StringBuilder> extensionHeaders,
HashSet<string> keysToExcludeSpaceInNextValueSeparator = null)
{
foreach (var header in headers)
{
var key = header.Key.ToLowerInvariant();
if (!key.StartsWith(GoogHeaderPrefix) ||
key == EncryptionKey.KeyHeader ||
key == EncryptionKey.KeyHashHeader)
{
continue;
}

StringBuilder values;
if (!extensionHeaders.TryGetValue(key, out values))
{
values = new StringBuilder();
extensionHeaders.Add(key, values);
}
else
{
if (keysToExcludeSpaceInNextValueSeparator == null ||
!keysToExcludeSpaceInNextValueSeparator.Remove(key))
{
values.Append(' ');
}
values.Append(',');
}

values.Append(string.Join(", ", header.Value.Select(PrepareHeaderValue)));
}
}

private static string GetFirstHeaderValue(Dictionary<string, IEnumerable<string>> contentHeaders, string name)
{
IEnumerable<string> values;
if (contentHeaders != null && contentHeaders.TryGetValue(name, out values))
{
return values.FirstOrDefault();
}
return null;
}

/// <summary>
/// State which needs to be carried between the "pre-signing" stage and "post-signing" stages
/// of the implementation.
/// </summary>
private struct SigningState
{
private string resourcePath;
private List<string> queryParameters;
internal byte[] blobToSign;
private string _resourcePath;
private List<string> _queryParameters;
internal byte[] _blobToSign;

internal SigningState(
string bucket,
Expand All @@ -93,10 +164,10 @@ internal SigningState(
}

string expiryUnixSeconds = ((int) (expiration - UnixEpoch).TotalSeconds).ToString(CultureInfo.InvariantCulture);
resourcePath = $"/{bucket}";
_resourcePath = $"/{bucket}";
if (objectName != null)
{
resourcePath += $"/{Uri.EscapeDataString(objectName)}";
_resourcePath += $"/{Uri.EscapeDataString(objectName)}";
}
var extensionHeaders = GetExtensionHeaders(requestHeaders, contentHeaders);
if (isResumableUpload)
Expand All @@ -116,19 +187,19 @@ internal SigningState(
};
signatureLines.AddRange(extensionHeaders.Select(
header => $"{header.Key}:{string.Join(", ", header.Value)}"));
signatureLines.Add(resourcePath);
blobToSign = Encoding.UTF8.GetBytes(string.Join("\n", signatureLines));
queryParameters = new List<string> { $"GoogleAccessId={blobSigner.Id}" };
signatureLines.Add(_resourcePath);
_blobToSign = Encoding.UTF8.GetBytes(string.Join("\n", signatureLines));
_queryParameters = new List<string> { $"GoogleAccessId={blobSigner.Id}" };
if (expiryUnixSeconds != null)
{
queryParameters.Add($"Expires={expiryUnixSeconds}");
_queryParameters.Add($"Expires={expiryUnixSeconds}");
}
}

internal string GetResult(string signature)
{
queryParameters.Add($"Signature={WebUtility.UrlEncode(signature)}");
return $"{StorageHost}{resourcePath}?{string.Join("&", queryParameters)}";
_queryParameters.Add($"Signature={WebUtility.UrlEncode(signature)}");
return $"{StorageHost}{_resourcePath}?{string.Join("&", _queryParameters)}";
}
}
}
Expand Down
Loading