From 4c37673abd39914c24731893c0b73bb069bf8d8a Mon Sep 17 00:00:00 2001 From: tombuildsstuff Date: Sun, 7 Jul 2019 11:16:34 +0200 Subject: [PATCH] Storage: adding an Authorizer for Storage via SharedKeyLite --- .../authorizers/authorizer_shared_key_lite.go | 90 ++++++ .../authorizer_shared_key_lite_test.go | 36 +++ azurerm/internal/authorizers/consts.go | 19 ++ azurerm/internal/authorizers/helpers.go | 120 ++++++++ azurerm/internal/authorizers/helpers_test.go | 271 ++++++++++++++++++ 5 files changed, 536 insertions(+) create mode 100644 azurerm/internal/authorizers/authorizer_shared_key_lite.go create mode 100644 azurerm/internal/authorizers/authorizer_shared_key_lite_test.go create mode 100644 azurerm/internal/authorizers/consts.go create mode 100644 azurerm/internal/authorizers/helpers.go create mode 100644 azurerm/internal/authorizers/helpers_test.go diff --git a/azurerm/internal/authorizers/authorizer_shared_key_lite.go b/azurerm/internal/authorizers/authorizer_shared_key_lite.go new file mode 100644 index 000000000000..ae960691de65 --- /dev/null +++ b/azurerm/internal/authorizers/authorizer_shared_key_lite.go @@ -0,0 +1,90 @@ +package authorizers + +import ( + "net/http" + "strings" + + "github.com/Azure/go-autorest/autorest" +) + +// TODO: switch to using the version from github.com/Azure/go-autorest +// once https://github.com/Azure/go-autorest/pull/416 has been merged + +// SharedKeyLiteAuthorizer implements an authorization for Shared Key Lite +// this can be used for interaction with Blob, File and Queue Storage Endpoints +type SharedKeyLiteAuthorizer struct { + storageAccountName string + storageAccountKey string +} + +// NewSharedKeyLiteAuthorizer crates a SharedKeyLiteAuthorizer using the given credentials +func NewSharedKeyLiteAuthorizer(accountName, accountKey string) *SharedKeyLiteAuthorizer { + return &SharedKeyLiteAuthorizer{ + storageAccountName: accountName, + storageAccountKey: accountKey, + } +} + +// WithAuthorization returns a PrepareDecorator that adds an HTTP Authorization header whose +// value is "SharedKeyLite " followed by the computed key. +// This can be used for the Blob, Queue, and File Services +// +// from: https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key +// You may use Shared Key Lite authorization to authorize a request made against the +// 2009-09-19 version and later of the Blob and Queue services, +// and version 2014-02-14 and later of the File services. +func (skl *SharedKeyLiteAuthorizer) WithAuthorization() autorest.PrepareDecorator { + return func(p autorest.Preparer) autorest.Preparer { + return autorest.PreparerFunc(func(r *http.Request) (*http.Request, error) { + r, err := p.Prepare(r) + if err != nil { + return r, err + } + + key, err := buildSharedKeyLite(skl.storageAccountName, skl.storageAccountKey, r) + if err != nil { + return r, err + } + + sharedKeyHeader := formatSharedKeyLiteAuthorizationHeader(skl.storageAccountName, *key) + return autorest.Prepare(r, autorest.WithHeader(HeaderAuthorization, sharedKeyHeader)) + }) + } +} +func buildSharedKeyLite(accountName, storageAccountKey string, r *http.Request) (*string, error) { + // first ensure the relevant headers are configured + prepareHeadersForRequest(r) + + sharedKey, err := computeSharedKeyLite(r.Method, r.URL.String(), accountName, r.Header) + if err != nil { + return nil, err + } + + // we then need to HMAC that value + hmacdValue := hmacValue(storageAccountKey, *sharedKey) + return &hmacdValue, nil +} + +// computeSharedKeyLite computes the Shared Key Lite required for Storage Authentication +// NOTE: this function assumes that the `x-ms-date` field is set +func computeSharedKeyLite(verb, url string, accountName string, headers http.Header) (*string, error) { + canonicalizedResource, err := buildCanonicalizedResource(url, accountName) + if err != nil { + return nil, err + } + + canonicalizedHeaders := buildCanonicalizedHeader(headers) + canonicalizedString := buildCanonicalizedStringForSharedKeyLite(verb, headers, canonicalizedHeaders, *canonicalizedResource) + return &canonicalizedString, nil +} + +func buildCanonicalizedStringForSharedKeyLite(verb string, headers http.Header, canonicalizedHeaders, canonicalizedResource string) string { + return strings.Join([]string{ + verb, + headers.Get(HeaderContentMD5), // TODO: this appears to always be empty? + headers.Get(HeaderContentType), + "", // date should be nil, apparently :shrug: + canonicalizedHeaders, + canonicalizedResource, + }, "\n") +} diff --git a/azurerm/internal/authorizers/authorizer_shared_key_lite_test.go b/azurerm/internal/authorizers/authorizer_shared_key_lite_test.go new file mode 100644 index 000000000000..4a0625f7bdb4 --- /dev/null +++ b/azurerm/internal/authorizers/authorizer_shared_key_lite_test.go @@ -0,0 +1,36 @@ +package authorizers + +import ( + "testing" +) + +func TestBuildCanonicalizedStringForSharedKeyLite(t *testing.T) { + testData := []struct { + name string + headers map[string][]string + canonicalizedHeaders string + canonicalizedResource string + verb string + expected string + }{ + { + name: "completed", + verb: "NOM", + headers: map[string][]string{ + "Content-MD5": {"abc123"}, + "Content-Type": {"vnd/panda-pops+v1"}, + }, + canonicalizedHeaders: "all-the-headers", + canonicalizedResource: "all-the-resources", + expected: "NOM\n\nvnd/panda-pops+v1\n\nall-the-headers\nall-the-resources", + }, + } + + for _, test := range testData { + t.Logf("Test: %q", test.name) + actual := buildCanonicalizedStringForSharedKeyLite(test.verb, test.headers, test.canonicalizedHeaders, test.canonicalizedResource) + if actual != test.expected { + t.Fatalf("Expected %q but got %q", test.expected, actual) + } + } +} diff --git a/azurerm/internal/authorizers/consts.go b/azurerm/internal/authorizers/consts.go new file mode 100644 index 000000000000..86e73314029b --- /dev/null +++ b/azurerm/internal/authorizers/consts.go @@ -0,0 +1,19 @@ +package authorizers + +var ( + HeaderAuthorization = "Authorization" + HeaderContentLength = "Content-Length" + HeaderContentEncoding = "Content-Encoding" + HeaderContentLanguage = "Content-Language" + HeaderContentType = "Content-Type" + HeaderContentMD5 = "Content-MD5" + HeaderIfModifiedSince = "If-Modified-Since" + HeaderIfMatch = "If-Match" + HeaderIfNoneMatch = "If-None-Match" + HeaderIfUnmodifiedSince = "If-Unmodified-Since" + HeaderMSDate = "X-Ms-Date" + HeaderRange = "Range" + + StorageEmulatorAccountName = "devstoreaccount1" + StorageEmulatorAccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" +) diff --git a/azurerm/internal/authorizers/helpers.go b/azurerm/internal/authorizers/helpers.go new file mode 100644 index 000000000000..bfb7a37dd3df --- /dev/null +++ b/azurerm/internal/authorizers/helpers.go @@ -0,0 +1,120 @@ +package authorizers + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// buildCanonicalizedHeader builds the Canonicalized Header required to sign Storage Requests +func buildCanonicalizedHeader(headers http.Header) string { + cm := make(map[string]string) + + for k, v := range headers { + headerName := strings.TrimSpace(strings.ToLower(k)) + if strings.HasPrefix(headerName, "x-ms-") { + cm[headerName] = v[0] + } + } + + if len(cm) == 0 { + return "" + } + + var keys []string + for key := range cm { + keys = append(keys, key) + } + + sort.Strings(keys) + + ch := bytes.NewBufferString("") + + for _, key := range keys { + ch.WriteString(key) + ch.WriteRune(':') + ch.WriteString(cm[key]) + ch.WriteRune('\n') + } + + return strings.TrimSuffix(string(ch.Bytes()), "\n") +} + +// buildCanonicalizedResource builds the Canonical Resource required for to sign Storage Account requests +func buildCanonicalizedResource(uri, accountName string) (*string, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + cr := bytes.NewBufferString("") + if accountName != StorageEmulatorAccountName { + cr.WriteString("/") + cr.WriteString(primaryStorageAccountName(accountName)) + } + + if len(u.Path) > 0 { + // Any portion of the CanonicalizedResource string that is derived from + // the resource's URI should be encoded exactly as it is in the URI. + // -- https://msdn.microsoft.com/en-gb/library/azure/dd179428.aspx + cr.WriteString(u.EscapedPath()) + } + + // TODO: replace this with less of a hack + if comp := u.Query().Get("comp"); comp != "" { + cr.WriteString(fmt.Sprintf("?comp=%s", comp)) + } + + out := string(cr.Bytes()) + return &out, nil +} + +func formatSharedKeyLiteAuthorizationHeader(accountName, key string) string { + canonicalizedAccountName := primaryStorageAccountName(accountName) + return fmt.Sprintf("SharedKeyLite %s:%s", canonicalizedAccountName, key) +} + +// hmacValue base-64 decodes the storageAccountKey, then signs the string with it +// as outlined here: https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key +func hmacValue(storageAccountKey, canonicalizedString string) string { + key, err := base64.StdEncoding.DecodeString(storageAccountKey) + if err != nil { + return "" + } + + encr := hmac.New(sha256.New, []byte(key)) + encr.Write([]byte(canonicalizedString)) + return base64.StdEncoding.EncodeToString(encr.Sum(nil)) +} + +// prepareHeadersForRequest prepares a request so that it can be signed +// by ensuring the `date` and `x-ms-date` headers are set +func prepareHeadersForRequest(r *http.Request) { + if r.Header == nil { + r.Header = http.Header{} + } + + date := time.Now().UTC().Format(http.TimeFormat) + + // a date must be set, X-Ms-Date should be used when both are set; but let's set both for completeness + r.Header.Set("date", date) + r.Header.Set("x-ms-date", date) +} + +// primaryStorageAccountName returns the name of the primary for a given Storage Account +func primaryStorageAccountName(input string) string { + // from https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key + // If you are accessing the secondary location in a storage account for which + // read-access geo-replication (RA-GRS) is enabled, do not include the + // -secondary designation in the authorization header. + // For authorization purposes, the account name is always the name of the primary location, + // even for secondary access. + return strings.TrimSuffix(input, "-secondary") +} diff --git a/azurerm/internal/authorizers/helpers_test.go b/azurerm/internal/authorizers/helpers_test.go new file mode 100644 index 000000000000..b182b7573915 --- /dev/null +++ b/azurerm/internal/authorizers/helpers_test.go @@ -0,0 +1,271 @@ +package authorizers + +import ( + "encoding/base64" + "net/http" + "testing" +) + +func TestBuildCanonicalizedHeader(t *testing.T) { + testData := []struct { + Input http.Header + Expected string + }{ + { + // no headers + Expected: "", + Input: map[string][]string{ + "": {""}, + }, + }, + { + // no x-ms headers + Expected: "", + Input: map[string][]string{ + "panda": {"pops"}, + }, + }, + { + // only a single x-ms header + Expected: "x-ms-panda:nom", + Input: map[string][]string{ + "x-ms-panda": {"nom"}, + }, + }, + { + // multiple x-ms headers + Expected: "x-ms-panda:nom\nx-ms-tiger:rawr", + Input: map[string][]string{ + "x-ms-panda": {"nom"}, + "x-ms-tiger": {"rawr"}, + }, + }, + { + // multiple x-ms headers, out of order + Expected: "x-ms-panda:nom\nx-ms-tiger:rawr", + Input: map[string][]string{ + "x-ms-tiger": {"rawr"}, + "x-ms-panda": {"nom"}, + }, + }, + { + // mixed headers (some ms, some non-ms) + Expected: "x-ms-panda:nom\nx-ms-tiger:rawr", + Input: map[string][]string{ + "x-ms-tiger": {"rawr"}, + "panda": {"pops"}, + "x-ms-panda": {"nom"}, + }, + }, + { + // casing + Expected: "x-ms-panda:nom\nx-ms-tiger:rawr", + Input: map[string][]string{ + "X-Ms-Tiger": {"rawr"}, + "X-Ms-Panda": {"nom"}, + }, + }, + } + + for _, v := range testData { + actual := buildCanonicalizedHeader(v.Input) + if actual != v.Expected { + t.Fatalf("Expected %q but got %q", v.Expected, actual) + } + } +} + +func TestBuildCanonicalizedResource(t *testing.T) { + testData := []struct { + name string + accountName string + uri string + expected string + expectError bool + }{ + { + name: "invalid uri", + accountName: "example", + uri: "://example.com", + expected: "", + expectError: true, + }, + { + name: "storage emulator doesn't get prefix", + accountName: StorageEmulatorAccountName, + uri: "http://www.example.com/foo", + expected: "/foo", + }, + { + name: "non storage emulator gets prefix", + accountName: StorageEmulatorAccountName + "test", + uri: "http://www.example.com/foo", + expected: "/" + StorageEmulatorAccountName + "test/foo", + }, + { + name: "uri encoding", + accountName: "example", + uri: "", + expected: "/example%3Chello%3E", + }, + { + name: "comp-arg", + accountName: "example", + uri: "/endpoint?first=true&comp=bar&second=false&third=panda", + expected: "/example/endpoint?comp=bar", + }, + { + name: "arguments", + accountName: "example", + uri: "/endpoint?first=true&second=false&third=panda", + expected: "/example/endpoint", + }, + } + + for _, test := range testData { + t.Logf("Test %q", test.name) + actual, err := buildCanonicalizedResource(test.uri, test.accountName) + if err != nil { + if test.expectError { + continue + } + + t.Fatalf("Error: %s", err) + } + + if *actual != test.expected { + t.Fatalf("Expected %q but got %q", test.expected, *actual) + } + } +} + +func TestFormatSharedKeyLiteAuthorizationHeader(t *testing.T) { + testData := []struct { + name string + accountName string + accountKey string + expected string + }{ + { + name: "primary", + accountName: "account1", + accountKey: "examplekey", + expected: "SharedKeyLite account1:examplekey", + }, + { + name: "secondary", + accountName: "account1-secondary", + accountKey: "examplekey", + expected: "SharedKeyLite account1:examplekey", + }, + } + + for _, test := range testData { + t.Logf("Test: %q", test.name) + actual := formatSharedKeyLiteAuthorizationHeader(test.accountName, test.accountKey) + + if actual != test.expected { + t.Fatalf("Expected %q but got %q", test.expected, actual) + } + } +} + +func TestHMAC(t *testing.T) { + testData := []struct { + Expected string + StorageAccountKey string + CanonicalizedString string + }{ + { + // When Storage Key isn't base-64 encoded + Expected: "", + StorageAccountKey: "bar", + CanonicalizedString: "foobarzoo", + }, + { + // Valid + Expected: "h5U0ATVX6SpbFX1H6GNuxIMeXXCILLoIvhflPtuQZ30=", + StorageAccountKey: base64.StdEncoding.EncodeToString([]byte("bar")), + CanonicalizedString: "foobarzoo", + }, + } + + for _, v := range testData { + actual := hmacValue(v.StorageAccountKey, v.CanonicalizedString) + if actual != v.Expected { + t.Fatalf("Expected %q but got %q", v.Expected, actual) + } + } +} + +func TestTestPrepareHeadersForRequest(t *testing.T) { + request, err := http.NewRequest("GET", "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + + headers := []string{ + "Date", + "X-Ms-Date", + } + + for _, header := range headers { + existingVal := request.Header.Get(header) + if existingVal != "" { + t.Fatalf("%q had a value prior to being set: %q", header, existingVal) + } + } + + prepareHeadersForRequest(request) + + for _, header := range headers { + updatedVal := request.Header.Get(header) + if updatedVal == "" { + t.Fatalf("%q didn't have a value after being set: %q", header, updatedVal) + } + } +} + +func TestPrepareHeadersForRequestWithNoneConfigured(t *testing.T) { + request, err := http.NewRequest("GET", "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + + request.Header = nil + prepareHeadersForRequest(request) + + if request.Header == nil { + t.Fatalf("Expected `request.Header` to not be nil, but it was!") + } +} + +func TestPrimaryStorageAccountName(t *testing.T) { + testData := []struct { + Expected string + Input string + }{ + { + // Empty + Expected: "", + Input: "", + }, + { + // Primary + Expected: "bar", + Input: "bar", + }, + { + // Secondary + Expected: "bar", + Input: "bar-secondary", + }, + } + + for _, v := range testData { + actual := primaryStorageAccountName(v.Input) + if actual != v.Expected { + t.Fatalf("Expected %q but got %q", v.Expected, actual) + } + } +}