[#607] Support sigV4a streaming with trailers
All checks were successful
/ DCO (pull_request) Successful in 32s
/ Vulncheck (pull_request) Successful in 1m6s
/ Builds (pull_request) Successful in 1m34s
/ OCI image (pull_request) Successful in 2m2s
/ Lint (pull_request) Successful in 2m10s
/ Tests (pull_request) Successful in 1m17s
/ Vulncheck (push) Successful in 1m6s
/ Builds (push) Successful in 1m6s
/ Lint (push) Successful in 1m57s
/ Tests (push) Successful in 1m20s
/ OCI image (push) Successful in 2m10s

Signed-off-by: Denis Kirillov <d.kirillov@yadro.com>
This commit is contained in:
Denis Kirillov 2025-02-03 18:24:23 +03:00
parent 5e9c562683
commit a53e50b324
3 changed files with 217 additions and 69 deletions

View file

@ -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),

View file

@ -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) {

View file

@ -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
}