aws-sdk-go-v2でGCSを叩いた際のSignatureDoesNotMatchに対処する

Google Cloud Storage は実は S3 互換の API が生えていて、既存の S3 を使うコードのオブジェクトの保存先を簡単に GCS に向けることができるようになっています。 理想的にはエンドポイントやトークンを差し替えれば移行できるのですが、他の S3 互換を謳うオブジェクトストレージと同じように単純にいかないことも多々あります。 最も一般的なエラーは S3 でサポートされているエンドポイントを互換オブジェクトストレージがサポートしていないという問題だと思いますが、 今回細かい部分の仕様の差異で AWS SDK で GCS のエンドポイントが叩けないという問題があったので、そのときにとった方法をメモしておきます。

雑な対処なので全く良い方法ではないですが。

起こったことと原因

aws-sdk-go-v2 で GCS に HMAC キーを使用してアクセスすると、正しいキーを指定しているにもかかわらず SignatureNoesNotMatch エラーが出ます。

S3 の API は Authorization ヘッダーに、他のヘッダーの値を使用して計算した signature を含めることになっています(AWS Signature v4)。

GCS の場合も S3 互換の API を叩いた場合はこれを検証していますが、(おそらく Google のロードバランサー的な何かで)Accept-Encoding ヘッダーが書き換えられるため、クライアントが計算した signature とサーバが計算した signature が一致せず、 エラーになってしまいます。

リクエスト

GET /XXXXXXXX?list-type=2 HTTP/1.1
Host: storage.googleapis.com
User-Agent: aws-sdk-go-v2/1.24.0 os/linux lang/go#1.21.5 md/GOOS#linux md/GOARCH#amd64 api/s3#1.47.7
Accept-Encoding: identity
Amz-Sdk-Invocation-Id: XXXXXXXX
Amz-Sdk-Request: attempt=1; max=3
Authorization: AWS4-HMAC-SHA256 Credential=XXXXXXXX/20240104//s3/aws4_request, SignedHeaders=accept-encoding;amz-sdk-invocation-id;host;x-amz-content-sha256;x-amz-date, Signature=XXXXXXXX
X-Amz-Content-Sha256: XXXXXXXX
X-Amz-Date: 20240104T105425Z

レスポンス

HTTP/2.0 403 Forbidden
Content-Length: 877
Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Cache-Control: private, max-age=0
Content-Type: application/xml; charset=UTF-8
Date: Thu, 04 Jan 2024 10:54:25 GMT
Expires: Thu, 04 Jan 2024 10:54:25 GMT
Server: UploadServer
X-Guploader-Uploadid: XXXXXXXX

<?xml version='1.0' encoding='UTF-8'?><Error><Code>SignatureDoesNotMatch</Code><Message>Access denied.</Message><Details>The request signature we calculated does not match the signature you provided. Check your Google secret key and signing method.</Details><StringToSign>AWS4-HMAC-SHA256
20240104T105425Z
20240104//s3/aws4_request
XXXXXXXX</StringToSign><CanonicalRequest>GET
/XXXXXXXX
list-type=2
accept-encoding:identity,gzip(gfe)
amz-sdk-invocation-id:XXXXXXXX
host:storage.googleapis.com
x-amz-content-sha256:XXXXXXXX
x-amz-date:20240104T105425Z

accept-encoding;amz-sdk-invocation-id;host;x-amz-content-sha256;x-amz-date
XXXXXXXX</CanonicalRequest></Error>

解決方法

Accept-Encoding を signature の計算に含めないようにしました。 そもそも対象のヘッダーは host, Content-Type, x-amz- で始まるものということになっていて、Accept-Encoding を含める必要はないはずです。

ただ、この方法は一筋縄では行かず、aws-sdk-go-v2 でデフォルトの HTTPSigner をラップする、カスタムの signer を実装することで対処しています。

import (
	"context"
	"net/http"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	v4Signer "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type noAcceptEncodingSigner struct {
	signer s3.HTTPSignerV4
}

func newNoAcceptEncodingSigner(signer s3.HTTPSignerV4) *noAcceptEncodingSigner {
	return &noAcceptEncodingSigner{
		signer: signer,
	}
}

func (self *noAcceptEncodingSigner) SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*v4Signer.SignerOptions)) error {
	// 署名前に Accept-Encoding を取り除き、署名後に復元する
	acceptEncoding := r.Header.Get("Accept-Encoding")
	r.Header.Del("Accept-Encoding")
	err := self.signer.SignHTTP(ctx, credentials, r, payloadHash, service, region, signingTime, optFns...)
	if acceptEncoding != "" {
		r.Header.Set("Accept-Encoding", acceptEncoding)
	}
	return err
}

これを使って S3 の client を作成します。

	s3Client := s3.NewFromConfig(config, func(options *s3.Options) {
		options.UsePathStyle = true
		defSigner := v4Signer.NewSigner(func(so *v4Signer.SignerOptions) {
			so.Logger = options.Logger
			so.LogSigning = options.ClientLogMode.IsSigning()
			so.DisableURIPathEscaping = true
		})
		options.HTTPSignerV4 = newNoAcceptEncodingSigner(defSigner)
	})

参考