aws / aws-sdk-go

AWS SDK for the Go programming language.
http://aws.amazon.com/sdk-for-go/
Apache License 2.0
8.64k stars 2.07k forks source link

Unable to generate pre-signed for AWS IoT Websocket with STS credential #2485

Closed nqbao closed 5 years ago

nqbao commented 5 years ago

Please fill out the sections below to help us address your issue.

Version of AWS SDK for Go?

v1.17.9

Version of Go (go version)?

go version go1.11 darwin/amd64

What issue did you see?

I tried to use v4 to PreSign an AWS IoT Websocket URL with STS credentials. I always get bad status connection when I try to connect to the pre-signed URL.

Steps to reproduce

This code works with regular credential. I'm also 100% sure that the Assumed Role has the correct permission because I have tested it with https://github.com/aws/aws-iot-device-sdk-js before. It just does not work with Go.

func getWssUrl() (*url.URL, error) {
    region := "us-west-2"
    roleName := "bao-test"
    roleArn := "REDACTED"
    endpoint := "REDACTED"

    sess := session.Must(session.NewSession())

    cli := sts.New(sess, aws.NewConfig().WithRegion(region))

    s, err := cli.AssumeRole(&sts.AssumeRoleInput{
        RoleArn:         aws.String(roleArn),
        RoleSessionName: aws.String(roleName),
    })

    if err != nil {
        panic(err)
    }

    fmt.Printf("%v\n", s)

    cred := credentials.NewChainCredentials(
        []credentials.Provider{
            &credentials.StaticProvider{
                Value: credentials.Value{
                    AccessKeyID:     *s.Credentials.AccessKeyId,
                    SecretAccessKey: *s.Credentials.SecretAccessKey,
                    SessionToken:    *s.Credentials.SessionToken,
                },
            },
            // &credentials.EnvProvider{},
            // &credentials.SharedCredentialsProvider{},
        },
    )

    signer := v4.NewSigner(cred)
    wsUrl, err := url.Parse(endpoint)
    if err != nil {
        return nil, err
    }

    r := &http.Request{
        Method: "GET",
        URL:    wsUrl,
    }
    m, err := signer.Presign(r, nil, "iotdevicegateway", region, 900*time.Second, time.Now())
    if err != nil {
        return nil, err
    }

    fmt.Printf("%v\n%v\n", wsUrl, m)

    return wsUrl, nil
}
nqbao commented 5 years ago

If I use this snippet, it works

package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "fmt"
    "net/url"
    "strings"
    "time"
)

func AwsIotWsUrl(accessKey string, secretKey string, sessionToken string, region string, endpoint string) string {
    host := fmt.Sprintf("%s.iot.%s.amazonaws.com", endpoint, region)

    // according to docs, time must be within 5min of actual time (or at least according to AWS servers)
    now := time.Now().UTC()

    dateLong := now.Format("20060102T150405Z")
    dateShort := dateLong[:8]
    serviceName := "iotdevicegateway"
    scope := fmt.Sprintf("%s/%s/%s/aws4_request", dateShort, region, serviceName)
    alg := "AWS4-HMAC-SHA256"
    q := [][2]string{
        {"X-Amz-Algorithm", alg},
        {"X-Amz-Credential", accessKey + "/" + scope},
        {"X-Amz-Date", dateLong},
        {"X-Amz-SignedHeaders", "host"},
    }

    query := awsQueryParams(q)

    signKey := awsSignKey(secretKey, dateShort, region, serviceName)
    stringToSign := awsSignString(accessKey, secretKey, query, host, dateLong, alg, scope)
    signature := fmt.Sprintf("%x", awsHmac(signKey, []byte(stringToSign)))

    wsurl := fmt.Sprintf("wss://%s/mqtt?%s&X-Amz-Signature=%s", host, query, signature)

    if sessionToken != "" {
        wsurl = fmt.Sprintf("%s&X-Amz-Security-Token=%s", wsurl, url.QueryEscape(sessionToken))
    }

    return wsurl
}

func awsQueryParams(q [][2]string) string {
    var buff bytes.Buffer
    var i int
    for _, param := range q {
        if i != 0 {
            buff.WriteRune('&')
        }
        i++
        buff.WriteString(param[0])
        buff.WriteRune('=')
        buff.WriteString(url.QueryEscape(param[1]))
    }
    return buff.String()
}

func awsSignString(accessKey string, secretKey string, query string, host string, dateLongStr string, alg string, scopeStr string) string {
    emptyStringHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    req := strings.Join([]string{
        "GET",
        "/mqtt",
        query,
        "host:" + host,
        "", // separator
        "host",
        emptyStringHash,
    }, "\n")
    return strings.Join([]string{
        alg,
        dateLongStr,
        scopeStr,
        awsSha(req),
    }, "\n")
}

func awsHmac(key []byte, data []byte) []byte {
    h := hmac.New(sha256.New, key)
    h.Write(data)
    return h.Sum(nil)
}

func awsSignKey(secretKey string, dateShort string, region string, serviceName string) []byte {
    h := awsHmac([]byte("AWS4"+secretKey), []byte(dateShort))
    h = awsHmac(h, []byte(region))
    h = awsHmac(h, []byte(serviceName))
    h = awsHmac(h, []byte("aws4_request"))
    return h
}

func awsSha(in string) string {
    h := sha256.New()
    fmt.Fprintf(h, "%s", in)
    return fmt.Sprintf("%x", h.Sum(nil))
}
diehlaws commented 5 years ago

Hey @nqbao, thanks for reaching out to us. The SigV4 signing process for IoT Websocket URLs when using temporary session credentials requires appending the session token (X-Amz-Security-Token) to the end of the URL after the canonical request is signed. By contrast, the standard SigV4 signing process implemented in the non-IoT AWS SDK's signers requires including the session token in the canonical request before the request is signed, so these signers won't work for Websocket URLs when using temporary session credentials. Instead you'll need to perform the SigV4 signing manually as shown in your second snippet or use one of the AWS IoT Device SDKs to pre-sign Websocket URLs using credentials that require a session token.

nqbao commented 5 years ago

Alright, thank you. I will use the custom sign method.

joeshaw commented 1 year ago

I ran into this and found a simpler way to get things to work with the v4.Signer. The key is to not provide the session token with your credentials, and add it to the URL after you've presigned your request.

To tweak your original code:


cred := credentials.NewChainCredentials(
        []credentials.Provider{
            &credentials.StaticProvider{
                Value: credentials.Value{
                    AccessKeyID:     *s.Credentials.AccessKeyId,
                    SecretAccessKey: *s.Credentials.SecretAccessKey,
                    // NOTE: we're not setting SessionToken here
                },
            },
            // &credentials.EnvProvider{},
            // &credentials.SharedCredentialsProvider{},
        },
    )

    signer := v4.NewSigner(cred)
    wsUrl, err := url.Parse(endpoint)
    if err != nil {
        return nil, err
    }

    r := &http.Request{
        Method: "GET",
        URL:    wsUrl,
    }
    m, err := signer.Presign(r, nil, "iotdevicegateway", region, 900*time.Second, time.Now())
    if err != nil {
        return nil, err
    }

    q := r.URL.Query()
    q.Set("X-Amz-Security-Token", *s.Credentials.SessionToken)
    r.URL.RawQuery = q.Encode()

    fmt.Printf("%v\n%v\n", r.URL.String(), m)

    return r.URL.String(), nil