diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 7db23671a4..bf7221a1bf 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -449,15 +449,18 @@ Consider requiring authentication in order to prevent unauthorized read access t #### AWS Signature -OPA will authenticate with an [AWS4 HMAC](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html) signature. Several methods of obtaining the -necessary credentials are available; exactly one must be specified to use the AWS signature -authentication method. +OPA will authenticate with an [AWS Version 4](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html) or version 4A signature. While version 4 is the default, version 4A must be used when making requests that might be handled by more than one region, such as an [S3 Multi-Region Access Point](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiRegionAccessPoints.html). You must use version 4A for this or requests will fail when routed to a different region than the one indicated in a version 4 signature. Furthermore, using version 4a also requires that temporary credentials are retrieved from a [regional AWS STS endpoint](https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html), rather than the global STS endpoint. + +Several methods of obtaining the necessary credentials are available; exactly one must be specified to use the AWS signature authentication method. The AWS service for which to sign the request can be specified in the `service` field. If omitted, the default is `s3`. -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `services[_].credentials.s3_signing.service` | `string` | No | The AWS service to sign requests with, eg `execute-api` or `s3`. Default: `s3` | +The AWS signature version to sign the request with can be specified in the `signature_version` field. If omitted, the default is `4`. The only other valid value is `4a`. + +| Field | Type | Required | Description | +|--------------------------------------------------------| --- | --- |--------------------------------------------------------------------------------| +| `services[_].credentials.s3_signing.service` | `string` | No | The AWS service to sign requests with, eg `execute-api` or `s3`. Default: `s3` | +| `services[_].credentials.s3_signing.signature_version` | `string` | No | The AWS signature version to sign requests with, eg `4` or `4a`. Default: `4` | ##### Using Static Environment Credentials If specifying `environment_credentials`, OPA will expect to find environment variables diff --git a/internal/providers/aws/NOTICE.txt b/internal/providers/aws/NOTICE.txt new file mode 100644 index 0000000000..5f14d1162e --- /dev/null +++ b/internal/providers/aws/NOTICE.txt @@ -0,0 +1,3 @@ +AWS SDK for Go +Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. +Copyright 2014-2015 Stripe, Inc. diff --git a/internal/providers/aws/crypto/compare.go b/internal/providers/aws/crypto/compare.go new file mode 100644 index 0000000000..1d0f25f8c2 --- /dev/null +++ b/internal/providers/aws/crypto/compare.go @@ -0,0 +1,30 @@ +package crypto + +import "fmt" + +// ConstantTimeByteCompare is a constant-time byte comparison of x and y. This function performs an absolute comparison +// if the two byte slices assuming they represent a big-endian number. +// +// error if len(x) != len(y) +// -1 if x < y +// 0 if x == y +// +1 if x > y +func ConstantTimeByteCompare(x, y []byte) (int, error) { + if len(x) != len(y) { + return 0, fmt.Errorf("slice lengths do not match") + } + + xLarger, yLarger := 0, 0 + + for i := 0; i < len(x); i++ { + xByte, yByte := int(x[i]), int(y[i]) + + x := ((yByte - xByte) >> 8) & 1 + y := ((xByte - yByte) >> 8) & 1 + + xLarger |= x &^ yLarger + yLarger |= y &^ xLarger + } + + return xLarger - yLarger, nil +} diff --git a/internal/providers/aws/crypto/compare_test.go b/internal/providers/aws/crypto/compare_test.go new file mode 100644 index 0000000000..1de09f36a1 --- /dev/null +++ b/internal/providers/aws/crypto/compare_test.go @@ -0,0 +1,52 @@ +package crypto + +import ( + "bytes" + "math/big" + "testing" +) + +func TestConstantTimeByteCompare(t *testing.T) { + cases := []struct { + x, y []byte + r int + expectErr bool + }{ + {x: []byte{}, y: []byte{}, r: 0}, + {x: []byte{40}, y: []byte{30}, r: 1}, + {x: []byte{30}, y: []byte{40}, r: -1}, + {x: []byte{60, 40, 30, 10, 20}, y: []byte{50, 30, 20, 0, 10}, r: 1}, + {x: []byte{50, 30, 20, 0, 10}, y: []byte{60, 40, 30, 10, 20}, r: -1}, + {x: nil, y: []byte{}, r: 0}, + {x: []byte{}, y: nil, r: 0}, + {x: []byte{}, y: []byte{10}, expectErr: true}, + {x: []byte{10}, y: []byte{}, expectErr: true}, + {x: []byte{10, 20}, y: []byte{10}, expectErr: true}, + } + + for _, tt := range cases { + compare, err := ConstantTimeByteCompare(tt.x, tt.y) + if (err != nil) != tt.expectErr { + t.Fatalf("expectErr=%v, got %v", tt.expectErr, err) + } + if e, a := tt.r, compare; e != a { + t.Errorf("expect %v, got %v", e, a) + } + } +} + +func BenchmarkConstantTimeCompare(b *testing.B) { + x, y := big.NewInt(1023), big.NewInt(1024) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ConstantTimeByteCompare(x.Bytes(), y.Bytes()) + } +} + +func BenchmarkCompare(b *testing.B) { + x, y := big.NewInt(1023).Bytes(), big.NewInt(1024).Bytes() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bytes.Compare(x, y) + } +} diff --git a/internal/providers/aws/crypto/ecc.go b/internal/providers/aws/crypto/ecc.go new file mode 100644 index 0000000000..758c73fcb3 --- /dev/null +++ b/internal/providers/aws/crypto/ecc.go @@ -0,0 +1,113 @@ +package crypto + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "encoding/asn1" + "encoding/binary" + "fmt" + "hash" + "math" + "math/big" +) + +type ecdsaSignature struct { + R, S *big.Int +} + +// ECDSAKey takes the given elliptic curve, and private key (d) byte slice +// and returns the private ECDSA key. +func ECDSAKey(curve elliptic.Curve, d []byte) *ecdsa.PrivateKey { + return ECDSAKeyFromPoint(curve, (&big.Int{}).SetBytes(d)) +} + +// ECDSAKeyFromPoint takes the given elliptic curve and point and returns the +// private and public keypair +func ECDSAKeyFromPoint(curve elliptic.Curve, d *big.Int) *ecdsa.PrivateKey { + pX, pY := curve.ScalarBaseMult(d.Bytes()) + + privKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: curve, + X: pX, + Y: pY, + }, + D: d, + } + + return privKey +} + +// ECDSAPublicKey takes the provide curve and (x, y) coordinates and returns +// *ecdsa.PublicKey. Returns an error if the given points are not on the curve. +func ECDSAPublicKey(curve elliptic.Curve, x, y []byte) (*ecdsa.PublicKey, error) { + xPoint := (&big.Int{}).SetBytes(x) + yPoint := (&big.Int{}).SetBytes(y) + + if !curve.IsOnCurve(xPoint, yPoint) { + return nil, fmt.Errorf("point(%v, %v) is not on the given curve", xPoint.String(), yPoint.String()) + } + + return &ecdsa.PublicKey{ + Curve: curve, + X: xPoint, + Y: yPoint, + }, nil +} + +// VerifySignature takes the provided public key, hash, and asn1 encoded signature and returns +// whether the given signature is valid. +func VerifySignature(key *ecdsa.PublicKey, hash []byte, signature []byte) (bool, error) { + var ecdsaSignature ecdsaSignature + + _, err := asn1.Unmarshal(signature, &ecdsaSignature) + if err != nil { + return false, err + } + + return ecdsa.Verify(key, hash, ecdsaSignature.R, ecdsaSignature.S), nil +} + +// HMACKeyDerivation provides an implementation of a NIST-800-108 of a KDF (Key Derivation Function) in Counter Mode. +// For the purposes of this implantation HMAC is used as the PRF (Pseudorandom function), where the value of +// `r` is defined as a 4 byte counter. +func HMACKeyDerivation(hash func() hash.Hash, bitLen int, key []byte, label, context []byte) ([]byte, error) { + // verify that we won't overflow the counter + n := int64(math.Ceil((float64(bitLen) / 8) / float64(hash().Size()))) + if n > 0x7FFFFFFF { + return nil, fmt.Errorf("unable to derive key of size %d using 32-bit counter", bitLen) + } + + // verify the requested bit length is not larger then the length encoding size + if int64(bitLen) > 0x7FFFFFFF { + return nil, fmt.Errorf("bitLen is greater than 32-bits") + } + + fixedInput := bytes.NewBuffer(nil) + fixedInput.Write(label) + fixedInput.WriteByte(0x00) + fixedInput.Write(context) + if err := binary.Write(fixedInput, binary.BigEndian, int32(bitLen)); err != nil { + return nil, fmt.Errorf("failed to write bit length to fixed input string: %v", err) + } + + var output []byte + + h := hmac.New(hash, key) + + for i := int64(1); i <= n; i++ { + h.Reset() + if err := binary.Write(h, binary.BigEndian, int32(i)); err != nil { + return nil, err + } + _, err := h.Write(fixedInput.Bytes()) + if err != nil { + return nil, err + } + output = append(output, h.Sum(nil)...) + } + + return output[:bitLen/8], nil +} diff --git a/internal/providers/aws/crypto/ecc_test.go b/internal/providers/aws/crypto/ecc_test.go new file mode 100644 index 0000000000..fb3e97165c --- /dev/null +++ b/internal/providers/aws/crypto/ecc_test.go @@ -0,0 +1,277 @@ +package crypto + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "io" + "testing" +) + +func TestECDSAPublicKeyDerivation_P256(t *testing.T) { + d := []byte{ + 0xc9, 0x80, 0x68, 0x98, 0xa0, 0x33, 0x49, 0x16, 0xc8, 0x60, 0x74, 0x88, 0x80, 0xa5, 0x41, 0xf0, + 0x93, 0xb5, 0x79, 0xa9, 0xb1, 0xf3, 0x29, 0x34, 0xd8, 0x6c, 0x36, 0x3c, 0x39, 0x80, 0x03, 0x57, + } + + x := []byte{ + 0xd0, 0x72, 0x0d, 0xc6, 0x91, 0xaa, 0x80, 0x09, 0x6b, 0xa3, 0x2f, 0xed, 0x1c, 0xb9, 0x7c, 0x2b, + 0x62, 0x06, 0x90, 0xd0, 0x6d, 0xe0, 0x31, 0x7b, 0x86, 0x18, 0xd5, 0xce, 0x65, 0xeb, 0x72, 0x8f, + } + + y := []byte{ + 0x96, 0x81, 0xb5, 0x17, 0xb1, 0xcd, 0xa1, 0x7d, 0x0d, 0x83, 0xd3, 0x35, 0xd9, 0xc4, 0xa8, 0xa9, + 0xa9, 0xb0, 0xb1, 0xb3, 0xc7, 0x10, 0x6d, 0x8f, 0x3c, 0x72, 0xbc, 0x50, 0x93, 0xdc, 0x27, 0x5f, + } + + testKeyDerivation(t, elliptic.P256(), d, x, y) +} + +func TestECDSAPublicKeyDerivation_P384(t *testing.T) { + d := []byte{ + 0x53, 0x94, 0xf7, 0x97, 0x3e, 0xa8, 0x68, 0xc5, 0x2b, 0xf3, 0xff, 0x8d, 0x8c, 0xee, 0xb4, 0xdb, + 0x90, 0xa6, 0x83, 0x65, 0x3b, 0x12, 0x48, 0x5d, 0x5f, 0x62, 0x7c, 0x3c, 0xe5, 0xab, 0xd8, 0x97, + 0x8f, 0xc9, 0x67, 0x3d, 0x14, 0xa7, 0x1d, 0x92, 0x57, 0x47, 0x93, 0x16, 0x62, 0x49, 0x3c, 0x37, + } + + x := []byte{ + 0xfd, 0x3c, 0x84, 0xe5, 0x68, 0x9b, 0xed, 0x27, 0x0e, 0x60, 0x1b, 0x3d, 0x80, 0xf9, 0x0d, 0x67, + 0xa9, 0xae, 0x45, 0x1c, 0xce, 0x89, 0x0f, 0x53, 0xe5, 0x83, 0x22, 0x9a, 0xd0, 0xe2, 0xee, 0x64, + 0x56, 0x11, 0xfa, 0x99, 0x36, 0xdf, 0xa4, 0x53, 0x06, 0xec, 0x18, 0x06, 0x67, 0x74, 0xaa, 0x24, + } + + y := []byte{ + 0xb8, 0x3c, 0xa4, 0x12, 0x6c, 0xfc, 0x4c, 0x4d, 0x1d, 0x18, 0xa4, 0xb6, 0xc2, 0x1c, 0x7f, 0x69, + 0x9d, 0x51, 0x23, 0xdd, 0x9c, 0x24, 0xf6, 0x6f, 0x83, 0x38, 0x46, 0xee, 0xb5, 0x82, 0x96, 0x19, + 0x6b, 0x42, 0xec, 0x06, 0x42, 0x5d, 0xb5, 0xb7, 0x0a, 0x4b, 0x81, 0xb7, 0xfc, 0xf7, 0x05, 0xa0, + } + + testKeyDerivation(t, elliptic.P384(), d, x, y) +} + +func TestECDSAKnownSigningValue_P256(t *testing.T) { + d := []byte{ + 0x51, 0x9b, 0x42, 0x3d, 0x71, 0x5f, 0x8b, 0x58, 0x1f, 0x4f, 0xa8, 0xee, 0x59, 0xf4, 0x77, 0x1a, + 0x5b, 0x44, 0xc8, 0x13, 0x0b, 0x4e, 0x3e, 0xac, 0xca, 0x54, 0xa5, 0x6d, 0xda, 0x72, 0xb4, 0x64, + } + + testKnownSigningValue(t, elliptic.P256(), d) +} + +func TestECDSAKnownSigningValue_P384(t *testing.T) { + d := []byte{ + 0x53, 0x94, 0xf7, 0x97, 0x3e, 0xa8, 0x68, 0xc5, 0x2b, 0xf3, 0xff, 0x8d, 0x8c, 0xee, 0xb4, 0xdb, + 0x90, 0xa6, 0x83, 0x65, 0x3b, 0x12, 0x48, 0x5d, 0x5f, 0x62, 0x7c, 0x3c, 0xe5, 0xab, 0xd8, 0x97, + 0x8f, 0xc9, 0x67, 0x3d, 0x14, 0xa7, 0x1d, 0x92, 0x57, 0x47, 0x93, 0x16, 0x62, 0x49, 0x3c, 0x37, + } + + testKnownSigningValue(t, elliptic.P384(), d) +} + +func testKeyDerivation(t *testing.T, curve elliptic.Curve, d, expectedX, expectedY []byte) { + privKey := ECDSAKey(curve, d) + + if e, a := d, privKey.D.Bytes(); !bytes.Equal(e, a) { + t.Errorf("expected % x, got % x", e, a) + } + + if e, a := expectedX, privKey.X.Bytes(); !bytes.Equal(e, a) { + t.Errorf("expected % x, got % x", e, a) + } + + if e, a := expectedY, privKey.Y.Bytes(); !bytes.Equal(e, a) { + t.Errorf("expected % x, got % x", e, a) + } +} + +func testKnownSigningValue(t *testing.T, curve elliptic.Curve, d []byte) { + signingKey := ECDSAKey(curve, d) + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err := io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := signingKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + verified, err := VerifySignature(&signingKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verified { + t.Fatalf("failed to verify message msgSignature") + } +} + +func TestECDSAInvalidSignature_P256(t *testing.T) { + testInvalidSignature(t, elliptic.P256()) +} + +func TestECDSAInvalidSignature_P384(t *testing.T) { + testInvalidSignature(t, elliptic.P384()) +} + +func TestECDSAGenKeySignature_P256(t *testing.T) { + testGenKeySignature(t, elliptic.P256()) +} + +func TestECDSAGenKeySignature_P384(t *testing.T) { + testGenKeySignature(t, elliptic.P384()) +} + +func testInvalidSignature(t *testing.T, curve elliptic.Curve) { + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err = io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := privateKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + byteToFlip := 15 + switch msgSignature[byteToFlip] { + case 0: + msgSignature[byteToFlip] = 0x0a + default: + msgSignature[byteToFlip] &^= msgSignature[byteToFlip] + } + + verified, err := VerifySignature(&privateKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if verified { + t.Fatalf("expected message verification to fail") + } +} + +func testGenKeySignature(t *testing.T, curve elliptic.Curve) { + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err = io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := privateKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + verified, err := VerifySignature(&privateKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verified { + t.Fatalf("expected message verification to fail") + } +} + +func TestECDSASignatureFormat(t *testing.T) { + asn1Signature := []byte{ + 0x30, 0x45, 0x02, 0x21, 0x00, 0xd7, 0xc5, 0xb9, 0x9e, 0x0b, 0xb1, 0x1a, 0x1f, 0x32, 0xda, 0x66, 0xe0, 0xff, + 0x59, 0xb7, 0x8a, 0x5e, 0xb3, 0x94, 0x9c, 0x23, 0xb3, 0xfc, 0x1f, 0x18, 0xcc, 0xf6, 0x61, 0x67, 0x8b, 0xf1, + 0xc1, 0x02, 0x20, 0x26, 0x4d, 0x8b, 0x7c, 0xaa, 0x52, 0x4c, 0xc0, 0x2e, 0x5f, 0xf6, 0x7e, 0x24, 0x82, 0xe5, + 0xfb, 0xcb, 0xc7, 0x9b, 0x83, 0x0d, 0x19, 0x7e, 0x7a, 0x40, 0x37, 0x87, 0xdd, 0x1c, 0x93, 0x13, 0xc4, + } + + x := []byte{ + 0x1c, 0xcb, 0xe9, 0x1c, 0x07, 0x5f, 0xc7, 0xf4, 0xf0, 0x33, 0xbf, 0xa2, 0x48, 0xdb, 0x8f, 0xcc, + 0xd3, 0x56, 0x5d, 0xe9, 0x4b, 0xbf, 0xb1, 0x2f, 0x3c, 0x59, 0xff, 0x46, 0xc2, 0x71, 0xbf, 0x83, + } + + y := []byte{ + 0xce, 0x40, 0x14, 0xc6, 0x88, 0x11, 0xf9, 0xa2, 0x1a, 0x1f, 0xdb, 0x2c, 0x0e, 0x61, 0x13, 0xe0, + 0x6d, 0xb7, 0xca, 0x93, 0xb7, 0x40, 0x4e, 0x78, 0xdc, 0x7c, 0xcd, 0x5c, 0xa8, 0x9a, 0x4c, 0xa9, + } + + publicKey, err := ECDSAPublicKey(elliptic.P256(), x, y) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + hash := sha256.New() + _, err = io.Copy(hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + msgHash := hash.Sum(nil) + + verifySignature, err := VerifySignature(publicKey, msgHash, asn1Signature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verifySignature { + t.Fatalf("failed to verify signature") + } +} diff --git a/internal/providers/aws.go b/internal/providers/aws/signing_v4.go similarity index 92% rename from internal/providers/aws.go rename to internal/providers/aws/signing_v4.go index 4bd5342ecc..eaa1a3e8c3 100644 --- a/internal/providers/aws.go +++ b/internal/providers/aws/signing_v4.go @@ -2,7 +2,7 @@ // Use of this source code is governed by an Apache2 // license that can be found in the LICENSE file. -package providers +package aws import ( "crypto/hmac" @@ -32,15 +32,15 @@ var awsSigv4IgnoredHeaders = map[string]struct{}{ "x-amzn-trace-id": {}, } -type AWSCredentials struct { +type Credentials struct { AccessKey string SecretKey string RegionName string SessionToken string } -func AWSCredentialsFromObject(v ast.Object) AWSCredentials { - var creds AWSCredentials +func CredentialsFromObject(v ast.Object) Credentials { + var creds Credentials awsAccessKey := v.Get(ast.StringTerm("aws_access_key")) awsSecretKey := v.Get(ast.StringTerm("aws_secret_access_key")) awsRegion := v.Get(ast.StringTerm("aws_region")) @@ -74,8 +74,8 @@ func sortKeys(strMap map[string][]string) []string { return keys } -// AWSSignV4 modifies a map[string][]string of headers to generate an AWS V4 signature + headers based on the config/credentials provided. -func AWSSignV4(headers map[string][]string, method string, theURL *url.URL, body []byte, service string, awsCreds AWSCredentials, theTime time.Time) (string, map[string]string) { +// SignV4 modifies a map[string][]string of headers to generate an AWS V4 signature + headers based on the config/credentials provided. +func SignV4(headers map[string][]string, method string, theURL *url.URL, body []byte, service string, awsCreds Credentials, theTime time.Time) (string, map[string]string) { // General ref. https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html // S3 ref. https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html // APIGateway ref. https://docs.aws.amazon.com/apigateway/api-reference/signing-requests/ diff --git a/internal/providers/aws/signing_v4a.go b/internal/providers/aws/signing_v4a.go new file mode 100644 index 0000000000..f1235a68b6 --- /dev/null +++ b/internal/providers/aws/signing_v4a.go @@ -0,0 +1,410 @@ +// modified from github.com/aws/aws-sdk-go-v2/internal/v4a@7a32d707af +package aws + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "math/big" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + signerCrypto "github.com/open-policy-agent/opa/internal/providers/aws/crypto" + v4Internal "github.com/open-policy-agent/opa/internal/providers/aws/v4" +) + +const ( + // AmzRegionSetKey represents the region set header used for sigv4a + AmzRegionSetKey = "X-Amz-Region-Set" + amzSecurityTokenKey = v4Internal.AmzSecurityTokenKey + amzDateKey = v4Internal.AmzDateKey + authorizationHeader = "Authorization" + + signingAlgorithm = "AWS4-ECDSA-P256-SHA256" + + timeFormat = "20060102T150405Z" + shortTimeFormat = "20060102" +) + +var ( + p256 elliptic.Curve + nMinusTwoP256 *big.Int + + one = new(big.Int).SetInt64(1) + + cache = credsCache{} + + randomSource = rand.Reader +) + +func init() { + // Ensure the elliptic curve parameters are initialized on package import rather then on first usage + p256 = elliptic.P256() + + nMinusTwoP256 = new(big.Int).SetBytes(p256.Params().N.Bytes()) + nMinusTwoP256 = nMinusTwoP256.Sub(nMinusTwoP256, new(big.Int).SetInt64(2)) +} + +type credsCache struct { + asymmetric atomic.Value + m sync.Mutex +} + +// SetRandomSource used for testing to override rand so tests can expect stable output +func SetRandomSource(reader io.Reader) { + randomSource = reader +} + +// deriveKeyFromAccessKeyPair derives a NIST P-256 PrivateKey from the given +// IAM AccessKey and SecretKey pair. +// +// Based on FIPS.186-4 Appendix B.4.2 +func deriveKeyFromAccessKeyPair(accessKey, secretKey string) (*ecdsa.PrivateKey, error) { + params := p256.Params() + bitLen := params.BitSize // Testing random candidates does not require an additional 64 bits + counter := 0x01 + + buffer := make([]byte, 1+len(accessKey)) // 1 byte counter + len(accessKey) + kdfContext := bytes.NewBuffer(buffer) + + inputKey := append([]byte("AWS4A"), []byte(secretKey)...) + + d := new(big.Int) + for { + kdfContext.Reset() + kdfContext.WriteString(accessKey) + kdfContext.WriteByte(byte(counter)) + + key, err := signerCrypto.HMACKeyDerivation(sha256.New, bitLen, inputKey, []byte(signingAlgorithm), kdfContext.Bytes()) + if err != nil { + return nil, err + } + + // Check key first before calling SetBytes if key is in fact a valid candidate. + // This ensures the byte slice is the correct length (32-bytes) to compare in constant-time + cmp, err := signerCrypto.ConstantTimeByteCompare(key, nMinusTwoP256.Bytes()) + if err != nil { + return nil, err + } + if cmp == -1 { + d.SetBytes(key) + break + } + + counter++ + if counter > 0xFF { + return nil, fmt.Errorf("exhausted single byte external counter") + } + } + d = d.Add(d, one) + + priv := new(ecdsa.PrivateKey) + priv.PublicKey.Curve = p256 + priv.D = d + priv.PublicKey.X, priv.PublicKey.Y = p256.ScalarBaseMult(d.Bytes()) + + return priv, nil +} + +// v4aCredentials is Context, ECDSA, and Optional Session Token that can be used +// to sign requests using SigV4a +type v4aCredentials struct { + Context string + PrivateKey *ecdsa.PrivateKey + SessionToken string +} + +// retrievePrivateKey returns credentials suitable for SigV4a signing +func retrievePrivateKey(symmetric Credentials) (v4aCredentials, error) { + cache.m.Lock() + defer cache.m.Unlock() + + // try to get creds from cache + v := cache.asymmetric.Load() + if v != nil { + c := v.(*v4aCredentials) + // if the cached Context matches the symmetric AccessKey ID, then use cached value. Otherwise, creds have + // changed and we need to derive new asymmetric creds + if c != nil && c.Context == symmetric.AccessKey { + return *c, nil + } + } + + privateKey, err := deriveKeyFromAccessKeyPair(symmetric.AccessKey, symmetric.SecretKey) + if err != nil { + return v4aCredentials{}, fmt.Errorf("failed to derive asymmetric key from credentials") + } + + creds := v4aCredentials{ + Context: symmetric.AccessKey, + PrivateKey: privateKey, + SessionToken: symmetric.SessionToken, + } + + // cache derived asymmetric creds so we don't derive new ones until symmetric creds change + cache.asymmetric.Store(&creds) + + return creds, nil +} + +type httpSigner struct { + Request *http.Request + ServiceName string + RegionSet []string + Time time.Time + Credentials v4aCredentials + + // PayloadHash is the hex encoded SHA-256 hash of the request payload + // If len(PayloadHash) == 0 the signer will attempt to send the request + // as an unsigned payload. Note: Unsigned payloads only work for a subset of services. + PayloadHash string +} + +func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) { + amzDate := s.Time.Format(timeFormat) + + headers.Set(AmzRegionSetKey, strings.Join(s.RegionSet, ",")) + headers.Set(amzDateKey, amzDate) + if len(s.Credentials.SessionToken) > 0 { + headers.Set(amzSecurityTokenKey, s.Credentials.SessionToken) + } +} + +// Build modifies the Request attribute of the httpSigner, adding an Authorization header +func (s *httpSigner) Build() (signedRequest, error) { + req := s.Request + + query := req.URL.Query() + headers := req.Header + + // seemingly required by S3/MRAP -- 403 Forbidden otherwise + headers.Set("host", req.URL.Host) + headers.Set("x-amz-content-sha256", s.PayloadHash) + + s.setRequiredSigningFields(headers, query) + + // Sort Each Query Key's Values + for key := range query { + sort.Strings(query[key]) + } + + v4Internal.SanitizeHostForHeader(req) + + credentialScope := s.buildCredentialScope() + credentialStr := s.Credentials.Context + "/" + credentialScope + + unsignedHeaders := headers + + host := req.URL.Host + if len(req.Host) > 0 { + host = req.Host + } + + signedHeaders, signedHeadersStr, canonicalHeaderStr := s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength) + + rawQuery := strings.Replace(query.Encode(), "+", "%20", -1) + + canonicalURI := v4Internal.GetURIPath(req.URL) + + canonicalString := s.buildCanonicalString( + req.Method, + canonicalURI, + rawQuery, + signedHeadersStr, + canonicalHeaderStr, + ) + + strToSign := s.buildStringToSign(credentialScope, canonicalString) + signingSignature, err := s.buildSignature(strToSign) + if err != nil { + return signedRequest{}, err + } + + headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature)) + + req.URL.RawQuery = rawQuery + + return signedRequest{ + Request: req, + SignedHeaders: signedHeaders, + CanonicalString: canonicalString, + StringToSign: strToSign, + }, nil +} + +func (s *httpSigner) buildCredentialScope() string { + return strings.Join([]string{ + s.Time.Format(shortTimeFormat), + s.ServiceName, + "aws4_request", + }, "/") + +} + +func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string { + const credential = "Credential=" + const signedHeaders = "SignedHeaders=" + const signature = "Signature=" + const commaSpace = ", " + + var parts strings.Builder + parts.Grow(len(signingAlgorithm) + 1 + + len(credential) + len(credentialStr) + len(commaSpace) + + len(signedHeaders) + len(signedHeadersStr) + len(commaSpace) + + len(signature) + len(signingSignature), + ) + parts.WriteString(signingAlgorithm) + parts.WriteRune(' ') + parts.WriteString(credential) + parts.WriteString(credentialStr) + parts.WriteString(commaSpace) + parts.WriteString(signedHeaders) + parts.WriteString(signedHeadersStr) + parts.WriteString(commaSpace) + parts.WriteString(signature) + parts.WriteString(signingSignature) + return parts.String() +} + +func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { + signed = make(http.Header) + + const hostHeader = "host" + headers := make([]string, 0) + + if length > 0 { + const contentLengthHeader = "content-length" + headers = append(headers, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) + } + + for k, v := range header { + if !rule.IsValid(k) { + continue // ignored header + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + // include additional values + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headers = append(headers, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headers) + + signedHeaders = strings.Join(headers, ";") + + var canonicalHeaders strings.Builder + n := len(headers) + const colon = ':' + for i := 0; i < n; i++ { + if headers[i] == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteRune(colon) + canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(headers[i]) + canonicalHeaders.WriteRune(colon) + // Trim out leading, trailing, and dedup inner spaces from signed header values. + values := signed[headers[i]] + for j, v := range values { + cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v)) + canonicalHeaders.WriteString(cleanedValue) + if j < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + } + canonicalHeaders.WriteRune('\n') + } + canonicalHeadersStr = canonicalHeaders.String() + + return signed, signedHeaders, canonicalHeadersStr +} + +func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + s.PayloadHash, + }, "\n") +} + +func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string { + return strings.Join([]string{ + signingAlgorithm, + s.Time.Format(timeFormat), + credentialScope, + hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), + }, "\n") +} + +func makeHash(hash hash.Hash, b []byte) []byte { + hash.Reset() + hash.Write(b) + return hash.Sum(nil) +} + +func (s *httpSigner) buildSignature(strToSign string) (string, error) { + sig, err := s.Credentials.PrivateKey.Sign(randomSource, makeHash(sha256.New(), []byte(strToSign)), crypto.SHA256) + if err != nil { + return "", err + } + return hex.EncodeToString(sig), nil +} + +type signedRequest struct { + Request *http.Request + SignedHeaders http.Header + CanonicalString string + StringToSign string +} + +// SignV4a returns a map[string][]string of headers, including an added AWS V4a signature based on the config/credentials provided. +func SignV4a(headers map[string][]string, method string, theURL *url.URL, body []byte, service string, awsCreds Credentials, theTime time.Time) map[string][]string { + bodyHexHash := fmt.Sprintf("%x", sha256.Sum256(body)) + + key, err := retrievePrivateKey(awsCreds) + if err != nil { + return map[string][]string{} + } + + bodyReader := bytes.NewReader(body) + req, _ := http.NewRequest(method, theURL.String(), bodyReader) + req.Header = headers + + signer := &httpSigner{ + Request: req, + PayloadHash: bodyHexHash, + ServiceName: service, + RegionSet: []string{"*"}, + Credentials: key, + Time: theTime, + } + + _, err = signer.Build() + if err != nil { + return map[string][]string{} + } + + return req.Header +} diff --git a/internal/providers/aws/v4/const.go b/internal/providers/aws/v4/const.go new file mode 100644 index 0000000000..89a76e2eaa --- /dev/null +++ b/internal/providers/aws/v4/const.go @@ -0,0 +1,36 @@ +package v4 + +const ( + // EmptyStringSHA256 is the hex encoded sha256 value of an empty string + EmptyStringSHA256 = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` + + // UnsignedPayload indicates that the request payload body is unsigned + UnsignedPayload = "UNSIGNED-PAYLOAD" + + // AmzAlgorithmKey indicates the signing algorithm + AmzAlgorithmKey = "X-Amz-Algorithm" + + // AmzSecurityTokenKey indicates the security token to be used with temporary credentials + AmzSecurityTokenKey = "X-Amz-Security-Token" + + // AmzDateKey is the UTC timestamp for the request in the format YYYYMMDD'T'HHMMSS'Z' + AmzDateKey = "X-Amz-Date" + + // AmzCredentialKey is the access key ID and credential scope + AmzCredentialKey = "X-Amz-Credential" + + // AmzSignedHeadersKey is the set of headers signed for the request + AmzSignedHeadersKey = "X-Amz-SignedHeaders" + + // AmzSignatureKey is the query parameter to store the SigV4 signature + AmzSignatureKey = "X-Amz-Signature" + + // TimeFormat is the time format to be used in the X-Amz-Date header or query parameter + TimeFormat = "20060102T150405Z" + + // ShortTimeFormat is the shorten time format used in the credential scope + ShortTimeFormat = "20060102" + + // ContentSHAKey is the SHA256 of request body + ContentSHAKey = "X-Amz-Content-Sha256" +) diff --git a/internal/providers/aws/v4/header_rules.go b/internal/providers/aws/v4/header_rules.go new file mode 100644 index 0000000000..baf6d12cc6 --- /dev/null +++ b/internal/providers/aws/v4/header_rules.go @@ -0,0 +1,87 @@ +package v4 + +import ( + "strings" +) + +// Rules houses a set of Rule needed for validation of a +// string value +type Rules []Rule + +// Rule interface allows for more flexible rules and just simply +// checks whether or not a value adheres to that Rule +type Rule interface { + IsValid(value string) bool +} + +// IsValid will iterate through all rules and see if any rules +// apply to the value and supports nested rules +func (r Rules) IsValid(value string) bool { + for _, rule := range r { + if rule.IsValid(value) { + return true + } + } + return false +} + +// MapRule generic Rule for maps +type MapRule map[string]struct{} + +// IsValid for the map Rule satisfies whether it exists in the map +func (m MapRule) IsValid(value string) bool { + _, ok := m[value] + return ok +} + +// AllowList is a generic Rule for whitelisting +type AllowList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (w AllowList) IsValid(value string) bool { + return w.Rule.IsValid(value) +} + +// DenyList is a generic Rule for blacklisting +type DenyList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (b DenyList) IsValid(value string) bool { + return !b.Rule.IsValid(value) +} + +// Patterns is a list of strings to match against +type Patterns []string + +// FORK: copied from aws-sdk-go-v2/internal/strings +func hasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) +} + +// IsValid for Patterns checks each pattern and returns if a match has +// been found +func (p Patterns) IsValid(value string) bool { + for _, pattern := range p { + if hasPrefixFold(value, pattern) { + return true + } + } + return false +} + +// InclusiveRules rules allow for rules to depend on one another +type InclusiveRules []Rule + +// IsValid will return true if all rules are true +func (r InclusiveRules) IsValid(value string) bool { + for _, rule := range r { + if !rule.IsValid(value) { + return false + } + } + return true +} diff --git a/internal/providers/aws/v4/headers.go b/internal/providers/aws/v4/headers.go new file mode 100644 index 0000000000..3487dc3352 --- /dev/null +++ b/internal/providers/aws/v4/headers.go @@ -0,0 +1,67 @@ +package v4 + +// IgnoredHeaders is a list of headers that are ignored during signing +var IgnoredHeaders = Rules{ + DenyList{ + MapRule{ + "Authorization": struct{}{}, + "User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + }, + }, +} + +// RequiredSignedHeaders is a whitelist for Build canonical headers. +var RequiredSignedHeaders = Rules{ + AllowList{ + MapRule{ + "Cache-Control": struct{}{}, + "Content-Disposition": struct{}{}, + "Content-Encoding": struct{}{}, + "Content-Language": struct{}{}, + "Content-Md5": struct{}{}, + "Content-Type": struct{}{}, + "Expires": struct{}{}, + "If-Match": struct{}{}, + "If-Modified-Since": struct{}{}, + "If-None-Match": struct{}{}, + "If-Unmodified-Since": struct{}{}, + "Range": struct{}{}, + "X-Amz-Acl": struct{}{}, + "X-Amz-Copy-Source": struct{}{}, + "X-Amz-Copy-Source-If-Match": struct{}{}, + "X-Amz-Copy-Source-If-Modified-Since": struct{}{}, + "X-Amz-Copy-Source-If-None-Match": struct{}{}, + "X-Amz-Copy-Source-If-Unmodified-Since": struct{}{}, + "X-Amz-Copy-Source-Range": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Grant-Full-control": struct{}{}, + "X-Amz-Grant-Read": struct{}{}, + "X-Amz-Grant-Read-Acp": struct{}{}, + "X-Amz-Grant-Write": struct{}{}, + "X-Amz-Grant-Write-Acp": struct{}{}, + "X-Amz-Metadata-Directive": struct{}{}, + "X-Amz-Mfa": struct{}{}, + "X-Amz-Request-Payer": struct{}{}, + "X-Amz-Server-Side-Encryption": struct{}{}, + "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Storage-Class": struct{}{}, + "X-Amz-Website-Redirect-Location": struct{}{}, + "X-Amz-Content-Sha256": struct{}{}, + "X-Amz-Tagging": struct{}{}, + }, + }, + Patterns{"X-Amz-Meta-"}, +} + +// AllowedQueryHoisting is a whitelist for Build query headers. The boolean value +// represents whether or not it is a pattern. +var AllowedQueryHoisting = InclusiveRules{ + DenyList{RequiredSignedHeaders}, + Patterns{"X-Amz-"}, +} diff --git a/internal/providers/aws/v4/host.go b/internal/providers/aws/v4/host.go new file mode 100644 index 0000000000..bf93659a43 --- /dev/null +++ b/internal/providers/aws/v4/host.go @@ -0,0 +1,75 @@ +package v4 + +import ( + "net/http" + "strings" +) + +// SanitizeHostForHeader removes default port from host and updates request.Host +func SanitizeHostForHeader(r *http.Request) { + host := getHost(r) + port := portOnly(host) + if port != "" && isDefaultPort(r.URL.Scheme, port) { + r.Host = stripPort(host) + } +} + +// Returns host from request +func getHost(r *http.Request) string { + if r.Host != "" { + return r.Host + } + + return r.URL.Host +} + +// Hostname returns u.Host, without any port number. +// +// If Host is an IPv6 literal with a port number, Hostname returns the +// IPv6 literal without the square brackets. IPv6 literals may include +// a zone identifier. +// +// Copied from the Go 1.8 standard library (net/url) +func stripPort(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return hostport + } + if i := strings.IndexByte(hostport, ']'); i != -1 { + return strings.TrimPrefix(hostport[:i], "[") + } + return hostport[:colon] +} + +// Port returns the port part of u.Host, without the leading colon. +// If u.Host doesn't contain a port, Port returns an empty string. +// +// Copied from the Go 1.8 standard library (net/url) +func portOnly(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return "" + } + if i := strings.Index(hostport, "]:"); i != -1 { + return hostport[i+len("]:"):] + } + if strings.Contains(hostport, "]") { + return "" + } + return hostport[colon+len(":"):] +} + +// Returns true if the specified URI is using the standard port +// (i.e. port 80 for HTTP URIs or 443 for HTTPS URIs) +func isDefaultPort(scheme, port string) bool { + if port == "" { + return true + } + + lowerCaseScheme := strings.ToLower(scheme) + if (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") { + return true + } + + return false +} diff --git a/internal/providers/aws/v4/util.go b/internal/providers/aws/v4/util.go new file mode 100644 index 0000000000..0cb9cffaf5 --- /dev/null +++ b/internal/providers/aws/v4/util.go @@ -0,0 +1,64 @@ +package v4 + +import ( + "net/url" + "strings" +) + +const doubleSpace = " " + +// StripExcessSpaces will rewrite the passed in slice's string values to not +// contain multiple side-by-side spaces. +func StripExcessSpaces(str string) string { + var j, k, l, m, spaces int + // Trim trailing spaces + for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { + } + + // Trim leading spaces + for k = 0; k < j && str[k] == ' '; k++ { + } + str = str[k : j+1] + + // Strip multiple spaces. + j = strings.Index(str, doubleSpace) + if j < 0 { + return str + } + + buf := []byte(str) + for k, m, l = j, j, len(buf); k < l; k++ { + if buf[k] == ' ' { + if spaces == 0 { + // First space. + buf[m] = buf[k] + m++ + } + spaces++ + } else { + // End of multiple spaces. + spaces = 0 + buf[m] = buf[k] + m++ + } + } + + return string(buf[:m]) +} + +// GetURIPath returns the escaped URI component from the provided URL +func GetURIPath(u *url.URL) string { + var uri string + + if len(u.Opaque) > 0 { + uri = "/" + strings.Join(strings.Split(u.Opaque, "/")[3:], "/") + } else { + uri = u.EscapedPath() + } + + if len(uri) == 0 { + uri = "/" + } + + return uri +} diff --git a/internal/providers/aws/v4/util_test.go b/internal/providers/aws/v4/util_test.go new file mode 100644 index 0000000000..c29c1fa850 --- /dev/null +++ b/internal/providers/aws/v4/util_test.go @@ -0,0 +1,75 @@ +package v4 + +import ( + "testing" +) + +func TestStripExcessHeaders(t *testing.T) { + vals := []string{ + "", + "123", + "1 2 3", + "1 2 3 ", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + expected := []string{ + "", + "123", + "1 2 3", + "1 2 3", + "1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2", + "1 2", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + for i := 0; i < len(vals); i++ { + r := StripExcessSpaces(vals[i]) + if e, a := expected[i], r; e != a { + t.Errorf("%d, expect %v, got %v", i, e, a) + } + } +} + +var stripExcessSpaceCases = []string{ + `AWS4-HMAC-SHA256 Credential=AKIDFAKEIDFAKEID/20160628/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=1234567890abcdef1234567890abcdef1234567890abcdef`, + `123 321 123 321`, + ` 123 321 123 321 `, + ` 123 321 123 321 `, + "123", + "1 2 3", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", +} + +func BenchmarkStripExcessSpaces(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, v := range stripExcessSpaceCases { + StripExcessSpaces(v) + } + } +} diff --git a/plugins/rest/aws.go b/plugins/rest/aws.go index 3561448fd0..679a79fa88 100644 --- a/plugins/rest/aws.go +++ b/plugins/rest/aws.go @@ -19,8 +19,7 @@ import ( "time" "github.com/go-ini/ini" - - "github.com/open-policy-agent/opa/internal/providers" + "github.com/open-policy-agent/opa/internal/providers/aws" "github.com/open-policy-agent/opa/logging" ) @@ -58,7 +57,7 @@ const ( // awsCredentialService represents the interface for AWS credential providers type awsCredentialService interface { - credentials() (providers.AWSCredentials, error) + credentials() (aws.Credentials, error) } // awsEnvironmentCredentialService represents an static environment-variable credential provider for AWS @@ -66,8 +65,8 @@ type awsEnvironmentCredentialService struct { logger logging.Logger } -func (cs *awsEnvironmentCredentialService) credentials() (providers.AWSCredentials, error) { - var creds providers.AWSCredentials +func (cs *awsEnvironmentCredentialService) credentials() (aws.Credentials, error) { + var creds aws.Credentials creds.AccessKey = os.Getenv(accessKeyEnvVar) if creds.AccessKey == "" { return creds, errors.New("no " + accessKeyEnvVar + " set in environment") @@ -114,8 +113,8 @@ type awsProfileCredentialService struct { logger logging.Logger } -func (cs *awsProfileCredentialService) credentials() (providers.AWSCredentials, error) { - var creds providers.AWSCredentials +func (cs *awsProfileCredentialService) credentials() (aws.Credentials, error) { + var creds aws.Credentials filename, err := cs.path() if err != nil { @@ -142,7 +141,7 @@ func (cs *awsProfileCredentialService) credentials() (providers.AWSCredentials, return creds, fmt.Errorf("profile \"%v\" in credentials file %v does not contain \"%v\"", cs.Profile, cs.Path, secretKeyGlobalSetting) } - creds.SessionToken = profile.Key(securityTokenGlobalSetting).String() //default to empty string + creds.SessionToken = profile.Key(securityTokenGlobalSetting).String() // default to empty string if cs.RegionName == "" { if cs.RegionName = os.Getenv(awsRegionEnvVar); cs.RegionName == "" { @@ -191,7 +190,7 @@ func (cs *awsProfileCredentialService) profile() string { type awsMetadataCredentialService struct { RoleName string `json:"iam_role,omitempty"` RegionName string `json:"aws_region"` - creds providers.AWSCredentials + creds aws.Credentials expiration time.Time credServicePath string tokenPath string @@ -308,7 +307,7 @@ func (cs *awsMetadataCredentialService) refreshFromService() error { return nil } -func (cs *awsMetadataCredentialService) credentials() (providers.AWSCredentials, error) { +func (cs *awsMetadataCredentialService) credentials() (aws.Credentials, error) { err := cs.refreshFromService() if err != nil { return cs.creds, err @@ -323,7 +322,7 @@ type awsWebIdentityCredentialService struct { RegionName string `json:"aws_region"` SessionName string `json:"session_name"` stsURL string - creds providers.AWSCredentials + creds aws.Credentials expiration time.Time logger logging.Logger } @@ -433,7 +432,7 @@ func (cs *awsWebIdentityCredentialService) refreshFromService() error { return nil } -func (cs *awsWebIdentityCredentialService) credentials() (providers.AWSCredentials, error) { +func (cs *awsWebIdentityCredentialService) credentials() (aws.Credentials, error) { err := cs.refreshFromService() if err != nil { return cs.creds, err @@ -482,7 +481,7 @@ func doMetaDataRequestWithClient(req *http.Request, client *http.Client, desc st } // signV4 modifies an http.Request to include an AWS V4 signature based on a credential provider -func signV4(req *http.Request, service string, credService awsCredentialService, theTime time.Time) error { +func signV4(req *http.Request, service string, credService awsCredentialService, theTime time.Time, sigVersion string) error { // General ref. https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html // S3 ref. https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html // APIGateway ref. https://docs.aws.amazon.com/apigateway/api-reference/signing-requests/ @@ -507,11 +506,15 @@ func signV4(req *http.Request, service string, credService awsCredentialService, now := theTime.UTC() - authHeader, awsHeaders := providers.AWSSignV4(req.Header, req.Method, req.URL, body, service, creds, now) - - req.Header.Set("Authorization", authHeader) - for k, v := range awsHeaders { - req.Header.Add(k, v) + if sigVersion == "4a" { + signedHeaders := aws.SignV4a(req.Header, req.Method, req.URL, body, service, creds, now) + req.Header = signedHeaders + } else { + authHeader, awsHeaders := aws.SignV4(req.Header, req.Method, req.URL, body, service, creds, now) + req.Header.Set("Authorization", authHeader) + for k, v := range awsHeaders { + req.Header.Add(k, v) + } } return nil diff --git a/plugins/rest/aws_test.go b/plugins/rest/aws_test.go index a80e320071..c5ba885d38 100644 --- a/plugins/rest/aws_test.go +++ b/plugins/rest/aws_test.go @@ -5,6 +5,7 @@ package rest import ( + "crypto/rand" "encoding/json" "fmt" "io" @@ -16,7 +17,7 @@ import ( "testing" "time" - "github.com/open-policy-agent/opa/internal/providers" + "github.com/open-policy-agent/opa/internal/providers/aws" "github.com/open-policy-agent/opa/logging" "github.com/open-policy-agent/opa/util/test" ) @@ -30,13 +31,22 @@ type metadataPayload struct { Expiration time.Time } -// quicky and dirty assertions +// quick and dirty assertions func assertEq(expected string, actual string, t *testing.T) { t.Helper() if actual != expected { t.Error("expected: ", expected, " but got: ", actual) } } +func assertIn(candidates []string, actual string, t *testing.T) { + t.Helper() + for _, expected := range candidates { + if actual == expected { + return + } + } + t.Error("value: '", actual, "' not found in: ", candidates) +} func assertErr(expected string, actual error, t *testing.T) { t.Helper() @@ -62,7 +72,7 @@ func TestEnvironmentCredentialService(t *testing.T) { t.Setenv("AWS_REGION", "us-east-1") - expectedCreds := providers.AWSCredentials{ + expectedCreds := aws.Credentials{ AccessKey: "MYAWSACCESSKEYGOESHERE", SecretKey: "MYAWSSECRETACCESSKEYGOESHERE", RegionName: "us-east-1", @@ -135,7 +145,7 @@ aws_secret_access_key=%v t.Fatal(err) } - expected := providers.AWSCredentials{ + expected := aws.Credentials{ AccessKey: fooKey, SecretKey: fooSecret, RegionName: fooRegion, @@ -158,7 +168,7 @@ aws_secret_access_key=%v t.Fatal(err) } - expected = providers.AWSCredentials{ + expected = aws.Credentials{ AccessKey: defaultKey, SecretKey: defaultSecret, RegionName: defaultRegion, @@ -201,7 +211,7 @@ aws_session_token=%s t.Fatal(err) } - expected := providers.AWSCredentials{ + expected := aws.Credentials{ AccessKey: defaultKey, SecretKey: defaultSecret, RegionName: defaultRegion, @@ -250,7 +260,7 @@ aws_session_token=%s t.Fatal(err) } - expected := providers.AWSCredentials{ + expected := aws.Credentials{ AccessKey: defaultKey, SecretKey: defaultSecret, RegionName: defaultRegion, @@ -414,7 +424,7 @@ func TestMetadataCredentialService(t *testing.T) { tokenPath: ts.server.URL + "/latest/api/token", logger: logging.Get(), } - var creds providers.AWSCredentials + var creds aws.Credentials creds, err = cs.credentials() if err != nil { // Cannot proceed with test if unable to fetch credentials. @@ -480,7 +490,7 @@ func TestMetadataCredentialService(t *testing.T) { assertEq(creds.SessionToken, ts.payload.Token, t) } -func TestV4Signing(t *testing.T) { +func TestMetadataServiceErrorHandled(t *testing.T) { ts := ec2CredTestServer{} ts.start() defer ts.stop() @@ -494,12 +504,18 @@ func TestV4Signing(t *testing.T) { logger: logging.Get(), } req, _ := http.NewRequest("GET", "https://mybucket.s3.amazonaws.com/bundle.tar.gz", strings.NewReader("")) - err := signV4(req, "s3", cs, time.Unix(1556129697, 0)) + err := signV4(req, "s3", cs, time.Unix(1556129697, 0), "4") assertErr("error getting AWS credentials: metadata HTTP request returned unexpected status: 404 Not Found", err, t) +} + +func TestV4Signing(t *testing.T) { + ts := ec2CredTestServer{} + ts.start() + defer ts.stop() // happy path: sign correctly - cs = &awsMetadataCredentialService{ + cs := &awsMetadataCredentialService{ RoleName: "my_iam_role", // not present RegionName: "us-east-1", credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", @@ -512,23 +528,55 @@ func TestV4Signing(t *testing.T) { Code: "Success", Token: "MYAWSSECURITYTOKENGOESHERE", Expiration: time.Now().UTC().Add(time.Minute * 2)} - req, _ = http.NewRequest("GET", "https://mybucket.s3.amazonaws.com/bundle.tar.gz", strings.NewReader("")) - err = signV4(req, "s3", cs, time.Unix(1556129697, 0)) + req, _ := http.NewRequest("GET", "https://mybucket.s3.amazonaws.com/bundle.tar.gz", strings.NewReader("")) - if err != nil { - t.Fatal("unexpected error during signing") + // force a non-random source so that we can predict the v4a signing key and, thus, signature + myReader := strings.NewReader("000000000000000000000000000000000") + aws.SetRandomSource(myReader) + defer func() { aws.SetRandomSource(rand.Reader) }() + + tests := []struct { + sigVersion string + expectedAuthorization []string + }{ + { + sigVersion: "4", + expectedAuthorization: []string{ + "AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/s3/aws4_request," + + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token," + + "Signature=d3f0561abae5e35d9ee2c15e678bb7acacc4b4743707a8f7fbcbfdb519078990", + }, + }, + { + sigVersion: "4a", + expectedAuthorization: []string{ + // this signature is for go 1.18+, which changed crypto/ecdsa so signatures differ from go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/s3/aws4_request, " + + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=304402207d1bcb6fb68d85be3e9f6948a8dc8596a531b3f5a82ca2350acabe98941312bc02207d81ed07c7356226d93611820548a806c8e1f0cc72ff41ba672d23901e5a06bf", + // this signature is for go 1.17. Remove this and only test for a single value when OPA drops go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/s3/aws4_request, " + + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=3045022100f951364b6495e3fe6be830a3550043bfd98e5312f79091e7c87bd51455cfb93c02203a4ca3e29ad63b6a9b473172e6ebb870f3d1947f2c44334bfd7eb74dbda4ec97", + }, + }, } - // expect mandatory headers - assertEq(req.Header.Get("Host"), "mybucket.s3.amazonaws.com", t) - assertEq(req.Header.Get("Authorization"), - "AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/s3/aws4_request,"+ - "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token,"+ - "Signature=d3f0561abae5e35d9ee2c15e678bb7acacc4b4743707a8f7fbcbfdb519078990", t) - assertEq(req.Header.Get("X-Amz-Content-Sha256"), - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", t) - assertEq(req.Header.Get("X-Amz-Date"), "20190424T181457Z", t) - assertEq(req.Header.Get("X-Amz-Security-Token"), "MYAWSSECURITYTOKENGOESHERE", t) + for _, test := range tests { + err := signV4(req, "s3", cs, time.Unix(1556129697, 0), test.sigVersion) + + if err != nil { + t.Fatal("unexpected error during signing", err) + } + + // expect mandatory headers + assertEq("mybucket.s3.amazonaws.com", req.Header.Get("Host"), t) + assertIn(test.expectedAuthorization, req.Header.Get("Authorization"), t) + assertEq("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + req.Header.Get("X-Amz-Content-Sha256"), t) + assertEq("20190424T181457Z", req.Header.Get("X-Amz-Date"), t) + assertEq("MYAWSSECURITYTOKENGOESHERE", req.Header.Get("X-Amz-Security-Token"), t) + } } func TestV4SigningForApiGateway(t *testing.T) { @@ -553,7 +601,7 @@ func TestV4SigningForApiGateway(t *testing.T) { strings.NewReader("{ \"payload\": 42 }")) req.Header.Set("Content-Type", "application/json") - err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0)) + err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0), "4") if err != nil { t.Fatal("unexpected error during signing") @@ -598,20 +646,51 @@ func TestV4SigningOmitsIgnoredHeaders(t *testing.T) { req.Header.Set("Authorization", "Auth header will be overwritten, and shouldn't be signed") req.Header.Set("X-Amzn-Trace-Id", "Some trace id") - err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0)) + // force a non-random source so that we can predict the v4a signing key and, thus, signature + myReader := strings.NewReader("000000000000000000000000000000000") + aws.SetRandomSource(myReader) + defer func() { aws.SetRandomSource(rand.Reader) }() - if err != nil { - t.Fatal("unexpected error during signing") + tests := []struct { + sigVersion string + expectedAuthorization []string + }{ + { + sigVersion: "4", + expectedAuthorization: []string{"AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/execute-api/aws4_request," + + "SignedHeaders=content-type;host;x-amz-date;x-amz-security-token," + + "Signature=c8ee72cc45050b255bcbf19defc693f7cd788959b5380fa0985de6e865635339", + }, + }, + { + sigVersion: "4a", + expectedAuthorization: []string{ + // this signature is for go 1.18+, which changed crypto/ecdsa so signatures differ from go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/execute-api/aws4_request, " + + "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=30450221009f3b0cda178456dfd1bec61b78bdbd115c0cf497eaa52c58bbb2850ad9c49c3002207009cb88a1219a4a6626056c31823a6b5bc2728bc88bc98a06e12e1148482c94", + // this signature is for go 1.17. Remove this and only test for a single value when OPA drops go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/execute-api/aws4_request, " + + "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=304602210088b5a5ccf9e37aac765f7e6bf0507577eb1b919b80bc3c385b8856c7ab7a9912022100f4c558e36be338c9644240b722e06333ea9a5305b2e638d56ad0105995c9b1f7", + }, + }, + } + + for _, test := range tests { + err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0), test.sigVersion) + + if err != nil { + t.Fatal("unexpected error during signing") + } + + // Check the signed headers doesn't include user-agent, authorization or x-amz-trace-id + assertIn(test.expectedAuthorization, req.Header.Get("Authorization"), t) + // The headers omitted from signing should still be present in the request + assertEq(req.Header.Get("User-Agent"), "Unit Tests!", t) + assertEq(req.Header.Get("X-Amzn-Trace-Id"), "Some trace id", t) } - // Check the signed headers doesn't include user-agent, authorization or x-amz-trace-id - assertEq(req.Header.Get("Authorization"), - "AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/execute-api/aws4_request,"+ - "SignedHeaders=content-type;host;x-amz-date;x-amz-security-token,"+ - "Signature=c8ee72cc45050b255bcbf19defc693f7cd788959b5380fa0985de6e865635339", t) - // The headers omitted from signing should still be present in the request - assertEq(req.Header.Get("User-Agent"), "Unit Tests!", t) - assertEq(req.Header.Get("X-Amzn-Trace-Id"), "Some trace id", t) } func TestV4SigningCustomPort(t *testing.T) { @@ -633,7 +712,7 @@ func TestV4SigningCustomPort(t *testing.T) { Token: "MYAWSSECURITYTOKENGOESHERE", Expiration: time.Now().UTC().Add(time.Minute * 2)} req, _ := http.NewRequest("GET", "https://custom.s3.server:9000/bundle.tar.gz", strings.NewReader("")) - err := signV4(req, "s3", cs, time.Unix(1556129697, 0)) + err := signV4(req, "s3", cs, time.Unix(1556129697, 0), "4") if err != nil { t.Fatal("unexpected error during signing") @@ -669,18 +748,33 @@ func TestV4SigningDoesNotMutateBody(t *testing.T) { Code: "Success", Token: "MYAWSSECURITYTOKENGOESHERE", Expiration: time.Now().UTC().Add(time.Minute * 2)} - req, _ := http.NewRequest("POST", "https://myrestapi.execute-api.us-east-1.amazonaws.com/prod/logs", - strings.NewReader("{ \"payload\": 42 }")) - err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0)) + // force a non-random source so that we can predict the v4a signing key and, thus, signature + myReader := strings.NewReader("000000000000000000000000000000000") + aws.SetRandomSource(myReader) + defer func() { aws.SetRandomSource(rand.Reader) }() - if err != nil { - t.Fatal("unexpected error during signing") + tests := []struct { + sigVersion string + }{ + {sigVersion: "4"}, + {sigVersion: "4a"}, } - // Read the body and check that it was not mutated - body, _ := io.ReadAll(req.Body) - assertEq(string(body), "{ \"payload\": 42 }", t) + for _, test := range tests { + req, _ := http.NewRequest("POST", "https://myrestapi.execute-api.us-east-1.amazonaws.com/prod/logs", + strings.NewReader("{ \"payload\": 42 }")) + + err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0), test.sigVersion) + + if err != nil { + t.Fatal("unexpected error during signing") + } + + // Read the body and check that it was not mutated + body, _ := io.ReadAll(req.Body) + assertEq(string(body), "{ \"payload\": 42 }", t) + } } func TestV4SigningWithMultiValueHeaders(t *testing.T) { @@ -706,24 +800,53 @@ func TestV4SigningWithMultiValueHeaders(t *testing.T) { req.Header.Add("Accept", "text/plain") req.Header.Add("Accept", "text/html") - err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0)) + // force a non-random source so that we can predict the v4a signing key and, thus, signature + myReader := strings.NewReader("000000000000000000000000000000000") + aws.SetRandomSource(myReader) + defer func() { aws.SetRandomSource(rand.Reader) }() - if err != nil { - t.Fatal("unexpected error during signing") + tests := []struct { + sigVersion string + expectedAuthorization []string + }{ + { + sigVersion: "4", + expectedAuthorization: []string{ + "AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/execute-api/aws4_request," + + "SignedHeaders=accept;host;x-amz-date;x-amz-security-token," + + "Signature=0237b0c789cad36212f0efba70c02549e1f659ab9caaca16423930cc7236c046", + }, + }, + { + sigVersion: "4a", + expectedAuthorization: []string{ + // this signature is for go 1.18+, which changed crypto/ecdsa so signatures differ from go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/execute-api/aws4_request, " + + "SignedHeaders=accept;content-length;host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=304402202d5f2d4d42fe59b2e61fa455cb35a335139d109c2d37aaa8946d45fd0fb4989c022068238cbfbc80326f5cc391f2b6837910191ceabb58ec0bf986c0141f76046594", + // this signature is for go 1.17. Remove this and only test for a single value when OPA drops go 1.17 + "AWS4-ECDSA-P256-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/execute-api/aws4_request, " + + "SignedHeaders=accept;content-length;host;x-amz-content-sha256;x-amz-date;x-amz-region-set;x-amz-security-token, " + + "Signature=304502206ac05ae8f63689e989227fac6c6c16008c25c1d66903f6535610df6496942701022100e1db05ec77d5142462537f7fd4d14db1d1e9b5c8c14643f2434206fe7284dfd6", + }, + }, } - // Check the signed headers includes our multi-value 'accept' header - assertEq(req.Header.Get("Authorization"), - "AWS4-HMAC-SHA256 Credential=MYAWSACCESSKEYGOESHERE/20190424/us-east-1/execute-api/aws4_request,"+ - "SignedHeaders=accept;host;x-amz-date;x-amz-security-token,"+ - "Signature=0237b0c789cad36212f0efba70c02549e1f659ab9caaca16423930cc7236c046", t) - // Ensure 'authorization' is not multi-valued. - if len(req.Header.Values("Authorization")) != 1 { - t.Fatal("Authorization header is multi-valued. This will break AWS v4 signing.") + for _, test := range tests { + err := signV4(req, "execute-api", cs, time.Unix(1556129697, 0), test.sigVersion) + + if err != nil { + t.Fatal("unexpected error during signing") + } + if len(req.Header.Values("Authorization")) != 1 { + t.Fatal("Authorization header is multi-valued. This will break AWS v4 signing.") + } + // Check the signed headers includes our multi-value 'accept' header + assertIn(test.expectedAuthorization, req.Header.Get("Authorization"), t) + // The multi-value headers are preserved + assertEq("text/plain", req.Header.Values("Accept")[0], t) + assertEq("text/html", req.Header.Values("Accept")[1], t) } - // The multi-value headers are preserved - assertEq(req.Header.Values("Accept")[0], "text/plain", t) - assertEq(req.Header.Values("Accept")[1], "text/html", t) } // simulate EC2 metadata service diff --git a/plugins/rest/rest_auth.go b/plugins/rest/rest_auth.go index d33506c80e..79cb56cce9 100644 --- a/plugins/rest/rest_auth.go +++ b/plugins/rest/rest_auth.go @@ -26,7 +26,7 @@ import ( "github.com/open-policy-agent/opa/internal/jwx/jwa" "github.com/open-policy-agent/opa/internal/jwx/jws" "github.com/open-policy-agent/opa/internal/jwx/jws/sign" - "github.com/open-policy-agent/opa/internal/providers" + "github.com/open-policy-agent/opa/internal/providers/aws" "github.com/open-policy-agent/opa/internal/uuid" "github.com/open-policy-agent/opa/keys" "github.com/open-policy-agent/opa/logging" @@ -518,6 +518,7 @@ type awsSigningAuthPlugin struct { AWSWebIdentityCredentials *awsWebIdentityCredentialService `json:"web_identity_credentials,omitempty"` AWSProfileCredentials *awsProfileCredentialService `json:"profile_credentials,omitempty"` AWSService string `json:"service,omitempty"` + AWSSignatureVersion string `json:"signature_version,omitempty"` logger logging.Logger } @@ -531,7 +532,7 @@ func (acs *awsCredentialServiceChain) addService(service awsCredentialService) { acs.awsCredentialServices = append(acs.awsCredentialServices, service) } -func (acs *awsCredentialServiceChain) credentials() (providers.AWSCredentials, error) { +func (acs *awsCredentialServiceChain) credentials() (aws.Credentials, error) { for _, service := range acs.awsCredentialServices { credential, err := service.credentials() if err == nil { @@ -544,7 +545,7 @@ func (acs *awsCredentialServiceChain) credentials() (providers.AWSCredentials, e reflect.TypeOf(service).String(), err) } - return providers.AWSCredentials{}, errors.New("all AWS credential providers failed") + return aws.Credentials{}, errors.New("all AWS credential providers failed") } func (ap *awsSigningAuthPlugin) awsCredentialService() awsCredentialService { @@ -602,7 +603,7 @@ func (ap *awsSigningAuthPlugin) NewClient(c Config) (*http.Client, error) { func (ap *awsSigningAuthPlugin) Prepare(req *http.Request) error { ap.logger.Debug("Signing request with AWS credentials.") - return signV4(req, ap.AWSService, ap.awsCredentialService(), time.Now()) + return signV4(req, ap.AWSService, ap.awsCredentialService(), time.Now(), ap.AWSSignatureVersion) } func (ap *awsSigningAuthPlugin) validateConfig() error { @@ -632,5 +633,9 @@ func (ap *awsSigningAuthPlugin) validateConfig() error { ap.AWSService = awsSigv4SigningDefaultService } + if ap.AWSSignatureVersion == "" { + ap.AWSSignatureVersion = "4" + } + return nil } diff --git a/topdown/providers.go b/topdown/providers.go index 26e087d7c6..72ad1922ca 100644 --- a/topdown/providers.go +++ b/topdown/providers.go @@ -10,7 +10,7 @@ import ( "time" "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/internal/providers" + "github.com/open-policy-agent/opa/internal/providers/aws" "github.com/open-policy-agent/opa/topdown/builtins" ) @@ -104,7 +104,7 @@ func builtinAWSSigV4SignReq(ctx BuiltinContext, operands []*ast.Term, iter func( return err } service := stringFromTerm(awsConfigObj.Get(ast.StringTerm("aws_service"))) - awsCreds := providers.AWSCredentialsFromObject(awsConfigObj) + awsCreds := aws.CredentialsFromObject(awsConfigObj) // Timestamp for signing. var signingTimestamp time.Time @@ -172,7 +172,7 @@ func builtinAWSSigV4SignReq(ctx BuiltinContext, operands []*ast.Term, iter func( } // Sign the request object's headers, and reconstruct the headers map. - authHeader, signedHeadersMap := providers.AWSSignV4(objectToMap(headers), method, theURL, body, service, awsCreds, signingTimestamp) + authHeader, signedHeadersMap := aws.SignV4(objectToMap(headers), method, theURL, body, service, awsCreds, signingTimestamp) signedHeadersObj := ast.NewObject() signedHeadersObj.Insert(ast.StringTerm("Authorization"), ast.StringTerm(authHeader)) for k, v := range signedHeadersMap {