2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Go】AmazonPayの電子署名(signature)の実装サンプル

Last updated at Posted at 2022-08-16

Go強化月間らしいので、助かる人がいるか不明ですが、きっと将来の誰かの助けになるはず...🚀!

結論

AmazonPay、GoのSDK開発してくれ...!
すぐできるやろ😇

AmazonPayのComplete Checkout Sessionの電子署名

このサンプルを良い感じに調整して、フロントのボタンのsignatureとか、Update Checkout Sessionのsignatureとか生成できるはずです

電子署名の説明は書かないです。警察からコメントが来るので😗

package main

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"io/ioutil"
	"net/http"
	"strings"
	"time"

	"github.com/pkg/errors"
)

type AmazonPayGateway struct {
}

const (
	AmazonPayAlgorithm = "AMZN-PAY-RSASSA-PSS"
)

type ChargeAmount struct {
	Amount       int    `json:"amount"`
	CurrencyCode string `json:"currencyCode"` // JPY
}

type StatusDetails struct {
	State                string `json:"state"` // Completed
	LastUpdatedTimestamp string `json:"lastUpdatedTimestamp"`
}

// パラメータはもっとありますので、適宜フィールドを追加してください
type CompleteCheckoutSessionResponse struct {
	CheckoutSessionId  string         `json:"checkoutSessionId"`
	StatusDetails      *StatusDetails `json:"statusDetails"`
	ChargePermissionId string         `json:"chargePermissionId"`
	ChargeId           string         `json:"chargeId"`
	CreationTimestamp  string         `json:"creationTimestamp"`
}

func (g *AmazonPayGateway) CompleteCheckoutSession(sessionID string) error {
	ca := &ChargeAmount{
		Amount:       10,
		CurrencyCode: "JPY",
	}
	payload := map[string]interface{}{
		"chargeAmount": ca,
	}

	urlPath := fmt.Sprintf("/%s/v2/checkoutSessions/%s/complete", "sandbox", sessionID)
	bodyContents, err := g.sendRequest(urlPath, "POST", payload)
	if err != nil {
		return errors.WithStack(err)
	}

	var parsedRes *CompleteCheckoutSessionResponse
	err = json.Unmarshal(bodyContents, &parsedRes)
	if err != nil {
		return errors.WithStack(err)
	}

	return nil
}

func (g *AmazonPayGateway) sendRequest(urlPath string, requestMethod string, payload interface{}) ([]byte, error) {
	jsonPayload, err := json.Marshal(payload)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	r, err := http.NewRequest(
		requestMethod,
		// ここのドメイン pay-api.amazon.jp と pay-api.amazon.com があって、日本では.jpのドメインになります
		// これに気づくのに数時間ハマりました
		fmt.Sprintf("https://pay-api.amazon.jp/%s", urlPath),
		strings.NewReader(string(jsonPayload)),
	)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	timestamp := time.Now().UTC().Format("20060102T150405Z")
	r.Header.Add("content-type", "application/json")
	r.Header.Add("x-amz-pay-date", timestamp)

    // httpリクエストにヘッダーの順番という概念はないと思いますが、AmazonPayはヘッダーの追加順番と
    // ↓signedHeadersの順番が一致してないとsignatureエラーになるので
    // signedHeaders := "x-amz-pay-date;content-type" にするとエラーになります
    // これに気づけずに、何週間も溶かしました
	signedHeaders := "content-type;x-amz-pay-date"
	signature, err := g.createDigitalSignForRequest(r.Header, jsonPayload, requestMethod, urlPath, signedHeaders)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	authorization := fmt.Sprintf("%s PublicKeyId=%s, SignedHeaders=%s, Signature=%s", AmazonPayAlgorithm, "ここにpublickKeyID", signedHeaders, signature)
	r.Header.Add("Authorization", authorization)

	client := &http.Client{}
	res, err := client.Do(r)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	bodyContents, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	return bodyContents, nil
}

