Skip to content

Commit

Permalink
Introduce data-driven V4 URL signing tests
Browse files Browse the repository at this point in the history
Initially the test description is in our repo, but is likely to be somewhere common in the future.
  • Loading branch information
jskeet committed Feb 26, 2019
1 parent 6b56185 commit 1163f27
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 16 deletions.
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 => Enumerable.Repeat(kvp.Value, 1)),
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,18 @@
// 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,
"headers": {
"host": "storage.googleapis.com"
},
"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 @@ -30,6 +30,11 @@ public sealed partial class UrlSigner
{
private sealed class V4Signer : ISigner
{
private const string ScopeSuffix = "storage/goog4_request";
private const string DefaultRegion = "auto";
private const string HostHeaderValue = "storage.googleapis.com";
private const string Algorithm = "GOOG4-RSA-SHA256";

// Note: It's irritating to have to convert from base64 to bytes and then to hex, but we can't change the IBlobSigner implementation
// and ServiceAccountCredential.CreateSignature returns base64 anyway.

Expand Down Expand Up @@ -104,17 +109,17 @@ internal SigningState(
var now = clock.GetCurrentDateTimeUtc();
var timestamp = now.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
var datestamp = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// TODO: Validate again maximum expirary duration
// TODO: Validate against maximum expiry duration
int expirySeconds = (int) (expiration - now).TotalSeconds;
string expiryText = expirySeconds.ToString(CultureInfo.InvariantCulture);

string clientEmail = blobSigner.Id;
string credentialScope = $"{datestamp}/auto/gcs/goog4_request";
string credentialScope = $"{datestamp}/{DefaultRegion}/{ScopeSuffix}";
string credential = WebUtility.UrlEncode($"{blobSigner.Id}/{credentialScope}");

// FIXME: Use requestHeaders and contentHeaders
var headers = new SortedDictionary<string, string>();
headers["host"] = "storage.googleapis.com";
headers["host"] = HostHeaderValue;

var canonicalHeaderBuilder = new StringBuilder();
foreach (var pair in headers)
Expand All @@ -127,11 +132,11 @@ internal SigningState(

queryParameters = new List<string>
{
"x-goog-algorithm=GOOG4-RSA-SHA256",
$"x-goog-credential={credential}",
$"x-goog-date={timestamp}",
$"x-goog-expires={expirySeconds}",
$"x-goog-signedheaders={signedHeaders}"
$"X-Goog-Algorithm={Algorithm}",
$"X-Goog-Credential={credential}",
$"X-Goog-Date={timestamp}",
$"X-Goog-Expires={expirySeconds}",
$"X-Goog-SignedHeaders={signedHeaders}"
};
if (isResumableUpload)
{
Expand All @@ -152,12 +157,12 @@ internal SigningState(
hashHex = FormatHex(sha256.ComputeHash(Encoding.UTF8.GetBytes(canonicalRequest)));
}

blobToSign = Encoding.UTF8.GetBytes($"GOOG4-RSA-SHA256\n{timestamp}\n{credentialScope}\n{hashHex}");
blobToSign = Encoding.UTF8.GetBytes($"{Algorithm}\n{timestamp}\n{credentialScope}\n{hashHex}");
}

internal string GetResult(string signature)
{
queryParameters.Add($"x-goog-signature={WebUtility.UrlEncode(signature)}");
queryParameters.Add($"X-Goog-Signature={WebUtility.UrlEncode(signature)}");
return $"{StorageHost}{resourcePath}?{string.Join("&", queryParameters)}";
}
}
Expand Down

0 comments on commit 1163f27

Please sign in to comment.