diff --git a/cmd/clair/httpauth.go b/cmd/clair/httpauth.go new file mode 100644 index 0000000000..cb835fca97 --- /dev/null +++ b/cmd/clair/httpauth.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "net/http" + "strings" +) + +// AuthCheck is an interface that reports whether the passed request should be +// allowed to continue. +type AuthCheck interface { + Check(context.Context, *http.Request) bool +} + +type authHandler struct { + auth AuthCheck + next http.Handler +} + +func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !h.auth.Check(r.Context(), r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + h.next.ServeHTTP(w, r) +} + +// AuthHandler returns a Handler that gates access to the passed Handler behind +// the passed AuthCheck. +func AuthHandler(h http.Handler, f AuthCheck) http.Handler { + return &authHandler{ + auth: f, + next: h, + } +} + +func fromHeader(r *http.Request) (string, bool) { + hs, ok := r.Header["Authorization"] + if !ok { + return "", false + } + for _, h := range hs { + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer "), true + } + } + return "", false +} diff --git a/cmd/clair/httpauth_keyserver.go b/cmd/clair/httpauth_keyserver.go new file mode 100644 index 0000000000..cda330f543 --- /dev/null +++ b/cmd/clair/httpauth_keyserver.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" + + "github.com/gregjones/httpcache" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type ks struct { + root *url.URL + client *http.Client + mu sync.RWMutex + cache map[string]*jose.JSONWebKey +} + +// Check implements AuthCheck. +func (s *ks) Check(ctx context.Context, r *http.Request) bool { + wt, ok := fromHeader(r) + if !ok { + return false + } + tok, err := jwt.ParseSigned(wt) + if err != nil { + return false + } + aud, err := r.URL.Parse("/") + if err != nil { + return false + } + // Need to find the key id. + ok = false + var kid string + for _, h := range tok.Headers { + if h.Algorithm == string(jose.RS256) { + ok = true + kid = h.KeyID + break + } + } + if !ok { + return false + } + // Need to pull out the issuer to fetch the key. We cannot return "true" + // until *after* a safe Claims call succeeds. + cl := jwt.Claims{} + if err := tok.UnsafeClaimsWithoutVerification(&cl); err != nil { + return false + } + uri, err := s.root.Parse(path.Join("./", "services", cl.Issuer, "keys", kid)) + if err != nil { + return false + } + ck := cl.Issuer + "+" + kid + + // This request will be cached according to the cache-control headers. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil) + if err != nil { + return false + } + req.URL = uri + res, err := s.client.Do(req) + if res != nil { + defer res.Body.Close() + } + if err != nil { + return false + } + if res.StatusCode != http.StatusOK { + // If the keyserver returns a non-OK, we can't use the key: it doesn't + // exist or is expired or is not yet approved, so make sure to delete it + // from our cache. Delete is a no-op if we don't have the key. + s.mu.Lock() + delete(s.cache, ck) + s.mu.Unlock() + return false + } + s.mu.RLock() + jwk, ok := s.cache[ck] + s.mu.RUnlock() + // If not in our deserialized cache or our response has been served from the + // remote server, do the deserializtion and cache it. + if !ok || res.Header.Get(httpcache.XFromCache) != "" { + jwk = &jose.JSONWebKey{} + if err := json.NewDecoder(res.Body).Decode(jwk); err != nil { + return false + } + s.mu.Lock() + // Only store if we didn't get beaten by another request. + if _, ok := s.cache[ck]; !ok { + s.cache[ck] = jwk + } + s.mu.Unlock() + } + + if err := tok.Claims(jwk.Key, &cl); err != nil { + return false + } + // Returning true is now possible. + if err := cl.ValidateWithLeeway(jwt.Expected{ + Audience: jwt.Audience{strings.TrimRight(aud.String(), "/")}, + Time: time.Now(), + }, 15*time.Second); err != nil { + return false + } + return true +} + +// QuayKeyserver returns an AuthCheck that validates JWTs by fetching keys from the +// Quay at "api". +// +// It follows the algorithm outlined here: +// https://github.com/quay/jwtproxy/tree/master/jwt/keyserver/keyregistry#verifier +func QuayKeyserver(api string) (AuthCheck, error) { + root, err := url.Parse(api) + if err != nil { + return nil, err + } + + t := httpcache.NewMemoryCacheTransport() + t.MarkCachedResponses = true + return &ks{ + client: t.Client(), + root: root, + cache: make(map[string]*jose.JSONWebKey), + }, nil +} diff --git a/cmd/clair/httpauth_keyserver_test.go b/cmd/clair/httpauth_keyserver_test.go new file mode 100644 index 0000000000..84b7fa000d --- /dev/null +++ b/cmd/clair/httpauth_keyserver_test.go @@ -0,0 +1,309 @@ +package main + +import ( + "bytes" + "context" + crand "crypto/rand" + "crypto/rsa" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strconv" + "strings" + "testing" + "time" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +var ( + // KeyserverAPI indicates the root of a keyserver protocol server. For a + // Project Quay instance, this is probably "http://server/keys/". + keyserverAPI = flag.String("keyserver", "", "URI for a server implementing the jwtproxy keyserver protocol.") +) + +// KeyserverConfig is the data cached for integration testing. +type keyserverConfig struct { + JWK jose.JSONWebKey + URI string + Expiration time.Time +} + +// Load loads the Config from the named file, creating it if it does not exist. +func (c *keyserverConfig) Load(t *testing.T, file string) { + kf, err := os.OpenFile(file, os.O_CREATE|os.O_RDWR, 0640) + if err != nil { + t.Fatal(err) + } + defer kf.Close() + fi, err := kf.Stat() + if err != nil { + t.Fatal(err) + } + if fi.Size() == 0 { + if *keyserverAPI == "" { + t.Skip("'keyserver' flag not provided") + } + c.URI = *keyserverAPI + c.JWK = newJWK(t) + c.Expiration = time.Now().AddDate(1, 0, 0) + if err := json.NewEncoder(kf).Encode(c); err != nil { + t.Fatal(err) + } + kf.Sync() + return + } + if err := json.NewDecoder(kf).Decode(c); err != nil { + t.Fatal(err) + } +} + +// Regen creates a new key and writes it out to the specified file. +func (c *keyserverConfig) Regen(t *testing.T, file string) { + kf, err := os.OpenFile(file, os.O_WRONLY, 0640) + if err != nil { + t.Fatal(err) + } + defer kf.Close() + c.JWK = newJWK(t) + c.Expiration = time.Now().AddDate(1, 0, 0) + if err := json.NewEncoder(kf).Encode(c); err != nil { + t.Fatal(err) + } + kf.Sync() +} + +// Public returns the public key. +func (c *keyserverConfig) Public() jose.JSONWebKey { + return c.JWK.Public() +} + +// Signer returns a Signer using the key specified in the config. +func (c *keyserverConfig) Signer() (jose.Signer, error) { + sk := jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(c.JWK.Algorithm), + Key: c.JWK.Key, + } + opts := jose.SignerOptions{ + ExtraHeaders: map[jose.HeaderKey]interface{}{ + jose.HeaderKey("kid"): c.JWK.KeyID, + }, + } + return jose.NewSigner(sk, &opts) +} + +// TestKeyserver runs a test against a live Quay keyserver. +func TestKeyserver(t *testing.T) { + t.Parallel() + const ( + iss = `clair_integration` + configfile = `testdata/keyserver.config` + ) + ctx, done := context.WithCancel(context.Background()) + defer done() + + // Stash our keyserver config and re-use it if present. + var Config keyserverConfig + Config.Load(t, configfile) + root, err := url.Parse(Config.URI) + if err != nil { + t.Fatal(err) + } + signer, err := Config.Signer() + if err != nil { + t.Fatal(err) + } + + // Print what we're doing. + t.Logf("using API rooted at: %v", root) + t.Logf("using key id: %v", Config.JWK.KeyID) + + // Test server liveness, bail if not. + tctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + req, err := http.NewRequestWithContext(tctx, http.MethodGet, root.String(), nil) + if err != nil { + t.Fatal(err) + } + res, err := http.DefaultClient.Do(req) + if res != nil { + // Don't actually care about the response. + res.Body.Close() + } + if err != nil { + // TODO Decide if this should be hidden behind a tag like the claircore + // integration tests, or just skipped if setup is missing. + t.Skipf("skipping because of keyserver error: %v", err) + } + + keyURL, err := root.Parse(path.Join("services", iss, "keys", Config.JWK.KeyID)) + if err != nil { + t.Fatal(err) + } +Install: + // Keep asking for updates on this key. Once it's in place, we'll construct + // an AuthCheck to use this key and server. + for { + res, err = http.DefaultClient.Get(keyURL.String()) + if res != nil { + defer res.Body.Close() + } + if err != nil { + t.Fatal(err) + } + switch res.StatusCode { + // If OK, check the expiration on our key and optionally rotate it. + case http.StatusOK: + t.Log("key correctly installed") + if Config.Expiration.Sub(time.Now()).Hours() > float64(7*24) { + break Install + } + t.Log("key expiring within a week, rotating.") + Config.Regen(t, configfile) + // If we fall through here, the new key with be signed with the + // previous one, meaning it should be chained properly and installed + // without user interaction. + fallthrough + // If not found, upload the key as an initial upload. + case http.StatusNotFound: + t.Logf("uploading key %v", Config.JWK.KeyID) + et := strconv.FormatInt(Config.Expiration.Unix(), 10) + keyURL.RawQuery = url.Values{"expiration": {et}}.Encode() + audURI, err := root.Parse("/") + if err != nil { + t.Fatal(err) + } + aud := strings.TrimRight(audURI.String(), "/") + + keyjson, err := json.Marshal(Config.Public()) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + cl := jwt.Claims{ + Issuer: iss, + Audience: jwt.Audience{aud}, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(10 * time.Minute)), + NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)), + } + + auth, err := jwt.Signed(signer).Claims(cl).Claims(jti()).CompactSerialize() + if err != nil { + t.Fatal(err) + } + tctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + pr, err := http.NewRequestWithContext(tctx, http.MethodPut, keyURL.String(), bytes.NewReader(keyjson)) + if err != nil { + t.Fatal(err) + } + pr.Header.Set("content-type", "application/json") + pr.Header.Set("authorization", "Bearer "+auth) + + res, err := http.DefaultClient.Do(pr) + if res != nil { + defer res.Body.Close() + } + if err != nil { + t.Fatal(err) + } + + if res.StatusCode != http.StatusAccepted { + t.Fatalf("unexpected response: %d %s", res.StatusCode, res.Status) + } + // If conflicted, prompt the user to go approve the key. + case http.StatusConflict: + prompt, err := root.Parse("../superuser/?tab=servicekeys") + if err != nil { + t.Error(err) + } + // This is bad form, but we need to prompt for user interaction. + fmt.Fprintf(os.Stderr, "key %q awaiting approval: %v\n", Config.JWK.KeyID, prompt) + time.Sleep(10 * time.Second) + // If forbidden, nuke the key and restart. + case http.StatusForbidden: + t.Logf("key %q expired, restarting test", Config.JWK.KeyID) + if *keyserverAPI == "" { + *keyserverAPI = Config.URI + } + os.Remove(configfile) + TestKeyserver(t) + return + // If any other status, bail. + default: + t.Fatal(res.Status) + } + } + + now := time.Now() + ks, err := QuayKeyserver(root.String()) + if err != nil { + t.Fatal(err) + } + + // Construct and sign a request using the live key. + const checkAud = `http://example.com` + req, err = http.NewRequest(http.MethodGet, checkAud+"/test", nil) + if err != nil { + t.Fatal(err) + } + cl := jwt.Claims{ + Issuer: iss, + Audience: jwt.Audience{checkAud}, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(+5 * time.Second)), + NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)), + } + auth, err := jwt.Signed(signer).Claims(cl).CompactSerialize() + if err != nil { + t.Error(err) + } + req.Header.Set("authorization", "Bearer "+auth) + + // Finally, check that we can fetch and validate with the key. + if !ks.Check(ctx, req) { + t.Error("check failed") + } +} + +type JTI struct { + JTI string `json:"jti"` +} + +// Jti returns a claim containing a random JWT ID. +func jti() JTI { + b := make([]byte, 16) + if _, err := io.ReadFull(crand.Reader, b); err != nil { + panic(err) + } + return JTI{JTI: hex.EncodeToString(b)} +} + +// NewJWK generates and returns a new JWK. +func newJWK(t *testing.T) jose.JSONWebKey { + const alg = jose.RS256 + // generate an ID + b := make([]byte, 8) + if _, err := io.ReadFull(crand.Reader, b); err != nil { + t.Fatal(err) + } + kid := hex.EncodeToString(b) + // generate a key + key, err := rsa.GenerateKey(crand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwk := jose.JSONWebKey{Key: key, KeyID: kid, Algorithm: string(alg), Use: "sig"} + if !jwk.Valid() { + t.Fatal("jwk not valid") + } + return jwk +} diff --git a/cmd/clair/httpauth_psk.go b/cmd/clair/httpauth_psk.go new file mode 100644 index 0000000000..48fdd86a3e --- /dev/null +++ b/cmd/clair/httpauth_psk.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "net/http" + "time" + + "gopkg.in/square/go-jose.v2/jwt" +) + +type psk struct { + key []byte + iss string +} + +func (p *psk) Check(_ context.Context, r *http.Request) bool { + wt, ok := fromHeader(r) + if !ok { + return false + } + tok, err := jwt.ParseSigned(wt) + if err != nil { + return false + } + cl := jwt.Claims{} + if err := tok.Claims(p.key, &cl); err != nil { + return false + } + if err := cl.ValidateWithLeeway(jwt.Expected{ + Issuer: p.iss, + Time: time.Now(), + }, 15*time.Second); err != nil { + return false + } + return true +} + +// PSKAuth returns an AuthCheck that validates a JWT with the supplied key and +// ensures the issuer claim matches. +func PSKAuth(key []byte, issuer string) (AuthCheck, error) { + return &psk{ + key: key, + iss: issuer, + }, nil +} diff --git a/cmd/clair/httpauth_psk_test.go b/cmd/clair/httpauth_psk_test.go new file mode 100644 index 0000000000..98109ab27f --- /dev/null +++ b/cmd/clair/httpauth_psk_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "testing/quick" + "time" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type pskTestcase struct { + key []byte + issuer string + nonce string + alg jose.SignatureAlgorithm +} + +func (tc *pskTestcase) String() string { + return fmt.Sprintf("\nkey:\t%x\nissuer:\t%s\nnonce:\t%s", tc.key, tc.issuer, tc.nonce) +} + +var signAlgo = []jose.SignatureAlgorithm{ + jose.EdDSA, + jose.HS256, + jose.HS384, + jose.HS512, + jose.RS256, + jose.RS384, + jose.RS512, + jose.ES256, + jose.ES384, + jose.ES512, + jose.PS256, + jose.PS384, + jose.PS512, +} + +func (tc *pskTestcase) Generate(rand *rand.Rand, sz int) reflect.Value { + b := make([]byte, sz) + n := &pskTestcase{ + key: make([]byte, sz), + alg: signAlgo[rand.Intn(len(signAlgo))], + } + switch n, err := rand.Read(n.key); { + case n != sz: + panic(fmt.Errorf("read %d, expected %d", n, sz)) + case err != nil: + panic(err) + } + + for _, t := range []*string{ + &n.issuer, + &n.nonce, + } { + switch n, err := rand.Read(b); { + case n != sz: + panic(fmt.Errorf("read %d, expected %d", n, sz)) + case err != nil: + panic(err) + } + *t = base64.StdEncoding.EncodeToString(b) + } + + return reflect.ValueOf(n) +} + +func (tc *pskTestcase) Handler() (http.Handler, error) { + af, err := PSKAuth(tc.key, tc.issuer) + if err != nil { + return nil, err + } + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.nonce) + }) + return AuthHandler(h, af), nil +} + +// Roundtrips returns a function suitable for passing to quick.Check. +func roundtrips(t *testing.T) func(*pskTestcase) bool { + return func(tc *pskTestcase) bool { + t.Log(tc) + // Set up the jwt signer. + sk := jose.SigningKey{ + Algorithm: jose.HS256, + Key: tc.key, + } + s, err := jose.NewSigner(sk, nil) + if err != nil { + t.Error(err) + return false + } + now := time.Now() + + // Mint the jwt. + tok, err := jwt.Signed(s).Claims(&jwt.Claims{ + Issuer: tc.issuer, + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }).CompactSerialize() + if err != nil { + t.Error(err) + return false + } + + // Set up the http server. + h, err := tc.Handler() + if err != nil { + t.Error(err) + return false + } + srv := httptest.NewServer(h) + defer srv.Close() + + // Mint a request. + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + if err != nil { + t.Error(err) + return false + } + req.Header.Set("authorization", "Bearer "+tok) + + // Execute the request and read back the body. + res, err := srv.Client().Do(req) + if err != nil { + t.Error(err) + return false + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Error(fmt.Errorf("unexpected response: %d %s", res.StatusCode, res.Status)) + return false + } + buf := &bytes.Buffer{} + if _, err := buf.ReadFrom(res.Body); err != nil { + t.Error(err) + return false + } + + // Compare the body read to the nonce we were expecting. + t.Logf("\nread:\t%s", buf.String()) + if got, want := buf.String(), tc.nonce; got != want { + t.Error(fmt.Errorf("got: %q, want: %q", got, want)) + return false + } + return true + } +} + +// TestPSKAuth generates random keys and checks signing with it. +func TestPSKAuth(t *testing.T) { + t.Parallel() + // Generate random keys and check them via the roundtrips function. + cfg := quick.Config{} + if err := quick.Check(roundtrips(t), &cfg); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/clair/httptransport.go b/cmd/clair/httptransport.go index 8a28e3b38c..137019934e 100644 --- a/cmd/clair/httptransport.go +++ b/cmd/clair/httptransport.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/base64" "fmt" "net/http" "time" @@ -20,16 +21,25 @@ const ( // httptransport configures an http server according to Clair's operation mode. func httptransport(ctx context.Context, conf config.Config) (*http.Server, error) { + var srv *http.Server + var err error switch { case conf.Mode == config.DevMode: - return devMode(ctx, conf) + srv, err = devMode(ctx, conf) case conf.Mode == config.IndexerMode: - return indexerMode(ctx, conf) + srv, err = indexerMode(ctx, conf) case conf.Mode == config.MatcherMode: - return matcherMode(ctx, conf) + srv, err = matcherMode(ctx, conf) default: return nil, fmt.Errorf("mode not implemented: %v", conf.Mode) } + if err != nil { + return nil, err + } + if err := setAuth(srv, conf); err != nil { + return nil, err + } + return srv, nil } func devMode(ctx context.Context, conf config.Config) (*http.Server, error) { @@ -109,3 +119,45 @@ func matcherMode(ctx context.Context, conf config.Config) (*http.Server, error) Handler: Compress(matcher), }, nil } + +func setAuth(srv *http.Server, conf config.Config) error { + switch conf.Auth.Name { + case "keyserver": + const param = "api" + api, ok := conf.Auth.Params[param] + if !ok { + return fmt.Errorf("missing needed config key: %q", param) + } + ks, err := QuayKeyserver(api) + if err != nil { + return err + } + srv.Handler = AuthHandler(srv.Handler, ks) + case "psk": + const ( + iss = "issuer" + key = "key" + ) + ek, ok := conf.Auth.Params[key] + if !ok { + return fmt.Errorf("missing needed config key: %q", key) + } + k, err := base64.StdEncoding.DecodeString(ek) + if err != nil { + return err + } + i, ok := conf.Auth.Params[iss] + if !ok { + return fmt.Errorf("missing needed config key: %q", iss) + } + psk, err := PSKAuth(k, i) + if err != nil { + return err + } + srv.Handler = AuthHandler(srv.Handler, psk) + case "": + default: + return fmt.Errorf("unknown auth kind %q", conf.Auth.Name) + } + return nil +} diff --git a/cmd/clair/main.go b/cmd/clair/main.go index 5024d06825..6072cf5d6b 100644 --- a/cmd/clair/main.go +++ b/cmd/clair/main.go @@ -10,10 +10,11 @@ import ( "strings" "time" - "github.com/quay/clair/v4/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" yaml "gopkg.in/yaml.v2" + + "github.com/quay/clair/v4/config" ) const ( diff --git a/cmd/clair/main_test.go b/cmd/clair/main_test.go new file mode 100644 index 0000000000..749cd4b721 --- /dev/null +++ b/cmd/clair/main_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "flag" + "os" + "testing" +) + +// TestMain is here to call flag.Parse for the keyserver test. +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} diff --git a/cmd/clair/testdata/.gitignore b/cmd/clair/testdata/.gitignore new file mode 100644 index 0000000000..ddab71da87 --- /dev/null +++ b/cmd/clair/testdata/.gitignore @@ -0,0 +1 @@ +keyserver.config diff --git a/config/config.go b/config/config.go index 6d3274fec1..e380f0b6be 100644 --- a/config/config.go +++ b/config/config.go @@ -26,6 +26,12 @@ type Config struct { Indexer Indexer `yaml:"indexer"` // matcher mode specific config Matcher Matcher `yaml:"matcher"` + Auth Auth `yaml:"auth"` +} + +type Auth struct { + Name string `yaml:"name"` + Params map[string]string `yaml:"params"` } type Indexer struct { diff --git a/go.mod b/go.mod index 25ad2b3529..016298b9c0 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/quay/clair/v4 go 1.13 require ( + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/klauspost/compress v1.9.4 github.com/mattn/go-sqlite3 v1.11.0 // indirect github.com/quay/claircore v0.0.14 github.com/rs/zerolog v1.16.0 golang.org/x/tools v0.0.0-20191210200704-1bcf67c9cb49 // indirect + gopkg.in/square/go-jose.v2 v2.4.1 gopkg.in/yaml.v2 v2.2.5 ) diff --git a/go.sum b/go.sum index aa098dca48..89b786a4ae 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsC github.com/googleapis/gnostic v0.2.2/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -358,6 +360,8 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y= +gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=