diff --git a/api/auth/signer/v4asdk2/stream.go b/api/auth/signer/v4asdk2/stream.go index 93988f9c..54a45ec1 100644 --- a/api/auth/signer/v4asdk2/stream.go +++ b/api/auth/signer/v4asdk2/stream.go @@ -1,4 +1,6 @@ // This file is adopting https://github.com/aws/aws-sdk-go-v2/blob/a2b751d1ba71f59175a41f9cae5f159f1044360f/aws/signer/v4/stream.go for sigv4a. +// with changes +// * add VerifyTrailerSignature package v4a @@ -88,6 +90,39 @@ func (s *StreamSigner) buildEventStreamStringToSign(headers, payload, previousSi }, "\n") } +func (s *StreamSigner) VerifyTrailerSignature(payload []byte, signingTime time.Time, signature []byte) error { + prevSignature := s.prevSignature + + st := v4Internal.NewSigningTime(signingTime) + + scope := buildCredentialScope(st, s.service) + + stringToSign := s.buildEventStreamStringToSignTrailer(payload, prevSignature, scope, &st) + + ok, err := signerCrypto.VerifySignature(&s.credentials.PrivateKey.PublicKey, makeHash(sha256.New(), []byte(stringToSign)), signature) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("v4a: invalid signature") + } + + s.prevSignature = signature + + return nil +} + +func (s *StreamSigner) buildEventStreamStringToSignTrailer(payload, previousSignature []byte, credentialScope string, signingTime *v4Internal.SigningTime) string { + hash := sha256.New() + return strings.Join([]string{ + "AWS4-ECDSA-P256-SHA256-TRAILER", + signingTime.TimeFormat(), + credentialScope, + hex.EncodeToString(previousSignature), + hex.EncodeToString(makeHash(hash, payload)), + }, "\n") +} + func buildCredentialScope(st v4Internal.SigningTime, service string) string { return strings.Join([]string{ st.Format(shortTimeFormat), diff --git a/api/handler/s3reader_test.go b/api/handler/s3reader_test.go index d0cb5759..22eeeaef 100644 --- a/api/handler/s3reader_test.go +++ b/api/handler/s3reader_test.go @@ -16,48 +16,94 @@ import ( "github.com/stretchr/testify/require" ) -func TestSigV4AStreaming(t *testing.T) { - accessKeyID := "2XEbqH4M3ym7a3E3esxfZ2gRLnMwDXrCN4y1SkQg5fHa09sThVmVL3EE6xeKsyMzaqu5jPi41YCaVbnwbwCTF3bx1" - secretKey := "00637f53f842573aaa06c2164c598973cd986880987111416cf71f1619def537" +func TestSigV4AChunkedReader(t *testing.T) { + t.Run("with trailers", func(t *testing.T) { + accessKeyID := "9uEm8zMrGWsEDWiPCnVuQLKTiGtCEXpYXt8eBG7agupw0JDySJZMFuej7PTcPzRqBUyPtFowNu1RtvHULU8XHjie6" + secretKey := "9f546428957ed7e189b7be928906ce7d1d9cb3042dd4d2d5194e28ce8c4c3b8e" - chunk1 := "Testing with the {sdk-java}" - reqBody := bytes.NewBufferString("1b;chunk-signature=3045022100b63692a1b20759bdabd342011823427a8952df75c93174d98ad043abca8052e002201695228a91ba986171b8d0ad20856d3d94ca3614d0a90a50a531ba8e52447b9b**\r\n") - _, err := reqBody.WriteString(chunk1) - require.NoError(t, err) - _, err = reqBody.WriteString("\r\n0;chunk-signature=30440220455885a2d4e9f705256ca6b0a5a22f7f784780ccbd1c0a371e5db3059c91745b022073259dd44746cbd63261d628a04d25be5a32a974c077c5c2d83c8157fb323b9f****\r\n\r\n") - require.NoError(t, err) + chunk1 := "Testing with the {sdk-java}" + body := "1b;chunk-signature=3045022100956ca03d2166100b455b532de542892f73925fbcea2f6498674a39a61bb4860902202977c1d47aea548d434540f89640ce97e605d18353cbbd75a619874f02e3dd22**\r\n" + + chunk1 + + "\r\n0;chunk-signature=304502210097dcc1721675469910ef8712fc2af0678eb90c12216dd6228c6b621fb6f805a0022047d27d21ae2af8a8172f2ef83c81ce9d4746aa88fc9ee0ca783eaa5e71aaef6c**\r\n" + + "x-amz-checksum-crc32:Np6zMg==\r\n" + + "x-amz-trailer-signature:304502200ecacd9aa2c432af5a2327c22a2ff9b32f44ab8559de00309219aef105eaaac102210092cbc0e78c4bcd56490a73da8ceed1934be80f3affeffb14d8c743fc292dda4f**\r\n\r\n" - req, err := http.NewRequest("PUT", "http://localhost:8084/test/tmp", reqBody) - require.NoError(t, err) + reqBody := bytes.NewBufferString(body) + req, err := http.NewRequest("PUT", "https://localhost:8184/test2/tmp", reqBody) + require.NoError(t, err) + req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32") - signature := "30440220574244c5ff5deba388c4e3b0541a42113179b6839b3e6b4212d255a118fa9089022056f7b9b72c93f67dbcd25fe9ca67950b5913fc00bb7a62bc276c21e828c0b6c7" - signingTime, err := time.Parse("20060102T150405Z", "20240904T133253Z") - require.NoError(t, err) + signature := "3045022100ddbc6ab11785d7f23d299de7db97379116f543377a44e38170a4e43b38b0d62b02201d8dca13c67f04f45491345152db4b704768eb8bb89b5215fd59bb4a4d9d7b61" + signingTime, err := time.Parse("20060102T150405Z", "20250203T144621Z") + require.NoError(t, err) - key, err := keys.NewPrivateKey() - require.NoError(t, err) + key, err := keys.NewPrivateKey() + require.NoError(t, err) - accessBox, err := newTestAccessBox(key) - require.NoError(t, err) - accessBox.Gate.SecretKey = secretKey + accessBox, err := newTestAccessBox(key) + require.NoError(t, err) + accessBox.Gate.SecretKey = secretKey - ctx := middleware.SetBox(req.Context(), &middleware.Box{ - AccessBox: accessBox, - AuthHeaders: &middleware.AuthHeader{ - AccessKeyID: accessKeyID, - SignatureV4: signature, - }, - ClientTime: signingTime, + ctx := middleware.SetBox(req.Context(), &middleware.Box{ + AccessBox: accessBox, + AuthHeaders: &middleware.AuthHeader{ + AccessKeyID: accessKeyID, + SignatureV4: signature, + }, + ClientTime: signingTime, + }) + req = req.WithContext(ctx) + + r, err := newSignV4aChunkedReader(req) + require.NoError(t, err) + + data, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, chunk1, string(data)) }) - req = req.WithContext(ctx) - r, err := newSignV4aChunkedReader(req) - require.NoError(t, err) + t.Run("without trailers", func(t *testing.T) { + accessKeyID := "2XEbqH4M3ym7a3E3esxfZ2gRLnMwDXrCN4y1SkQg5fHa09sThVmVL3EE6xeKsyMzaqu5jPi41YCaVbnwbwCTF3bx1" + secretKey := "00637f53f842573aaa06c2164c598973cd986880987111416cf71f1619def537" - data, err := io.ReadAll(r) - require.NoError(t, err) + chunk1 := "Testing with the {sdk-java}" + reqBody := bytes.NewBufferString("1b;chunk-signature=3045022100b63692a1b20759bdabd342011823427a8952df75c93174d98ad043abca8052e002201695228a91ba986171b8d0ad20856d3d94ca3614d0a90a50a531ba8e52447b9b**\r\n") + _, err := reqBody.WriteString(chunk1) + require.NoError(t, err) + _, err = reqBody.WriteString("\r\n0;chunk-signature=30440220455885a2d4e9f705256ca6b0a5a22f7f784780ccbd1c0a371e5db3059c91745b022073259dd44746cbd63261d628a04d25be5a32a974c077c5c2d83c8157fb323b9f****\r\n\r\n") + require.NoError(t, err) - require.Equal(t, chunk1, string(data)) + req, err := http.NewRequest("PUT", "http://localhost:8084/test/tmp", reqBody) + require.NoError(t, err) + + signature := "30440220574244c5ff5deba388c4e3b0541a42113179b6839b3e6b4212d255a118fa9089022056f7b9b72c93f67dbcd25fe9ca67950b5913fc00bb7a62bc276c21e828c0b6c7" + signingTime, err := time.Parse("20060102T150405Z", "20240904T133253Z") + require.NoError(t, err) + + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + accessBox, err := newTestAccessBox(key) + require.NoError(t, err) + accessBox.Gate.SecretKey = secretKey + + ctx := middleware.SetBox(req.Context(), &middleware.Box{ + AccessBox: accessBox, + AuthHeaders: &middleware.AuthHeader{ + AccessKeyID: accessKeyID, + SignatureV4: signature, + }, + ClientTime: signingTime, + }) + req = req.WithContext(ctx) + + r, err := newSignV4aChunkedReader(req) + require.NoError(t, err) + + data, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, chunk1, string(data)) + }) } func TestSigV4ChunkedReader(t *testing.T) { diff --git a/api/handler/s3v4aReader.go b/api/handler/s3v4aReader.go index 086f405e..b20afaab 100644 --- a/api/handler/s3v4aReader.go +++ b/api/handler/s3v4aReader.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "slices" + "strings" "time" v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" @@ -20,10 +22,12 @@ type ( reader *bufio.Reader streamSigner *v4a.StreamSigner - requestTime time.Time - buffer []byte - offset int - err error + trailerHeaders []string + trailers map[string]string + requestTime time.Time + buffer []byte + offset int + err error } ) @@ -87,21 +91,9 @@ func (c *s3v4aChunkReader) Read(buf []byte) (num int, err error) { c.err = errMalformedChunkedEncoding return num, c.err } - b, err := c.reader.ReadByte() - if err != nil { - return c.handleErr(num, err) - } - if b != '\r' { - c.err = errMalformedChunkedEncoding - return num, c.err - } - b, err = c.reader.ReadByte() - if err != nil { - return c.handleErr(num, err) - } - if b != '\n' { - c.err = errMalformedChunkedEncoding - return num, c.err + + if err = c.readCRLF(); err != nil { + return num, err } if cap(c.buffer) < size { @@ -119,19 +111,6 @@ func (c *s3v4aChunkReader) Read(buf []byte) (num int, err error) { c.err = err return num, c.err } - b, err = c.reader.ReadByte() - if b != '\r' || err != nil { - c.err = errMalformedChunkedEncoding - return num, c.err - } - b, err = c.reader.ReadByte() - if err != nil { - return c.handleErr(num, err) - } - if b != '\n' { - c.err = errMalformedChunkedEncoding - return num, c.err - } // Once we have read the entire chunk successfully, we verify // that the received signature is valid. @@ -150,10 +129,23 @@ func (c *s3v4aChunkReader) Read(buf []byte) (num int, err error) { // If the chunk size is zero we return io.EOF. As specified by AWS, // only the last chunk is zero-sized. if size == 0 { + if len(c.trailerHeaders) != 0 { + if err = c.readTrailers(); err != nil { + c.err = err + return num, c.err + } + } else if err = c.readCRLF(); err != nil { + return num, err + } + c.err = io.EOF return num, c.err } + if err = c.readCRLF(); err != nil { + return num, err + } + c.offset = copy(buf, c.buffer) num += c.offset return num, err @@ -168,10 +160,77 @@ func (c *s3v4aChunkReader) handleErr(num int, err error) (int, error) { return num, c.err } -func (c *s3v4aChunkReader) TrailerHeaders() map[string]string { +func (c *s3v4aChunkReader) readCRLF() error { + for _, ch := range [2]byte{'\r', '\n'} { + b, err := c.reader.ReadByte() + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + + if err != nil { + c.err = err + return c.err + } + if b != ch { + c.err = errMalformedChunkedEncoding + return c.err + } + } + return nil } +func (c *s3v4aChunkReader) readTrailers() error { + var k, v []byte + var err error + for err == nil { + k, err = c.reader.ReadBytes(':') + if err != nil { + if err == io.EOF { + break + } + c.err = errMalformedTrailerHeaders + return c.err + } + v, err = c.reader.ReadBytes('\n') + if err != nil && err != io.EOF { + c.err = errMalformedTrailerHeaders + return c.err + } + if len(v) >= 2 && v[len(v)-2] == '\r' { + v[len(v)-2] = '\n' + v = v[:len(v)-1] + } + + switch { + case slices.Contains(c.trailerHeaders, string(k[:len(k)-1])): + c.buffer = append(append(c.buffer, k...), v...) // todo use copy + case string(k) == "x-amz-trailer-signature:": + n, err := hex.Decode(v[:], bytes.TrimRight(v[:], "*\n")) + if err != nil { + c.err = errMalformedChunkedEncoding + return c.err + } + + if err = c.streamSigner.VerifyTrailerSignature(c.buffer, c.requestTime, v[:n]); err != nil { + c.err = fmt.Errorf("%w: %s", errs.GetAPIError(errs.ErrSignatureDoesNotMatch), err.Error()) + return c.err + } + default: + c.err = errMalformedTrailerHeaders + return c.err + } + + c.trailers[string(k[:len(k)-1])] = string(v[:len(v)-1]) + } + + return nil +} + +func (c *s3v4aChunkReader) TrailerHeaders() map[string]string { + return c.trailers +} + func newSignV4aChunkedReader(req *http.Request) (*s3v4aChunkReader, error) { box, err := middleware.GetBoxData(req.Context()) if err != nil { @@ -204,10 +263,18 @@ func newSignV4aChunkedReader(req *http.Request) (*s3v4aChunkReader, error) { newStreamSigner := v4a.NewStreamSigner(creds, "s3", seed) + var trailerHeaders []string + trailer := req.Header.Get("x-amz-trailer") + if trailer != "" { + trailerHeaders = strings.Split(trailer, ";") + } + return &s3v4aChunkReader{ - reader: bufio.NewReader(req.Body), - streamSigner: newStreamSigner, - requestTime: reqTime, - buffer: make([]byte, 64*1024), + reader: bufio.NewReader(req.Body), + streamSigner: newStreamSigner, + requestTime: reqTime, + buffer: make([]byte, 64*1024), + trailerHeaders: trailerHeaders, + trailers: make(map[string]string, len(trailerHeaders)), }, nil }