From 8d36300f2726853872e825db07fed762514f145d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 17 Jan 2022 17:28:43 -0800 Subject: [PATCH] initial implementation - canonical request --- src/aws_signing.zig | 513 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 510 insertions(+), 3 deletions(-) diff --git a/src/aws_signing.zig b/src/aws_signing.zig index 9ec907f..f6a81c8 100644 --- a/src/aws_signing.zig +++ b/src/aws_signing.zig @@ -52,7 +52,7 @@ pub const Config = struct { // In the CRT, this is only used if the body has been precalculated. We don't have // this use case, and we'll ignore // .signed_body_value = c.aws_byte_cursor_from_c_str(""), - signed_body_header: enum { sha256, none } = .sha256, // https://github.com/awslabs/aws-c-auth/blob/ace1311f8ef6ea890b26dd376031bed2721648eb/include/aws/auth/signing_config.h#L131 + signed_body_header: SignatureType = .sha256, // https://github.com/awslabs/aws-c-auth/blob/ace1311f8ef6ea890b26dd376031bed2721648eb/include/aws/auth/signing_config.h#L131 // This is more complex in the CRT. We'll just take the creds. Someone // else can use a provider and get them in advance @@ -61,8 +61,11 @@ pub const Config = struct { // string, equal to the value specified here. If this value is zero or if header signing is being used then // this parameter has no effect. expiration_in_seconds: u64 = 0, + + flags: ConfigFlags = .{}, }; +pub const SignatureType = enum { sha256, none }; pub const SigningError = error{ NotImplemented, }; @@ -75,6 +78,510 @@ pub fn signRequest(allocator: std.mem.Allocator, http_request: base.Request, con } fn validateConfig(config: Config) SigningError!void { - _ = config; - return SigningError.NotImplemented; + if (config.signature_type != .headers or + config.signed_body_header != .sha256 or + config.expiration_in_seconds != 0 or + config.algorithm != .v4 or + !config.flags.omit_session_token or + !config.flags.should_normalize_uri_path or + !config.flags.use_double_uri_encode) + return SigningError.NotImplemented; +} + +fn createCanonicalRequest(allocator: std.mem.Allocator, request: base.Request, config: Config) ![]const u8 { + // CanonicalRequest = + // HTTPRequestMethod + '\n' + + // CanonicalURI + '\n' + + // CanonicalQueryString + '\n' + + // CanonicalHeaders + '\n' + + // SignedHeaders + '\n' + + // HexEncode(Hash(RequestPayload)) + const fmt = + \\{s} + \\{s} + \\{s} + \\{s} + \\{s} + \\{s} + ; + + // TODO: This is all better as a writer - less allocations/copying + const canonical_method = canonicalRequestMethod(request.method); + const canonical_url = try canonicalUri(allocator, request.path, config.flags.use_double_uri_encode); + defer allocator.free(canonical_url); + const canonical_query = try canonicalQueryString(allocator, request.path); + defer allocator.free(canonical_query); + const canonical_headers = try canonicalHeaders(allocator, request.headers); + defer allocator.free(canonical_headers.str); + defer allocator.free(canonical_headers.signed_headers); + const payload_hash = try hashPayload(allocator, request.body, config.signed_body_header); + defer allocator.free(payload_hash); + return try std.fmt.allocPrint(allocator, fmt, .{ + canonical_method, + canonical_url, + canonical_query, + canonical_headers.str, + canonical_headers.signed_headers, + payload_hash, + }); +} + +fn canonicalRequestMethod(method: []const u8) ![]const u8 { + return method; // We assume it's good +} + +fn canonicalUri(allocator: std.mem.Allocator, path: []const u8, double_encode: bool) ![]const u8 { + // Add the canonical URI parameter, followed by a newline character. The + // canonical URI is the URI-encoded version of the absolute path component + // of the URI, which is everything in the URI from the HTTP host to the + // question mark character ("?") that begins the query string parameters (if any). + // + // Normalize URI paths according to RFC 3986. Remove redundant and relative + // path components. Each path segment must be URI-encoded twice + // (except for Amazon S3 which only gets URI-encoded once). + // + // Note: In exception to this, you do not normalize URI paths for requests + // to Amazon S3. For example, if you have a bucket with an object + // named my-object//example//photo.user, use that path. Normalizing + // the path to my-object/example/photo.user will cause the request to + // fail. For more information, see Task 1: Create a Canonical Request in + // the Amazon Simple Storage Service API Reference. + // + // If the absolute path is empty, use a forward slash (/) + // + // For now, we will "Remove redundant and relative path components". This + // doesn't apply to S3 anyway, and we'll make it the callers's problem + if (!double_encode) + return error.S3NotImplemented; + if (path.len == 0 or path[0] == '?' or path[0] == '#') + return try allocator.dupe(u8, "/"); + const encoded_once = try encodeUri(allocator, path); + if (!double_encode) + return encoded_once[0 .. std.mem.lastIndexOf(u8, encoded_once, "?") orelse encoded_once.len]; + defer allocator.free(encoded_once); + const encoded_twice = try encodeUri(allocator, encoded_once); + return encoded_twice[0 .. std.mem.lastIndexOf(u8, encoded_twice, "?") orelse encoded_twice.len]; +} + +fn encodeParamPart(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + const unreserved_marks = "-_.!~*'()"; + var encoded = try std.ArrayList(u8).initCapacity(allocator, path.len); + defer encoded.deinit(); + for (path) |c| { + var should_encode = true; + for (unreserved_marks) |r| + if (r == c) { + should_encode = false; + break; + }; + if (should_encode and std.ascii.isAlNum(c)) + should_encode = false; + + if (!should_encode) { + try encoded.append(c); + continue; + } + // Whatever remains, encode it + try encoded.append('%'); + const hex = try std.fmt.allocPrint(allocator, "{s}", .{std.fmt.fmtSliceHexUpper(&[_]u8{c})}); + defer allocator.free(hex); + try encoded.appendSlice(hex); + } + return encoded.toOwnedSlice(); +} +fn encodeUri(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + const reserved_characters = ";,/?:@&=+$#"; + const unreserved_marks = "-_.!~*'()"; + var encoded = try std.ArrayList(u8).initCapacity(allocator, path.len); + defer encoded.deinit(); + for (path) |c| { + var should_encode = true; + for (reserved_characters) |r| + if (r == c) { + should_encode = false; + break; + }; + if (should_encode) { + for (unreserved_marks) |r| + if (r == c) { + should_encode = false; + break; + }; + } + if (should_encode and std.ascii.isAlNum(c)) + should_encode = false; + + if (!should_encode) { + try encoded.append(c); + continue; + } + // Whatever remains, encode it + try encoded.append('%'); + const hex = try std.fmt.allocPrint(allocator, "{s}", .{std.fmt.fmtSliceHexUpper(&[_]u8{c})}); + defer allocator.free(hex); + try encoded.appendSlice(hex); + } + return encoded.toOwnedSlice(); +} + +fn canonicalQueryString(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + // To construct the canonical query string, complete the following steps: + // + // Sort the parameter names by character code point in ascending order. + // Parameters with duplicate names should be sorted by value. For example, + // a parameter name that begins with the uppercase letter F precedes a + // parameter name that begins with a lowercase letter b. + // + // URI-encode each parameter name and value according to the following rules: + // + // Do not URI-encode any of the unreserved characters that RFC 3986 + // defines: A-Z, a-z, 0-9, hyphen ( - ), underscore ( _ ), period ( . ), and tilde ( ~ ). + // + // Percent-encode all other characters with %XY, where X and Y are + // hexadecimal characters (0-9 and uppercase A-F). For example, the + // space character must be encoded as %20 (not using '+', as some + // encoding schemes do) and extended UTF-8 characters must be in the + // form %XY%ZA%BC. + // + // Double-encode any equals ( = ) characters in parameter values. + // + // Build the canonical query string by starting with the first parameter + // name in the sorted list. + // + // For each parameter, append the URI-encoded parameter name, followed by + // the equals sign character (=), followed by the URI-encoded parameter + // value. Use an empty string for parameters that have no value. + // + // Append the ampersand character (&) after each parameter value, except + // for the last value in the list. + // + // One option for the query API is to put all request parameters in the query + // string. For example, you can do this for Amazon S3 to create a presigned + // URL. In that case, the canonical query string must include not only + // parameters for the request, but also the parameters used as part of the + // signing process—the hashing algorithm, credential scope, date, and signed + // headers parameters. + // + // The following example shows a query string that includes authentication + // information. The example is formatted with line breaks for readability, but + // the canonical query string must be one continuous line of text in your code. + const first_question = std.mem.indexOf(u8, path, "?"); + if (first_question == null) + return try allocator.dupe(u8, ""); + + // We have a query string + const query = path[first_question.? + 1 ..]; + + // Split this by component + var portions = std.mem.split(u8, query, "&"); + var sort_me = std.ArrayList([]const u8).init(allocator); + defer sort_me.deinit(); + while (portions.next()) |item| + try sort_me.append(item); + std.sort.sort([]const u8, sort_me.items, {}, lessThanBinary); + + var normalized = try std.ArrayList(u8).initCapacity(allocator, path.len); + defer normalized.deinit(); + var first = true; + for (sort_me.items) |i| { + if (!first) try normalized.append('&'); + first = false; + var first_equals = std.mem.indexOf(u8, i, "="); + if (first_equals == null) { + // Rare. This is "foo=" + const normed_item = try encodeUri(allocator, i); + defer allocator.free(normed_item); + try normalized.appendSlice(i); // This should be encoded + try normalized.append('='); + continue; + } + + // normal key=value stuff + const key = try encodeParamPart(allocator, i[0..first_equals.?]); + defer allocator.free(key); + + const value = try encodeParamPart(allocator, i[first_equals.? + 1 ..]); + defer allocator.free(value); + // Double-encode any = in the value. But not anything else? + const weird_equals_in_value_thing = try replace(allocator, value, "%3D", "%253D"); + defer allocator.free(weird_equals_in_value_thing); + try normalized.appendSlice(key); + try normalized.append('='); + try normalized.appendSlice(weird_equals_in_value_thing); + } + + return normalized.toOwnedSlice(); +} + +fn replace(allocator: std.mem.Allocator, haystack: []const u8, needle: []const u8, replacement_value: []const u8) ![]const u8 { + var buffer = try allocator.alloc(u8, std.mem.replacementSize(u8, haystack, needle, replacement_value)); + _ = std.mem.replace(u8, haystack, needle, replacement_value, buffer); + return buffer; +} + +fn lessThanBinary(context: void, lhs: []const u8, rhs: []const u8) bool { + _ = context; + return std.mem.lessThan(u8, lhs, rhs); +} +const CanonicalHeaders = struct { + str: []const u8, + signed_headers: []const u8, +}; +fn canonicalHeaders(allocator: std.mem.Allocator, headers: []base.Header) !CanonicalHeaders { + // + // Doc example. Original: + // + // Host:iam.amazonaws.com\n + // Content-Type:application/x-www-form-urlencoded; charset=utf-8\n + // My-header1: a b c \n + // X-Amz-Date:20150830T123600Z\n + // My-Header2: "a b c" \n + // + // Canonical form: + // content-type:application/x-www-form-urlencoded; charset=utf-8\n + // host:iam.amazonaws.com\n + // my-header1:a b c\n + // my-header2:"a b c"\n + // x-amz-date:20150830T123600Z\n + var dest = try allocator.alloc(base.Header, headers.len); + defer { + for (dest) |h| { + allocator.free(h.name); + allocator.free(h.value); + } + allocator.free(dest); + } + var total_len: usize = 0; + var total_name_len: usize = 0; + for (headers) |h, i| { + total_len += (h.name.len + h.value.len + 2); + total_name_len += (h.name.len + 1); + const value = try canonicalHeaderValue(allocator, h.value); + defer allocator.free(value); + dest[i] = .{ + .name = try std.ascii.allocLowerString(allocator, h.name), + .value = try std.fmt.allocPrint(allocator, "{s}", .{value}), + }; + } + + std.sort.sort(base.Header, dest, {}, lessThan); + + var dest_str = try std.ArrayList(u8).initCapacity(allocator, total_len); + defer dest_str.deinit(); + var signed_headers = try std.ArrayList(u8).initCapacity(allocator, total_name_len); + defer signed_headers.deinit(); + var first = true; + for (dest) |h| { + dest_str.appendSliceAssumeCapacity(h.name); + dest_str.appendAssumeCapacity(':'); + dest_str.appendSliceAssumeCapacity(h.value); + dest_str.appendAssumeCapacity('\n'); + + if (!first) signed_headers.appendAssumeCapacity(';'); + first = false; + signed_headers.appendSliceAssumeCapacity(h.name); + } + return CanonicalHeaders{ + .str = dest_str.toOwnedSlice(), + .signed_headers = signed_headers.toOwnedSlice(), + }; +} + +fn canonicalHeaderValue(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { + var started = false; + var in_quote = false; + var start: usize = 0; + const rc = try allocator.alloc(u8, value.len); + var rc_inx: usize = 0; + for (value) |c, i| { + if (!started and !std.ascii.isSpace(c)) { + started = true; + start = i; + } + if (started) { + if (!in_quote and i > 0 and std.ascii.isSpace(c) and std.ascii.isSpace(value[i - 1])) + continue; + // if (c == '"') in_quote = !in_quote; + rc[rc_inx] = c; + rc_inx += 1; + } + } + // Trim end + while (std.ascii.isSpace(rc[rc_inx - 1])) + rc_inx -= 1; + return rc[0..rc_inx]; +} +fn lessThan(context: void, lhs: base.Header, rhs: base.Header) bool { + _ = context; + return std.ascii.lessThanIgnoreCase(lhs.name, rhs.name); +} + +fn hashPayload(allocator: std.mem.Allocator, payload: []const u8, sig_type: SignatureType) ![]const u8 { + if (sig_type != .sha256) + return error.NotImplemented; + const to_hash = blk: { + if (payload.len > 0) { + break :blk payload; + } + break :blk ""; + }; + var out: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(to_hash, &out, .{}); + return try std.fmt.allocPrint(allocator, "{s}", .{std.fmt.fmtSliceHexLower(&out)}); +} +// SignedHeaders + '\n' + +// HexEncode(Hash(RequestPayload)) +test "canonical method" { + const actual = try canonicalRequestMethod("GET"); + try std.testing.expectEqualStrings("GET", actual); +} + +test "canonical uri" { + const allocator = std.testing.allocator; + const path = "/documents and settings/?foo=bar"; + const expected = "/documents%2520and%2520settings/"; + const actual = try canonicalUri(allocator, path, true); + defer allocator.free(actual); + try std.testing.expectEqualStrings(expected, actual); + + const slash = try canonicalUri(allocator, "", true); + defer allocator.free(slash); + try std.testing.expectEqualStrings("/", slash); +} +test "canonical query" { + const allocator = std.testing.allocator; + const path = "blahblahblah?foo=bar&zed=dead&qux&equals=x=y&Action=ListUsers&Version=2010-05-08"; + + // { + // // TODO: Remove block + // std.testing.log_level = .debug; + // _ = try std.io.getStdErr().write("\n"); + // } + const expected = "Action=ListUsers&Version=2010-05-08&equals=x%253Dy&foo=bar&qux=&zed=dead"; + const actual = try canonicalQueryString(allocator, path); + defer allocator.free(actual); + try std.testing.expectEqualStrings(expected, actual); +} +test "canonical headers" { + const allocator = std.testing.allocator; + var headers = try std.ArrayList(base.Header).initCapacity(allocator, 5); + defer headers.deinit(); + try headers.append(.{ .name = "Host", .value = "iam.amazonaws.com" }); + try headers.append(.{ .name = "Content-Type", .value = "application/x-www-form-urlencoded; charset=utf-8" }); + try headers.append(.{ .name = "My-header1", .value = " a b c " }); + try headers.append(.{ .name = "X-Amz-Date", .value = "20150830T123600Z" }); + try headers.append(.{ .name = "My-header2", .value = " \"a b c\" " }); + const expected = + \\content-type:application/x-www-form-urlencoded; charset=utf-8 + \\host:iam.amazonaws.com + \\my-header1:a b c + \\my-header2:"a b c" + \\x-amz-date:20150830T123600Z + \\ + ; + // { + // // TODO: Remove block + // std.testing.log_level = .debug; + // _ = try std.io.getStdErr().write("\n"); + // } + const actual = try canonicalHeaders(allocator, headers.items); + defer allocator.free(actual.str); + defer allocator.free(actual.signed_headers); + try std.testing.expectEqualStrings(expected, actual.str); + try std.testing.expectEqualStrings("content-type;host;my-header1;my-header2;x-amz-date", actual.signed_headers); +} + +test "canonical request" { + const allocator = std.testing.allocator; + var headers = try std.ArrayList(base.Header).initCapacity(allocator, 5); + defer headers.deinit(); + try headers.append(.{ .name = "Content-Type", .value = "application/x-www-form-urlencoded" }); + try headers.append(.{ .name = "Content-Length", .value = "43" }); + try headers.append(.{ .name = "User-Agent", .value = "zig-aws 1.0, Powered by the AWS Common Runtime." }); + try headers.append(.{ .name = "Host", .value = "sts.us-west-2.amazonaws.com" }); + try headers.append(.{ .name = "Accept", .value = "application/json" }); + const req = base.Request{ + .path = "/", + .query = "", + .body = "Action=GetCallerIdentity&Version=2011-06-15", + .method = "POST", + .content_type = "application/json", + .headers = headers.items, + }; + { + // TODO: Remove block + std.testing.log_level = .debug; + _ = try std.io.getStdErr().write("\n"); + } + const request = try createCanonicalRequest(allocator, req, .{ + .region = "us-west-2", // us-east-1 + .service = "sts", // service + .credentials = .{ + .access_key = "AKIDEXAMPLE", + .secret_key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + .session_token = null, + }, + .signing_time = 1642187728, // Should be 2015-08-30T12:36:00Z. Does zig stdlib have date parsing? + }); + defer allocator.free(request); + std.log.debug("canonical request:\n{s}", .{request}); + try std.testing.expect(request.len > 0); // TODO: improvify this + +} +test "can sign" { + // [debug] (aws): call: prefix sts, sigv4 sts, version 2011-06-15, action GetCallerIdentity + // [debug] (aws): proto: AwsProtocol.query + // [debug] (awshttp): host: sts.us-west-2.amazonaws.com, scheme: https, port: 443 + // [debug] (awshttp): Calling endpoint https://sts.us-west-2.amazonaws.com + // [debug] (awshttp): Path: / + // [debug] (awshttp): Query: + // [debug] (awshttp): Method: POST + // [debug] (awshttp): body length: 43 + // [debug] (awshttp): Body + // ==== + // Action=GetCallerIdentity&Version=2011-06-15 + // ==== + // [debug] (awshttp): All Request Headers: + // [debug] (awshttp): Accept: application/json + // [debug] (awshttp): Host: sts.us-west-2.amazonaws.com + // [debug] (awshttp): User-Agent: zig-aws 1.0, Powered by the AWS Common Runtime. + // [debug] (awshttp): Content-Type: application/x-www-form-urlencoded + // [debug] (awshttp): Content-Length: 43 + + const allocator = std.testing.allocator; + var headers = try std.ArrayList(base.Header).initCapacity(allocator, 5); + defer headers.deinit(); + try headers.append(.{ .name = "Content-Type", .value = "application/x-www-form-urlencoded" }); + try headers.append(.{ .name = "Content-Length", .value = "43" }); + try headers.append(.{ .name = "User-Agent", .value = "zig-aws 1.0, Powered by the AWS Common Runtime." }); + try headers.append(.{ .name = "Host", .value = "sts.us-west-2.amazonaws.com" }); + try headers.append(.{ .name = "Accept", .value = "application/json" }); + const req = base.Request{ + .path = "/", + .query = "", + .body = "Action=GetCallerIdentity&Version=2011-06-15", + .method = "POST", + .content_type = "application/json", + .headers = headers.items, + }; + { + // TODO: Remove block + std.testing.log_level = .debug; + _ = try std.io.getStdErr().write("\n"); + } + + // we could look at sigv4 signing tests at: + // https://github.com/awslabs/aws-c-auth/blob/ace1311f8ef6ea890b26dd376031bed2721648eb/tests/sigv4_signing_tests.c#L1478 + // + // for valid signatures. TODO: Get literally anything working first + try signRequest(allocator, req, .{ + .region = "us-west-2", // us-east-1 + .service = "sts", // service + .credentials = .{ + .access_key = "AKIDEXAMPLE", + .secret_key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + .session_token = null, + }, + .signing_time = 1642187728, // Should be 2015-08-30T12:36:00Z. Does zig stdlib have date parsing? + }); }