From 993e32d816687977f9f2e8c0162555ff2f265919 Mon Sep 17 00:00:00 2001 From: Daniel Katz Date: Wed, 31 Oct 2018 11:20:53 -0400 Subject: [PATCH] Sign upstream requests with a verifiable digital signature. --- internal/proxy/oauthproxy.go | 81 +++++++++-- internal/proxy/oauthproxy_test.go | 135 ++++++++++++++++- internal/proxy/options.go | 2 + internal/proxy/proxy_config.go | 15 +- internal/proxy/request_signer.go | 185 ++++++++++++++++++++++++ internal/proxy/request_signer_test.go | 179 +++++++++++++++++++++++ internal/proxy/testdata/private_key.pem | 28 ++++ internal/proxy/testdata/public_key.pub | 8 + 8 files changed, 610 insertions(+), 23 deletions(-) create mode 100644 internal/proxy/request_signer.go create mode 100644 internal/proxy/request_signer_test.go create mode 100644 internal/proxy/testdata/private_key.pem create mode 100644 internal/proxy/testdata/public_key.pub diff --git a/internal/proxy/oauthproxy.go b/internal/proxy/oauthproxy.go index d807a340..cc4205d9 100755 --- a/internal/proxy/oauthproxy.go +++ b/internal/proxy/oauthproxy.go @@ -24,8 +24,8 @@ import ( "github.com/datadog/datadog-go/statsd" ) -// SignatureHeader is the header name where the signed request header is stored. -const SignatureHeader = "Gap-Signature" +// HMACSignatureHeader is the header name where the signed request header is stored. +const HMACSignatureHeader = "Gap-Signature" // SignatureHeaders are the headers that are valid in the request. var SignatureHeaders = []string{ @@ -74,6 +74,9 @@ type OAuthProxy struct { mux map[string]*route regexRoutes []*route + + requestSigner *RequestSigner + publicCertsJSON []byte } type route struct { @@ -93,11 +96,19 @@ type StateParameter struct { // UpstreamProxy stores information necessary for proxying the request back to the upstream. type UpstreamProxy struct { - name string - cookieName string - handler http.Handler - auth hmacauth.HmacAuth - statsdClient *statsd.Client + name string + cookieName string + handler http.Handler + auth hmacauth.HmacAuth + requestSigner *RequestSigner + statsdClient *statsd.Client +} + +// Certificates stores certs that will be publicly exported via the `/v1/certs` endpoint. These may +// be utilized by upstreams to check the integrity of a request, by validating the `Sso-Signature` +// header value. +type Certificates struct { + PublicKey string `json:"signing_key"` } // deleteSSOCookieHeader deletes the session cookie from the request header string. @@ -118,6 +129,9 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if u.auth != nil { u.auth.SignRequest(r) } + if u.requestSigner != nil { + u.requestSigner.Sign(r) + } start := time.Now() u.handler.ServeHTTP(w, r) @@ -211,13 +225,17 @@ func NewRewriteReverseProxy(route *RewriteRoute, config *UpstreamConfig) *httput } // NewReverseProxyHandler creates a new http.Handler given a httputil.ReverseProxy -func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig) (http.Handler, []string) { +func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig, signer *RequestSigner) (http.Handler, []string) { upstreamProxy := &UpstreamProxy{ - name: config.Service, - handler: reverseProxy, - auth: config.HMACAuth, - cookieName: opts.CookieName, - statsdClient: opts.StatsdClient, + name: config.Service, + handler: reverseProxy, + auth: config.HMACAuth, + cookieName: opts.CookieName, + statsdClient: opts.StatsdClient, + requestSigner: signer, + } + if config.SkipRequestSigning { + upstreamProxy.requestSigner = nil } if config.FlushInterval != 0 { return NewStreamingHandler(upstreamProxy, opts, config), []string{"handler:streaming"} @@ -255,7 +273,7 @@ func generateHmacAuth(signatureKey string) (hmacauth.HmacAuth, error) { if err != nil { return nil, fmt.Errorf("unsupported signature hash algorithm: %s", algorithm) } - auth := hmacauth.NewHmacAuth(hash, []byte(secret), SignatureHeader, SignatureHeaders) + auth := hmacauth.NewHmacAuth(hash, []byte(secret), HMACSignatureHeader, SignatureHeaders) return auth, nil } @@ -283,6 +301,25 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr c.Run() }() + // Configure the RequestSigner (used to sign requests with `Sso-Signature` header). + // Also build the `certs` static JSON-string which will be served from a public endpoint. + // The key published at this endpoint allows upstreams to decrypt the `Sso-Signature` + // header, and validate the integrity and authenticity of a request. + var certs = Certificates{} + var requestSigner *RequestSigner + if len(opts.RequestSigningKey) > 0 { + if requestSigner, err = NewRequestSigner(opts.RequestSigningKey); err != nil { + return nil, fmt.Errorf("could not build RequestSigner: %s", err) + } + certs.PublicKey = requestSigner.PublicKey() + } else { + logger.Warn("Running OAuthProxy without signing key. Requests will not be signed.") + } + certsAsStr, err := json.MarshalIndent(certs, "", " ") + if err != nil { + return nil, fmt.Errorf("could not marshal public certs as JSON: %s", err) + } + p := &OAuthProxy{ CookieCipher: cipher, CookieDomain: opts.CookieDomain, @@ -303,6 +340,9 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr redirectURL: &url.URL{Path: "/oauth2/callback"}, skipAuthPreflight: opts.SkipAuthPreflight, templates: getTemplates(), + + requestSigner: requestSigner, + publicCertsJSON: certsAsStr, } for _, optFunc := range optFuncs { @@ -316,11 +356,13 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr switch route := upstreamConfig.Route.(type) { case *SimpleRoute: reverseProxy := NewReverseProxy(route.ToURL, upstreamConfig) - handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig) + handler, tags := NewReverseProxyHandler( + reverseProxy, opts, upstreamConfig, requestSigner) p.Handle(route.FromURL.Host, handler, tags, upstreamConfig) case *RewriteRoute: reverseProxy := NewRewriteReverseProxy(route, upstreamConfig) - handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig) + handler, tags := NewReverseProxyHandler( + reverseProxy, opts, upstreamConfig, requestSigner) p.HandleRegex(route.FromRegex, handler, tags, upstreamConfig) default: return nil, fmt.Errorf("unknown route type") @@ -335,6 +377,7 @@ func (p *OAuthProxy) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/favicon.ico", p.Favicon) mux.HandleFunc("/robots.txt", p.RobotsTxt) + mux.HandleFunc("/oauth2/v1/certs", p.Certs) mux.HandleFunc("/oauth2/sign_out", p.SignOut) mux.HandleFunc("/oauth2/callback", p.OAuthCallback) mux.HandleFunc("/oauth2/auth", p.AuthenticateOnly) @@ -533,6 +576,12 @@ func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter, _ *http.Request) { fmt.Fprintf(rw, "User-agent: *\nDisallow: /") } +// Certs publishes the public key necessary for upstream services to validate the digital signature +// used to sign each request. +func (p *OAuthProxy) Certs(rw http.ResponseWriter, _ *http.Request) { + rw.Write(p.publicCertsJSON) +} + // Favicon will proxy the request as usual if the user is already authenticated // but responds with a 404 otherwise, to avoid spurious and confusing // authentication attempts when a browser automatically requests the favicon on diff --git a/internal/proxy/oauthproxy_test.go b/internal/proxy/oauthproxy_test.go index fe539363..a0ca11a6 100644 --- a/internal/proxy/oauthproxy_test.go +++ b/internal/proxy/oauthproxy_test.go @@ -422,6 +422,73 @@ func TestRobotsTxt(t *testing.T) { testutil.Equal(t, "User-agent: *\nDisallow: /", rw.Body.String()) } +func TestCerts(t *testing.T) { + opts := NewOptions() + opts.ClientID = "bazquux" + opts.ClientSecret = "foobar" + opts.CookieSecret = testEncodedCookieSecret + opts.ProviderURLString = "https://auth.sso.dev" + opts.upstreamConfigs = generateTestUpstreamConfigs("foo-internal.sso.dev") + + // Dummy key; generated only for this test. + opts.RequestSigningKey = string(`-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCy38IQCH8QyeNF +s1zA0XuIyqnTcSfYZg0nPfB+K//pFy7tIOAwmR6th8NykrxFhEQDHKNCmLXt4j8V +FDHQZtGjUBHRmAXZW8NOQ0EI1vc/Dpt09sU40JQlXZZeL+9/7iAxEfSE3TQr1k7P +Xwxpjm9rsLSn7FoLnvXco0mc6+d2jjxf4cMgJIaQLKOd783KUQzLVEvBQJ05JnpI +2xMjS0q33ltMTMGF3QZQN9i4bZKgnItomKxTJbfxftO11FTNLB7og94sWmlThAY5 +/UMjZaWYJ1g89+WUJ+KpVYyJsHPBBkaQG+NYazcLDyIowpzJ1WVkInysshpTqwT+ +UPV4at+jAgMBAAECggEAX8lxK5LRMJVcLlwRZHQJekRE0yS6WKi1jHkfywEW5qRy +jatYQs4MXpLgN/+Z8IQWw6/XQXdznTLV4xzQXDBjPNhI4ntNTotUOBnNvsUW296f +ou/uxzDy1FuchU2YLGLBPGXIEko+gOcfhu74P6J1yi5zX6UyxxxVvtR2PCEb7yDw +m2881chwMblZ5Z8uyF++ajkK3/rqLk64w29+K4ZTDbTcCp5NtBYx2qSEU7yp12rc +qscUGqxG00Abx+osI3cUn0kOq7356LeR1rfA15yZwOb+s28QYp2WPlVB2hOiYXQv ++ttEOpt0x1QJhBAsFgwY173sD5w2MryRQb1RCwBvqQKBgQDeTdbRzxzAl83h/mAq +5I+pNEz57veAFVO+iby7TbZ/0w6q+QeT+bHF+TjGHiSlbtg3nd9NPrex2UjiN7ej ++DrxhsSLsP1ZfwDNv6f1Ii1HluJclUFSUNU/LntBjqqCJ959lniNp1y5+ZQ/j2Rf ++ZraVsHRB0itilFeAl5+n7CfxwKBgQDN/K+E1TCbp1inU60Lc9zeb8fqTEP6Mp36 +qQ0Dp+KMLPJ0xQSXFq9ILr4hTJlBqfmTkfmQUcQuwercZ3LNQPbsuIg96bPW73R1 +toXjokd6jUn5sJXCOE0RDumcJrL1VRf9RN1AmM4CgCc/adUMjws3pBc5R4An7UyU +ouRQhN+5RQKBgFOVTrzqM3RSX22mWAAomb9T09FxQQueeTM91IFUMdcTwwMTyP6h +Nm8qSmdrM/ojmBYpPKlteGHdQaMUse5rybXAJywiqs84ilPRyNPJOt8c4xVOZRYP +IG62Ck/W1VNErEnqBn+0OpAOP+g6ANJ5JfkL/6mZJIFjbT58g4z2e9FHAoGBAM3f +uBkd7lgTuLJ8Gh6xLVYQCJHuqZ49ytFE9qHpwK5zGdyFMSJE5OlS9mpXoXEUjkHk +iraoUlidLbwdlIr6XBCaGmku07SFXTNtOoIZpjEhV4c762HTXYsoCWos733uD2zt +z+iJEJVFOnTRtMK5kO+KjD+Oa9L8BCcmauTi+Ku1AoGAZBUzi95THA60hPXI0hm/ +o0J5mfLkFPfhpUmDAMaEpv3bM4byA+IGXSZVc1IZO6cGoaeUHD2Yl1m9a5tv5rF+ +FS9Ht+IgATvGojah+xxQy+kf6tRB9Hn4scyq+64AesXlDbWDEagomQ0hyV/JKSS6 +LQatvnCmBd9omRT2uwYUo+o= +-----END PRIVATE KEY-----`) + opts.Validate() + + expectedPublicKey := string(`-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAst/CEAh/EMnjRbNcwNF7iMqp03En2GYNJz3wfiv/6Rcu7SDgMJke +rYfDcpK8RYREAxyjQpi17eI/FRQx0GbRo1AR0ZgF2VvDTkNBCNb3Pw6bdPbFONCU +JV2WXi/vf+4gMRH0hN00K9ZOz18MaY5va7C0p+xaC5713KNJnOvndo48X+HDICSG +kCyjne/NylEMy1RLwUCdOSZ6SNsTI0tKt95bTEzBhd0GUDfYuG2SoJyLaJisUyW3 +8X7TtdRUzSwe6IPeLFppU4QGOf1DI2WlmCdYPPfllCfiqVWMibBzwQZGkBvjWGs3 +Cw8iKMKcydVlZCJ8rLIaU6sE/lD1eGrfowIDAQAB +-----END RSA PUBLIC KEY----- +`) + + proxy, err := NewOAuthProxy(opts) + if err != nil { + t.Errorf("unexpected error %s", err) + return + } + rw := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "https://foo.sso.dev/oauth2/v1/certs", nil) + proxy.Handler().ServeHTTP(rw, req) + testutil.Equal(t, 200, rw.Code) + + var certs map[string]string + if err := json.Unmarshal([]byte(rw.Body.String()), &certs); err != nil { + t.Errorf("failed to unmarshal certs from json response: %s", err) + return + } + testutil.Equal(t, expectedPublicKey, certs["signing_key"]) +} + func TestFavicon(t *testing.T) { opts := NewOptions() opts.ClientID = "bazquux" @@ -768,6 +835,72 @@ func TestAuthSkipRequests(t *testing.T) { testutil.Equal(t, "response", allowRW.Body.String()) } +func generateTestSkipRequestSigningConfig(to string) []*UpstreamConfig { + if !strings.Contains(to, "://") { + to = fmt.Sprintf("%s://%s", "http", to) + } + parsed, err := url.Parse(to) + if err != nil { + panic(err) + } + templateVars := map[string]string{ + "root_domain": "dev", + "cluster": "sso", + } + upstreamConfigs, err := loadServiceConfigs([]byte(fmt.Sprintf(` +- service: foo + default: + from: foo.sso.dev + to: %s + options: + skip_request_signing: true + skip_auth_regex: + - ^.*$ +`, parsed)), "sso", "http", templateVars) + if err != nil { + panic(err) + } + return upstreamConfigs +} + +func TestSkipSigningRequest(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, ok := r.Header["Sso-Signature"] + testutil.Assert(t, !ok, "found unexpected SSO-Signature header in request") + + _, ok = r.Header["kid"] + testutil.Assert(t, !ok, "found unexpected signing key id header in request") + + w.WriteHeader(200) + w.Write([]byte("response")) + })) + defer upstream.Close() + + signingKey, err := ioutil.ReadFile("testdata/private_key.pem") + testutil.Assert(t, err == nil, "could not read private key from testdata: %s", err) + + opts := NewOptions() + opts.ClientID = "bazquux" + opts.ClientSecret = "foobar" + opts.CookieSecret = testEncodedCookieSecret + opts.SkipAuthPreflight = true + opts.RequestSigningKey = string(signingKey) + opts.upstreamConfigs = generateTestSkipRequestSigningConfig(upstream.URL) + opts.Validate() + + upstreamURL, _ := url.Parse(upstream.URL) + opts.provider = NewTestProvider(upstreamURL, "") + + proxy, _ := NewOAuthProxy(opts) + + // Expect OK + allowRW := httptest.NewRecorder() + allowReq, _ := http.NewRequest("GET", "https://foo.sso.dev/endpoint", nil) + proxy.Handler().ServeHTTP(allowRW, allowReq) + testutil.Equal(t, http.StatusOK, allowRW.Code) + testutil.Equal(t, "response", allowRW.Body.String()) +} + func generateMultiTestAuthSkipConfigs(toFoo, toBar string) []*UpstreamConfig { if !strings.Contains(toFoo, "://") { toFoo = fmt.Sprintf("%s://%s", "http", toFoo) @@ -975,7 +1108,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) { req.AddCookie(cookie) // This is used by the upstream to validate the signature. st.authenticator.auth = hmacauth.NewHmacAuth( - crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders) + crypto.SHA1, []byte(key), HMACSignatureHeader, SignatureHeaders) proxy.Handler().ServeHTTP(st.rw, req) } diff --git a/internal/proxy/options.go b/internal/proxy/options.go index 7ec0bee9..d7e8a89a 100644 --- a/internal/proxy/options.go +++ b/internal/proxy/options.go @@ -84,6 +84,8 @@ type Options struct { StatsdHost string `envconfig:"STATSD_HOST"` StatsdPort int `envconfig:"STATSD_PORT"` + RequestSigningKey string `envconfig:"REQUEST_SIGNATURE_KEY"` + StatsdClient *statsd.Client // This is an override for supplying template vars at test time diff --git a/internal/proxy/proxy_config.go b/internal/proxy/proxy_config.go index 0842f386..38f4ca9a 100644 --- a/internal/proxy/proxy_config.go +++ b/internal/proxy/proxy_config.go @@ -57,6 +57,7 @@ type UpstreamConfig struct { Timeout time.Duration FlushInterval time.Duration HeaderOverrides map[string]string + SkipRequestSigning bool } // RouteConfig maps to the yaml config fields, @@ -77,12 +78,13 @@ type RouteConfig struct { // * timeout - duration before timing out request. // * flush_interval - interval at which the proxy should flush data to the browser type OptionsConfig struct { - HeaderOverrides map[string]string `yaml:"header_overrides"` - SkipAuthRegex []string `yaml:"skip_auth_regex"` - AllowedGroups []string `yaml:"allowed_groups"` - TLSSkipVerify bool `yaml:"tls_skip_verify"` - Timeout time.Duration `yaml:"timeout"` - FlushInterval time.Duration `yaml:"flush_interval"` + HeaderOverrides map[string]string `yaml:"header_overrides"` + SkipAuthRegex []string `yaml:"skip_auth_regex"` + AllowedGroups []string `yaml:"allowed_groups"` + TLSSkipVerify bool `yaml:"tls_skip_verify"` + Timeout time.Duration `yaml:"timeout"` + FlushInterval time.Duration `yaml:"flush_interval"` + SkipRequestSigning bool `yaml:"skip_request_signing"` } // ErrParsingConfig is an error specific to config parsing. @@ -365,6 +367,7 @@ func parseOptionsConfig(proxy *UpstreamConfig) error { proxy.FlushInterval = proxy.RouteConfig.Options.FlushInterval proxy.HeaderOverrides = proxy.RouteConfig.Options.HeaderOverrides proxy.TLSSkipVerify = proxy.RouteConfig.Options.TLSSkipVerify + proxy.SkipRequestSigning = proxy.RouteConfig.Options.SkipRequestSigning proxy.RouteConfig.Options = nil diff --git a/internal/proxy/request_signer.go b/internal/proxy/request_signer.go new file mode 100644 index 00000000..15addf7c --- /dev/null +++ b/internal/proxy/request_signer.go @@ -0,0 +1,185 @@ +package proxy + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "io/ioutil" + "net/http" + "strings" +) + +// Only headers enumerated in this list are used to compute the signature of a request. +var signedHeaders = []string{ + "Content-Length", + "Content-Md5", + "Content-Type", + "Date", + "Authorization", + "X-Forwarded-User", + "X-Forwarded-Email", + "X-Forwarded-Groups", + "Cookie", +} + +// Name of the header used to transmit the signature computed for the request. +var signatureHeader = "Sso-Signature" +var signingKeyHeader = "kid" + +// RequestSigner exposes an interface for digitally signing requests using an RSA private key. +// See comments for the Sign() method below, for more on how this signature is constructed. +type RequestSigner struct { + hasher hash.Hash + signingKey crypto.Signer + publicKeyStr string + publicKeyID string +} + +// NewRequestSigner constructs a RequestSigner object from a PEM+PKCS8 encoded RSA public key. +func NewRequestSigner(signingKeyPemStr string) (*RequestSigner, error) { + var privateKey crypto.Signer + var publicKeyPEM []byte + + // Build private key. + if block, _ := pem.Decode([]byte(signingKeyPemStr)); block == nil { + return nil, fmt.Errorf("could not read PEM block from signing key") + } else if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, fmt.Errorf("could not read key from signing key bytes: %s", err) + } else { + privateKey = key.(crypto.Signer) + } + + // Derive public key. + rsaPublicKey, ok := privateKey.Public().(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("only RSA public keys are currently supported") + } + publicKeyPEM = pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(rsaPublicKey), + }) + + var keyHash []byte + hasher := sha256.New() + _, _ = hasher.Write(publicKeyPEM) + keyHash = hasher.Sum(keyHash) + + return &RequestSigner{ + hasher: sha256.New(), + signingKey: privateKey, + publicKeyStr: string(publicKeyPEM), + publicKeyID: string(keyHash), + }, nil +} + +// mapRequestToHashInput returns a string representation of a Request, formatted as a +// newline-separated sequence of entries from the request. Any two Requests sharing the same +// representation are considered "equivalent" for purposes of verifying the integrity of a request. +// +// Representations are formatted as follows: +// +// ... +// +// +// +// where: +// is the ','-joined concatenation of all header values of `signedHeaders[k]`; all +// other headers in the request are ignored, +// is the string "(?)(#FRAGMENT)", where "?" and "#" are +// ommitted if the associated components are absent from the request URL, +// is the body of the Request (may be `nil`; e.g. for GET requests). +// +// Receiving endpoints authenticating the integrity of a request should reconstruct this document +// exactly, when verifying the contents of a received request. +func mapRequestToHashInput(req *http.Request) (string, error) { + entries := []string{} + + // Add signed headers. + for _, hdr := range signedHeaders { + if hdrValues := req.Header[hdr]; len(hdrValues) > 0 { + entries = append(entries, strings.Join(hdrValues, ",")) + } + } + + // Add canonical URL representation. Ignore URL {scheme, host, port, etc}. + entries = append(entries, func() string { + url := req.URL.Path + if len(req.URL.RawQuery) > 0 { + url += ("?" + req.URL.RawQuery) + } + if len(req.URL.Fragment) > 0 { + url += ("#" + req.URL.Fragment) + } + return url + }()) + + // Add request body, if present (may be absent for GET requests, etc). + if req.Body != nil { + body, _ := ioutil.ReadAll(req.Body) + req.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + entries = append(entries, string(body)) + } + + // Return the join of all entries, with each separated by a newline. + return strings.Join(entries, "\n"), nil +} + +// Sign appends a header to the request, with a public-key encrypted signature derive from +// a subset of the request headers, together with the request URL and body. +// +// Signature is computed as: +// repr := Representation(request) <- Computed by mapRequestToHashInput() +// hash := SHA256(repr) +// sig := SIGN(hash, SigningKey) +// final := WEB_SAFE_BASE64(sig) +// The header `Sso-Signature` is given the value of `final`. +// +// Receiving endpoints authenticating the integrity of a request should: +// 1. Strip the WEB_SAFE_BASE64 encoding from the value of `signatureHeader`, +// 2. Decrypt the resulting value using the public key published by sso_proxy, thus obtaining the +// hash of the request representation, +// 3. Compute the request representation from the received request, using the same format as the +// mapRequestToHashInput() function above, +// 4. Apply SHA256 hash to the recomputed representation, and verify that it matches the decrypted +// hash value received through the `Sso-Signature` of the request. +// +// Any requests failing this check should be considered tampered with, and rejected. +func (signer RequestSigner) Sign(req *http.Request) error { + // Generate the request representation that will serve as hash input. + repr, err := mapRequestToHashInput(req) + if err != nil { + return fmt.Errorf("could not generate representation for request: %s", err) + } + + // Generate hash of the document buffer. + var documentHash []byte + signer.hasher.Reset() + _, _ = signer.hasher.Write([]byte(repr)) + documentHash = signer.hasher.Sum(documentHash) + + // Sign the documentHash with the signing key. + signatureBytes, err := signer.signingKey.Sign(rand.Reader, documentHash, crypto.SHA256) + if err != nil { + return fmt.Errorf("failed signing document hash with signing key: %s", err) + } + signature := base64.URLEncoding.EncodeToString(signatureBytes) + + // Set the signature and signing-key request headers. Return nil to indicate no error. + req.Header.Set(signatureHeader, signature) + req.Header.Set(signingKeyHeader, signer.publicKeyID) + return nil +} + +// PublicKey returns a PEM+PKCS1 encoded representation of the public key corresponding to the +// private key used to sign requests. +func (signer RequestSigner) PublicKey() string { + return signer.publicKeyStr +} diff --git a/internal/proxy/request_signer_test.go b/internal/proxy/request_signer_test.go new file mode 100644 index 00000000..eac83ae4 --- /dev/null +++ b/internal/proxy/request_signer_test.go @@ -0,0 +1,179 @@ +package proxy + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/buzzfeed/sso/internal/pkg/testutil" +) + +// Convenience variables and utilities. +var urlExample = "https://foo.sso.example.com/path" + +func addHeaders(req *http.Request, examples []string, extras map[string][]string) { + var signedHeaderExamples = map[string][]string{ + "Content-Length": {"1234"}, + "Content-Md5": {"F00D"}, + "Content-Type": {"application/json"}, + "Date": {"2018-11-08"}, + "Authorization": {"Bearer ab12cd34"}, + "X-Forwarded-User": {"octoboi"}, + "X-Forwarded-Email": {"octoboi@example.com"}, + "X-Forwarded-Groups": {"molluscs", "security_applications"}, + } + + for _, signedHdr := range examples { + for _, value := range signedHeaderExamples[signedHdr] { + req.Header.Add(signedHdr, value) + } + } + for extraHdr, values := range extras { + for _, value := range values { + req.Header.Add(extraHdr, value) + } + } +} + +func TestRepr_UrlRepresentation(t *testing.T) { + testURL := func(url string, expect string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Errorf("could not build request: %s", err) + } + + repr, err := mapRequestToHashInput(req) + if err != nil { + t.Errorf("could not map request to hash input: %s", err) + } + testutil.Equal(t, expect, repr) + } + + testURL("http://foo.sso.example.com/path/to/resource", "/path/to/resource") + testURL("http://foo.sso.example.com/path?", "/path") + testURL("http://foo.sso.example.com/path/to?query#fragment", "/path/to?query#fragment") + testURL("https://foo.sso.example.com:4321/path#fragment", "/path#fragment") + testURL("http://foo.sso.example.com/path?query¶m=value#", "/path?query¶m=value") +} + +func TestRepr_HeaderRepresentation(t *testing.T) { + testHeaders := func(include []string, extra map[string][]string, expect string) { + req, err := http.NewRequest("GET", urlExample, nil) + if err != nil { + t.Errorf("could not build request: %s", err) + } + addHeaders(req, include, extra) + repr, err := mapRequestToHashInput(req) + if err != nil { + t.Errorf("could not map request to hash input: %s", err) + } + testutil.Equal(t, expect, repr) + } + + // Partial set of signed headers. + testHeaders([]string{"Authorization", "X-Forwarded-Groups"}, nil, + "Bearer ab12cd34\n"+ + "molluscs,security_applications\n"+ + "/path") + + // Full set of signed headers. + testHeaders(signedHeaders, nil, + "1234\n"+ + "F00D\n"+ + "application/json\n"+ + "2018-11-08\n"+ + "Bearer ab12cd34\n"+ + "octoboi\n"+ + "octoboi@example.com\n"+ + "molluscs,security_applications\n"+ + "/path") + + // Partial set of signed headers, plus another header (should not appear in representation). + testHeaders([]string{"Authorization", "X-Forwarded-Email"}, + map[string][]string{"X-Octopus-Stuff": {"54321"}}, + "Bearer ab12cd34\n"+ + "octoboi@example.com\n"+ + "/path") + + // Only unsigned headers. + testHeaders(nil, map[string][]string{"X-Octopus-Stuff": {"83721"}}, "/path") +} + +func TestRepr_PostWithBody(t *testing.T) { + req, err := http.NewRequest("POST", urlExample, strings.NewReader("something\nor other")) + if err != nil { + t.Errorf("could not build request: %s", err) + } + addHeaders(req, []string{"X-Forwarded-Email", "X-Forwarded-Groups"}, + map[string][]string{"X-Octopus-Stuff": {"54321"}}) + + repr, err := mapRequestToHashInput(req) + if err != nil { + t.Errorf("could not map request to hash input: %s", err) + } + testutil.Equal(t, + "octoboi@example.com\n"+ + "molluscs,security_applications\n"+ + "/path\n"+ + "something\n"+ + "or other", + repr) +} + +func TestSignatureRoundTripDecoding(t *testing.T) { + // Keys used for signing/validating request. + privateKey, err := ioutil.ReadFile("testdata/private_key.pem") + testutil.Assert(t, err == nil, "error reading private key from testdata") + + publicKey, err := ioutil.ReadFile("testdata/public_key.pub") + testutil.Assert(t, err == nil, "error reading public key from testdata") + + // Build the RequestSigner object used to generate the request signature header. + requestSigner, err := NewRequestSigner(string(privateKey)) + testutil.Assert(t, err == nil, "could not initialize request signer: %s", err) + + // And build the rsa.PublicKey object that will help verify the signature. + verifierKey, err := func() (*rsa.PublicKey, error) { + if block, _ := pem.Decode(publicKey); block == nil { + return nil, fmt.Errorf("could not read PEM block from public key") + } else if key, err := x509.ParsePKCS1PublicKey(block.Bytes); err != nil { + return nil, fmt.Errorf("could not read key from public key bytes: %s", err) + } else { + return key, nil + } + }() + testutil.Assert(t, err == nil, "could not construct public key: %s", err) + + // Build the Request to be signed. + req, err := http.NewRequest("POST", urlExample, strings.NewReader("something\nor other")) + testutil.Assert(t, err == nil, "could not construct request: %s", err) + addHeaders(req, []string{"X-Forwarded-Email", "X-Forwarded-Groups"}, + map[string][]string{"X-Octopus-Stuff": {"54321"}}) + + // Sign the request, and extract its signature from the header. + err = requestSigner.Sign(req) + testutil.Assert(t, err == nil, "could not sign request: %s", err) + sig, _ := base64.URLEncoding.DecodeString(req.Header.Get("Sso-Signature")) + + // Hardcoded expected hash, computed from the request. + expectedHash, _ := hex.DecodeString( + "04158c00fbecccd8b5dca58634a0a7f28bf5ad908f19cb1b404bdd37bb4485a9") + err = rsa.VerifyPKCS1v15(verifierKey, crypto.SHA256, expectedHash, sig) + testutil.Assert(t, err == nil, "could not verify request signature: %s", err) + + // Verify that the signing-key header is the hash of the public-key. + var pubKeyHash []byte + hasher := sha256.New() + _, _ = hasher.Write(publicKey) + pubKeyHash = hasher.Sum(pubKeyHash) + testutil.Equal(t, string(pubKeyHash), req.Header.Get("kid")) +} diff --git a/internal/proxy/testdata/private_key.pem b/internal/proxy/testdata/private_key.pem new file mode 100644 index 00000000..03a16bd3 --- /dev/null +++ b/internal/proxy/testdata/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCy38IQCH8QyeNF +s1zA0XuIyqnTcSfYZg0nPfB+K//pFy7tIOAwmR6th8NykrxFhEQDHKNCmLXt4j8V +FDHQZtGjUBHRmAXZW8NOQ0EI1vc/Dpt09sU40JQlXZZeL+9/7iAxEfSE3TQr1k7P +Xwxpjm9rsLSn7FoLnvXco0mc6+d2jjxf4cMgJIaQLKOd783KUQzLVEvBQJ05JnpI +2xMjS0q33ltMTMGF3QZQN9i4bZKgnItomKxTJbfxftO11FTNLB7og94sWmlThAY5 +/UMjZaWYJ1g89+WUJ+KpVYyJsHPBBkaQG+NYazcLDyIowpzJ1WVkInysshpTqwT+ +UPV4at+jAgMBAAECggEAX8lxK5LRMJVcLlwRZHQJekRE0yS6WKi1jHkfywEW5qRy +jatYQs4MXpLgN/+Z8IQWw6/XQXdznTLV4xzQXDBjPNhI4ntNTotUOBnNvsUW296f +ou/uxzDy1FuchU2YLGLBPGXIEko+gOcfhu74P6J1yi5zX6UyxxxVvtR2PCEb7yDw +m2881chwMblZ5Z8uyF++ajkK3/rqLk64w29+K4ZTDbTcCp5NtBYx2qSEU7yp12rc +qscUGqxG00Abx+osI3cUn0kOq7356LeR1rfA15yZwOb+s28QYp2WPlVB2hOiYXQv ++ttEOpt0x1QJhBAsFgwY173sD5w2MryRQb1RCwBvqQKBgQDeTdbRzxzAl83h/mAq +5I+pNEz57veAFVO+iby7TbZ/0w6q+QeT+bHF+TjGHiSlbtg3nd9NPrex2UjiN7ej ++DrxhsSLsP1ZfwDNv6f1Ii1HluJclUFSUNU/LntBjqqCJ959lniNp1y5+ZQ/j2Rf ++ZraVsHRB0itilFeAl5+n7CfxwKBgQDN/K+E1TCbp1inU60Lc9zeb8fqTEP6Mp36 +qQ0Dp+KMLPJ0xQSXFq9ILr4hTJlBqfmTkfmQUcQuwercZ3LNQPbsuIg96bPW73R1 +toXjokd6jUn5sJXCOE0RDumcJrL1VRf9RN1AmM4CgCc/adUMjws3pBc5R4An7UyU +ouRQhN+5RQKBgFOVTrzqM3RSX22mWAAomb9T09FxQQueeTM91IFUMdcTwwMTyP6h +Nm8qSmdrM/ojmBYpPKlteGHdQaMUse5rybXAJywiqs84ilPRyNPJOt8c4xVOZRYP +IG62Ck/W1VNErEnqBn+0OpAOP+g6ANJ5JfkL/6mZJIFjbT58g4z2e9FHAoGBAM3f +uBkd7lgTuLJ8Gh6xLVYQCJHuqZ49ytFE9qHpwK5zGdyFMSJE5OlS9mpXoXEUjkHk +iraoUlidLbwdlIr6XBCaGmku07SFXTNtOoIZpjEhV4c762HTXYsoCWos733uD2zt +z+iJEJVFOnTRtMK5kO+KjD+Oa9L8BCcmauTi+Ku1AoGAZBUzi95THA60hPXI0hm/ +o0J5mfLkFPfhpUmDAMaEpv3bM4byA+IGXSZVc1IZO6cGoaeUHD2Yl1m9a5tv5rF+ +FS9Ht+IgATvGojah+xxQy+kf6tRB9Hn4scyq+64AesXlDbWDEagomQ0hyV/JKSS6 +LQatvnCmBd9omRT2uwYUo+o= +-----END PRIVATE KEY----- diff --git a/internal/proxy/testdata/public_key.pub b/internal/proxy/testdata/public_key.pub new file mode 100644 index 00000000..cccac43b --- /dev/null +++ b/internal/proxy/testdata/public_key.pub @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAst/CEAh/EMnjRbNcwNF7iMqp03En2GYNJz3wfiv/6Rcu7SDgMJke +rYfDcpK8RYREAxyjQpi17eI/FRQx0GbRo1AR0ZgF2VvDTkNBCNb3Pw6bdPbFONCU +JV2WXi/vf+4gMRH0hN00K9ZOz18MaY5va7C0p+xaC5713KNJnOvndo48X+HDICSG +kCyjne/NylEMy1RLwUCdOSZ6SNsTI0tKt95bTEzBhd0GUDfYuG2SoJyLaJisUyW3 +8X7TtdRUzSwe6IPeLFppU4QGOf1DI2WlmCdYPPfllCfiqVWMibBzwQZGkBvjWGs3 +Cw8iKMKcydVlZCJ8rLIaU6sE/lD1eGrfowIDAQAB +-----END RSA PUBLIC KEY-----