package handler import ( "bufio" "bytes" "context" "encoding/hex" "errors" "io" "net/http" "slices" "strings" "time" v4 "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4sdk2/signer/v4" errs "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "github.com/aws/aws-sdk-go-v2/aws" ) const ( chunkSignatureHeader = "chunk-signature=" maxChunkSize = 16 << 20 ) type ( s3ChunkReader struct { ctx context.Context reader *bufio.Reader streamSigner *v4.StreamSigner trailerHeaders []string trailers map[string]string requestTime time.Time buffer []byte offset int err error } ) var ( errGiantChunk = errors.New("chunk too big: choose chunk size <= 16MiB") errMalformedChunkedEncoding = errors.New("malformed chunked encoding") errMalformedTrailerHeaders = errors.New("malformed trailer headers") ) func (c *s3ChunkReader) Close() (err error) { return nil } func (c *s3ChunkReader) Read(buf []byte) (num int, err error) { if c.offset > 0 { num = copy(buf, c.buffer[c.offset:]) if num == len(buf) { c.offset += num return num, nil } c.offset = 0 buf = buf[num:] } if c.err != nil { return 0, c.err } var size int for { b, err := c.reader.ReadByte() if err == io.EOF { err = io.ErrUnexpectedEOF } if err != nil { c.err = err return num, c.err } if b == ';' { // separating character break } // Manually deserialize the size since AWS specified // the chunk size to be of variable width. In particular, // a size of 16 is encoded as `10` while a size of 64 KB // is `10000`. switch { case b >= '0' && b <= '9': size = size<<4 | int(b-'0') case b >= 'a' && b <= 'f': size = size<<4 | int(b-('a'-10)) case b >= 'A' && b <= 'F': size = size<<4 | int(b-('A'-10)) default: c.err = errMalformedChunkedEncoding return num, c.err } if size > maxChunkSize { c.err = errGiantChunk return num, c.err } } // Now, we read the signature of the following payload and expect: // chunk-signature=" + + "\r\n" // // The signature is 64 bytes long (hex-encoded SHA256 hash) and // starts with a 16 byte header: len("chunk-signature=") + 64 == 80. var signature [80]byte _, err = io.ReadFull(c.reader, signature[:]) if err == io.EOF { err = io.ErrUnexpectedEOF } if err != nil { c.err = err return num, c.err } if !bytes.HasPrefix(signature[:], []byte(chunkSignatureHeader)) { c.err = errMalformedChunkedEncoding return num, c.err } if err = c.readCRLF(); err != nil { return num, err } if cap(c.buffer) < size { c.buffer = make([]byte, size) } else { c.buffer = c.buffer[:size] } // Now, we read the payload and compute its SHA-256 hash. _, err = io.ReadFull(c.reader, c.buffer) if err == io.EOF && size != 0 { err = io.ErrUnexpectedEOF } if err != nil && err != io.EOF { c.err = err return num, c.err } // Once we have read the entire chunk successfully, we verify // that the received signature matches our computed signature. calculatedSignature, err := c.streamSigner.GetSignature(c.ctx, nil, c.buffer, c.requestTime) if err != nil { c.err = err return num, c.err } if string(signature[16:]) != hex.EncodeToString(calculatedSignature) { c.err = errs.GetAPIError(errs.ErrSignatureDoesNotMatch) return num, c.err } // 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 } func (c *s3ChunkReader) 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 *s3ChunkReader) 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:": calculatedSignature, err := c.streamSigner.GetTrailerSignature(c.buffer, c.requestTime) if err != nil { c.err = err return c.err } if string(v[:64]) != hex.EncodeToString(calculatedSignature) { c.err = errs.GetAPIError(errs.ErrSignatureDoesNotMatch) 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 *s3ChunkReader) TrailerHeaders() map[string]string { return c.trailers } func newSignV4ChunkedReader(req *http.Request) (*s3ChunkReader, error) { ctx := req.Context() box, err := middleware.GetBoxData(ctx) if err != nil { return nil, errs.GetAPIError(errs.ErrAuthorizationHeaderMalformed) } authHeaders, err := middleware.GetAuthHeaders(ctx) if err != nil { return nil, errs.GetAPIError(errs.ErrAuthorizationHeaderMalformed) } currentCredentials := aws.Credentials{AccessKeyID: authHeaders.AccessKeyID, SecretAccessKey: box.Gate.SecretKey} seed, err := hex.DecodeString(authHeaders.SignatureV4) if err != nil { return nil, errs.GetAPIError(errs.ErrSignatureDoesNotMatch) } reqTime, err := middleware.GetClientTime(ctx) if err != nil { return nil, errs.GetAPIError(errs.ErrMalformedDate) } newStreamSigner := v4.NewStreamSigner(currentCredentials, "s3", authHeaders.Region, seed) var trailerHeaders []string trailer := req.Header.Get("x-amz-trailer") if trailer != "" { trailerHeaders = strings.Split(trailer, ";") } return &s3ChunkReader{ ctx: ctx, reader: bufio.NewReader(req.Body), streamSigner: newStreamSigner, requestTime: reqTime, buffer: make([]byte, 64*1024), trailerHeaders: trailerHeaders, trailers: make(map[string]string, len(trailerHeaders)), }, nil }