func (g *AmazonPayGateway) createDigitalSignForRequest(header http.Header, jsonPayload []byte, requestMethod, urlPath, signedHeaders string) (string, error) {
    var headers []string
	for key, values := range header {
		headers = append(headers, fmt.Sprintf("%s:%s", strings.ToLower(key), values[0]))
	}
	sort.Strings(headers) // http.Headerからheaderをrangeで取ろうとすると順不同で取ってしまい署名に影響が出てしまうので、ここでソートしている

	hashedPayload := sha256.Sum256(jsonPayload)
	canonicalRequest := fmt.Sprintf("%s\n%s\n\n%s\n%s\n%x", requestMethod, urlPath, headers, signedHeaders, hashedPayload)

	privateKey, err := ReadRsaPrivateKey("ここにprivateKeyをbase64でエンコードしたstringを入れる")
	if err != nil {
		return "", errors.WithStack(err)
	}

	canonicalRequest = fmt.Sprintf("%s\n%x", AmazonPayAlgorithm, sha256.Sum256([]byte(canonicalRequest)))
	hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest))

	pssOptions := &rsa.PSSOptions{
		SaltLength: 20,            // ここは20じゃないとダメなので、constにしましょう
		Hash:       crypto.SHA256, // これはなくても動くかも(試してない)
	}
	signature, err := rsa.SignPSS(rand.Reader, privateKey, crypto.SHA256, hashedCanonicalRequest[:], pssOptions)
	if err != nil {
		return "", errors.WithStack(err)
	}

	return base64.StdEncoding.EncodeToString(signature), nil
}

func ReadRsaPrivateKey(privateKeyBase64 string) (*rsa.PrivateKey, error) {
	// pemファイルをgithubにpushするわけにいかないので、pemファイルをbase64にエンコードして、envファイルに保存しています
	data, err := base64.StdEncoding.DecodeString(privateKeyBase64)
	if err != nil {
		return nil, errors.New("invalid private key base64")
	}

	// ↓参考にしました https://increment.hatenablog.com/entry/2017/08/25/223915

	// errorを握り潰してるわけではなく、デコードに失敗した場合、privateKeyBlockがnilになって、第二引数にデータをそのまま返却してるので第二引数必要なし
	privateKeyBlock, _ := pem.Decode(data)
	if privateKeyBlock == nil {
		return nil, errors.Errorf("invalid private key data")
	}

	var privateKey *rsa.PrivateKey
	if privateKeyBlock.Type == "PRIVATE KEY" {
		keyInterface, err := x509.ParsePKCS8PrivateKey(privateKeyBlock.Bytes)
		if err != nil {
			return nil, err
		}

		var ok bool
		privateKey, ok = keyInterface.(*rsa.PrivateKey)
		if !ok {
			return nil, errors.Errorf("invalid RSA private key")
		}
	} else if privateKeyBlock.Type == "RSA PRIVATE KEY" {
		privateKey, err = x509.ParsePKCS1PrivateKey(privateKeyBlock.Bytes)
		if err != nil {
			return nil, err
		}
	} else {
		return nil, errors.Errorf("invalid RSA private key type")
	}

	privateKey.Precompute()

	if err := privateKey.Validate(); err != nil {
		return nil, err
	}

	return privateKey, nil
}

細かい説明しないので、読んで頑張ってほしい!

AmazonPayの 良くないところ ハマりどころ

  • 継続支払い都度支払いのドキュメントがそっくりすぎる割に、微妙にパラメータが違ったりする
  • 送料なし決済についてのドキュメント(APB支払い)が端っこにあって気づかない
    → AmazonPayのドキュメントで一番上にあるAPIのインテグレーションガイドは主に、送料があるサービス向けの説明で、我々のようなデジタルサービスの開発ではAPB支払いという方法が推奨されています!!!!!!!(って一番上に書いておけ!!!!!
  • .comのドメインと.jpのドメインの説明が端っこに書いてあって気付きにくい

他にもハマりどころありそうですが、一旦ね、一旦これだけ

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?