Skip to content

Commit

Permalink
sasl: add support for AWS_MSK_IAM
Browse files Browse the repository at this point in the history
This took a good bit to figure out from the Java source, but, I've
tested this against a scrappy msk cluster and it now works.
  • Loading branch information
twmb committed May 19, 2021
1 parent d2aa290 commit 30c4ba3
Showing 1 changed file with 240 additions and 0 deletions.
240 changes: 240 additions & 0 deletions pkg/sasl/aws/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Package aws provides AWS_MSK_IAM sasl authentication as specified in the

This comment has been minimized.

Copy link
@grsubramanian

grsubramanian May 22, 2021

I think it would be useful to have an example of how customers can wire in the IAM credentials so that it is discoverable by this SASL mechanism implementation.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

I can add an example, that shouldn't be too bad.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

e39e7af adds an example of using AWS_MSK_IAM

// Java source.
//
// The Java source can be found at https://github.com/aws/aws-msk-iam-auth.
package aws

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"

"github.com/twmb/franz-go/pkg/sasl"
)

// Auth contains an AWS AccessKey and SecretKey for authentication.
//
// This client may add fields to this struct in the future if Kafka adds more
// capabilities to MSK IAM.
type Auth struct {

This comment has been minimized.

Copy link
@grsubramanian

grsubramanian May 22, 2021

IAM role credentials have 3 components, the access key, secret key and session token, whereas IAM user credentials have only 2 components, the access key and secret key. From this struct definition, am I correct in inferring that you are not supporting IAM roles? If so, it would be great to add support for that, since it is quite common to use EC2 instance profile role credentials to authenticate.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

If I'm not mistaken, this should be pretty easy to support: If a (new) SessionToken field is non-empty, then I add v.Set("X-Amz-Security-Token", auth.SessionToken) in the parameter map below. Would you be willing to test this? I haven't used session tokens before and have destroyed my AWS environment.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

8900120 adds support for session tokens

// AccessKey is an AWS AccessKey.
AccessKey string

// AccessKey is an AWS SecretKey.
SecretKey string

_internal struct{} // require explicit field initalization
}

// AsManagedStreamingIAMMechanism returns a sasl mechanism that will use 'a' as
// credentials for all sasl sessions.
//
// This is a shortcut for using the ManagedStreamingIAM function and is useful
// when you do not need to live-rotate credentials.
func (a Auth) AsManagedStreamingIAMMechanism() sasl.Mechanism {
return ManagedStreamingIAM(func(context.Context) (Auth, error) {
return a, nil
})
}

type mskiam func(context.Context) (Auth, error)

// ManagedStreamingIAM returns a sasl mechanism that will call authFn whenever
// sasl authentication is needed. The returned Auth is used for a single
// session.
func ManagedStreamingIAM(authFn func(context.Context) (Auth, error)) sasl.Mechanism {
return mskiam(authFn)
}

func (mskiam) Name() string { return "AWS_MSK_IAM" }

func (fn mskiam) Authenticate(ctx context.Context, host string) (sasl.Session, []byte, error) {
auth, err := fn(ctx)
if err != nil {
return nil, nil, err
}

challenge, err := challenge(auth, host)
if err != nil {
return nil, nil, err
}

return new(session), challenge, nil
}

type session struct{}

func (session) Challenge(resp []byte) (bool, []byte, error) {
if len(resp) == 0 {
return false, nil, errors.New("empty challenge response: failed")
}
return true, nil, nil
}

const service = "kafka-cluster"

func challenge(auth Auth, host string) ([]byte, error) {
host, _, err := net.SplitHostPort(host) // we do not need the port
if err != nil {
return nil, err
}
region, err := identifyRegion(host)
if err != nil {
return nil, err
}

var (
timestamp = time.Now().UTC().Format("20060102T150405Z")
date = timestamp[:8] // 20060102
scope = scope(date, region)
v = make(url.Values)
)

v.Set("Action", service+":Connect")
v.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
v.Set("X-Amz-Credential", auth.AccessKey+"/"+scope)
v.Set("X-Amz-Date", timestamp)
v.Set("X-Amz-Expires", "900") // 1 min

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

(note: the commit is fixed in a later commit)

v.Set("X-Amz-SignedHeaders", "host")

qps := strings.Replace(v.Encode(), "+", "%20", -1)

canonicalRequest := task1(host, qps)
sts := task2(timestamp, scope, canonicalRequest)
signature := task3(auth.SecretKey, region, date, sts)

v.Set("X-Amz-Signature", signature) // task4

This comment has been minimized.

Copy link
@grsubramanian

grsubramanian May 23, 2021

It would also be very useful to add the user-agent key. It can help users authorize by user agent with the aws:UserAgent condition key.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

Sure, I can add this. I can default to a package constant, and allow overrides via an optional UserAgent override in the Auth struct. I'd have to dig in again into the Java source to find the default UA, unless you have a better suggestion.

This comment has been minimized.

Copy link
@sayantacC

sayantacC May 23, 2021

The user agent is normally a description of the client, the library used, its version etc.

The simplest default UA would be
"franz-go"

A slightly more default UA would include the version of franz-go:
"franz-go/<version of franz-go>"

You could even add more details to the default UA if they are available easily such as:
"franz-go/<version of franz-go>/<os and version>/<version of go>"

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

As it turns out, it's not possible to automagically get the tagged version, so I'd have to remember to update a constant every time I tag, which is prone to being forgotten. There's no way to get the version of Go a binary was built with. It'd be pretty easy to get the hostname, though, so that may be useful.

I'll go with franz-go/<hostname>

This comment has been minimized.

Copy link
@sayantacC

sayantacC May 23, 2021

Sounds good.

Just a minor note: If you are adding runtime information such as , would it be possible to use runtime.Version() to get the runtime version of go ? Is that problematic ?

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

Amazing, I looked in so many packages to try to find something like that. Yes, I can add runtime.Version() to the user agent, and I think I'll put it before the hostname, to result in franz-go/<go version>/<hostname>

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

8900120 adds a User-Agent


// According to the Java source and manual testing, all values in our
// challenge map must be lowercased, and we MUST have host, and we MUST
// have version, and version MUST be 2020_10_22.
keyvals := make(map[string]string)
for key, values := range v {
keyvals[strings.ToLower(key)] = values[0]
}
keyvals["host"] = host
keyvals["version"] = "2020_10_22"

marshaled, err := json.Marshal(keyvals)
if err != nil {
return nil, err
}
return marshaled, nil
}

// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
// "CredentialScope", Part 3
func scope(date, region string) string {
return strings.Join([]string{date, region, service, "aws4_request"}, "/")
}

// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
func task1(host, qps string) []byte {
// We start with our defined method, "GET", and the defined empty path,
// "/". For query parameters, we have to escape +'s with %20, but we did
// that above when building our URL.
//
// HTTPRequestMethod + '\n' +
// CanonicalURI + '\n' +
// CanonicalQueryString + '\n' +
canon := make([]byte, 0, 200)
canon = append(canon, "GET\n"...)
canon = append(canon, "/\n"...)
canon = append(canon, qps...)
canon = append(canon, '\n')

// We only sign one header, the host. Each signed header is followed by
// a newline, and then the canonical header block is followed itself by
// a newline.
//
// CanonicalHeaders + '\n' +
// SignedHeaders + '\n' +
canon = append(canon, "host:"...)
canon = append(canon, host...)
canon = append(canon, '\n')
canon = append(canon, '\n')
canon = append(canon, "host\n"...)

// Finally, we add our empty body.
//
// HexEncode(Hash(RequestPayload))
const emptyBody = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
return append(canon, emptyBody...)
}

// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
func task2(timestamp, scope string, canonicalRequest []byte) []byte {
toSign := make([]byte, 0, 512)
toSign = append(toSign, "AWS4-HMAC-SHA256\n"...)
toSign = append(toSign, timestamp...)
toSign = append(toSign, '\n')
toSign = append(toSign, scope...)
toSign = append(toSign, '\n')
canonHash := sha256.Sum256(canonicalRequest)
hexBuf := make([]byte, 64) // 32 bytes to 64
hex.Encode(hexBuf[:], canonHash[:])
toSign = append(toSign, hexBuf[:]...)
return toSign
}

var aws4requestBytes = []byte("aws4_request")

// https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
func task3(secretKey, region, date string, sts []byte) string {

This comment has been minimized.

Copy link
@grsubramanian

grsubramanian May 22, 2021

Not strictly necessary, but signing keys can be cached for a given date, region and service. The AWS4Signer class in Java does this out of the box, but seems like the Go version of AWS4Signer does not.

This comment has been minimized.

Copy link
@twmb

twmb May 23, 2021

Author Owner

Minus saving hmac calculations, is there much benefit to caching signing keys?

key := make([]byte, 0, 100)
key = append(key, "AWS4"...)
key = append(key, secretKey...)

h := hmac.New(sha256.New, key)
h.Write([]byte(date)) // kDate

key = h.Sum(key[:0])
h = hmac.New(sha256.New, key)
h.Write([]byte(region)) // kRegion

key = h.Sum(key[:0])
h = hmac.New(sha256.New, key)
h.Write([]byte(service)) // kService

key = h.Sum(key[:0])
h = hmac.New(sha256.New, key)
h.Write(aws4requestBytes) // kSigning

key = h.Sum(key[:0])
h = hmac.New(sha256.New, key)
h.Write(sts)

return hex.EncodeToString(h.Sum(key[:0]))
}

// aws-java-sdk-core/src/main/resources/com/amazonaws/partitions/endpoints.json
var suffixes = []string{
".amazonaws.com",
".amazonaws.com.cn",
".c2s.ic.gov",
".sc2s.sgov.gov",
}

// aws-java-sdk-core/src/main/java/com/amazonaws/partitions/PartitionMetadataProvider.java
// tryGetRegionByEndpointDnsSuffix
func identifyRegion(host string) (string, error) {
for _, suffix := range suffixes {
if strings.HasSuffix(host, suffix) {
serviceRegion := strings.TrimSuffix(host, suffix)
regionDot := strings.LastIndexByte(serviceRegion, '.')
if regionDot == -1 {
break
}
return serviceRegion[regionDot+1:], nil
}
}
return "", fmt.Errorf("cannot determine the region in %+q", host)
}

0 comments on commit 30c4ba3

Please sign in to comment.