From 6e790eed747c55a2fb2e491f0c1ff00bfedfb346 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 31 Aug 2016 21:09:34 -0400 Subject: [PATCH 1/2] add initial jwt middleware --- example_config.yaml | 5 +- middleware/jwt/jwt.go | 179 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 middleware/jwt/jwt.go diff --git a/example_config.yaml b/example_config.yaml index 53c207c..908afd9 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -23,9 +23,10 @@ chihaya: prehooks: - name: jwt config: - jwk_set_uri: "" + issuer: https://issuer.com + audience: https://chihaya.issuer.com + jwk_set_uri: https://issuer.com/keys jwk_set_update_interval: 5m - jwt_audience: "" - name: approved_client config: type: whitelist diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go new file mode 100644 index 0000000..1743c1f --- /dev/null +++ b/middleware/jwt/jwt.go @@ -0,0 +1,179 @@ +// Package jwt implements a Hook that fails an Announce if the client's request +// is missing a valid JSON Web Token. +// +// JWTs are validated against the standard claims in RFC7519 along with an +// extra "infohash" claim that verifies the client has access to the Swarm. +// RS256 keys are asychronously rotated from a provided JWK Set HTTP endpoint. +package jwt + +import ( + "context" + "crypto" + "encoding/json" + "errors" + "log" + "net/http" + "net/url" + "time" + + jc "github.com/SermoDigital/jose/crypto" + "github.com/SermoDigital/jose/jws" + "github.com/SermoDigital/jose/jwt" + "github.com/mendsley/gojwk" + + "github.com/chihaya/chihaya/bittorrent" + "github.com/chihaya/chihaya/middleware" +) + +var ( + // ErrMissingJWT is returned when a JWT is missing from a request. + ErrMissingJWT = bittorrent.ClientError("unapproved request: missing jwt") + + // ErrInvalidJWT is returned when a JWT fails to verify. + ErrInvalidJWT = bittorrent.ClientError("unapproved request: invalid jwt") +) + +// Config represents all the values required by this middleware to fetch JWKs +// and verify JWTs. +type Config struct { + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + JWKSetURL string `yaml:"jwk_set_url"` + JWKUpdateInterval time.Duration `yaml:"jwk_set_update_interval"` +} + +type hook struct { + cfg Config + publicKeys map[string]crypto.PublicKey + closing chan struct{} +} + +// NewHook returns an instance of the JWT middleware. +func NewHook(cfg Config) middleware.Hook { + h := &hook{ + cfg: cfg, + publicKeys: map[string]crypto.PublicKey{}, + closing: make(chan struct{}), + } + + go func() { + for { + select { + case <-h.closing: + return + case <-time.After(cfg.JWKUpdateInterval): + resp, err := http.Get(cfg.JWKSetURL) + if err != nil { + log.Println("failed to fetch JWK Set: " + err.Error()) + continue + } + + parsedJWKs := map[string]gojwk.Key{} + err = json.NewDecoder(resp.Body).Decode(&parsedJWKs) + if err != nil { + resp.Body.Close() + log.Println("failed to decode JWK JSON: " + err.Error()) + continue + } + resp.Body.Close() + + keys := map[string]crypto.PublicKey{} + for kid, parsedJWK := range parsedJWKs { + publicKey, err := parsedJWK.DecodePublicKey() + if err != nil { + log.Println("failed to decode JWK into public key: " + err.Error()) + continue + } + keys[kid] = publicKey + } + h.publicKeys = keys + } + } + }() + + return h +} + +func (h *hook) Stop() { + close(h.closing) +} + +func (h *hook) HandleAnnounce(ctx context.Context, req *bittorrent.AnnounceRequest, resp *bittorrent.AnnounceResponse) error { + if req.Params == nil { + return ErrMissingJWT + } + + jwtParam, ok := req.Params.String("jwt") + if !ok { + return ErrMissingJWT + } + + if err := validateJWT(req.InfoHash, []byte(jwtParam), h.cfg.Issuer, h.cfg.Audience, h.publicKeys); err != nil { + return ErrInvalidJWT + } + + return nil +} + +func (h *hook) HandleScrape(ctx context.Context, req *bittorrent.ScrapeRequest, resp *bittorrent.ScrapeResponse) error { + // Scrapes don't require any protection. + return nil +} + +func validateJWT(ih bittorrent.InfoHash, jwtBytes []byte, cfgIss, cfgAud string, publicKeys map[string]crypto.PublicKey) error { + parsedJWT, err := jws.ParseJWT(jwtBytes) + if err != nil { + return err + } + + claims := parsedJWT.Claims() + if iss, ok := claims.Issuer(); !ok || iss != cfgIss { + return jwt.ErrInvalidISSClaim + } + + if aud, ok := claims.Audience(); !ok || !validAudience(aud, cfgAud) { + return jwt.ErrInvalidAUDClaim + } + + if ihClaim, ok := claims.Get("infohash").(string); !ok || !validInfoHash(ihClaim, ih) { + return errors.New("claim \"infohash\" is invalid") + } + + parsedJWS := parsedJWT.(jws.JWS) + kid, ok := parsedJWS.Protected().Get("kid").(string) + if !ok { + return errors.New("invalid kid") + } + publicKey, ok := publicKeys[kid] + if !ok { + return errors.New("signed by unknown kid") + } + + return parsedJWS.Verify(publicKey, jc.SigningMethodRS256) +} + +func validAudience(aud []string, cfgAud string) bool { + for _, a := range aud { + if a == cfgAud { + return true + } + } + return false +} + +func validInfoHash(claim string, ih bittorrent.InfoHash) bool { + if len(claim) == 20 && bittorrent.InfoHashFromString(claim) == ih { + return true + } + + unescapedClaim, err := url.QueryUnescape(claim) + if err != nil { + return false + } + + if len(unescapedClaim) == 20 && bittorrent.InfoHashFromString(unescapedClaim) == ih { + return true + } + + return false +} From e39da6b4e6cd89833a961480b4980cbeebae06e7 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 31 Aug 2016 21:09:46 -0400 Subject: [PATCH 2/2] main: add CreateHooks() method for ConfigFile This change simplifies middleware.Logic to having only one list of PreHooks and one list of PostHooks. --- cmd/chihaya/config.go | 36 +++++++++++++++++++++++++++++++ cmd/chihaya/main.go | 8 +++++-- middleware/middleware.go | 46 +++++++++++++++------------------------- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/cmd/chihaya/config.go b/cmd/chihaya/config.go index 26b11e3..04921e3 100644 --- a/cmd/chihaya/config.go +++ b/cmd/chihaya/config.go @@ -10,9 +10,15 @@ import ( httpfrontend "github.com/chihaya/chihaya/frontend/http" udpfrontend "github.com/chihaya/chihaya/frontend/udp" "github.com/chihaya/chihaya/middleware" + "github.com/chihaya/chihaya/middleware/jwt" "github.com/chihaya/chihaya/storage/memory" ) +type hookConfig struct { + Name string `yaml:"name"` + Config interface{} `yaml:"config"` +} + // ConfigFile represents a namespaced YAML configation file. type ConfigFile struct { MainConfigBlock struct { @@ -21,6 +27,8 @@ type ConfigFile struct { HTTPConfig httpfrontend.Config `yaml:"http"` UDPConfig udpfrontend.Config `yaml:"udp"` Storage memory.Config `yaml:"storage"` + PreHooks []hookConfig `yaml:"prehooks"` + PostHooks []hookConfig `yaml:"posthooks"` } `yaml:"chihaya"` } @@ -52,3 +60,31 @@ func ParseConfigFile(path string) (*ConfigFile, error) { return &cfgFile, nil } + +// CreateHooks creates instances of Hooks for all of the PreHooks and PostHooks +// configured in a ConfigFile. +func (cfg ConfigFile) CreateHooks() (preHooks, postHooks []middleware.Hook, err error) { + for _, hookCfg := range cfg.MainConfigBlock.PreHooks { + cfgBytes, err := yaml.Marshal(hookCfg.Config) + if err != nil { + panic("failed to remarshal valid YAML") + } + + switch hookCfg.Name { + case "jwt": + var jwtCfg jwt.Config + err := yaml.Unmarshal(cfgBytes, &jwtCfg) + if err != nil { + return nil, nil, errors.New("invalid JWT middleware config" + err.Error()) + } + preHooks = append(preHooks, jwt.NewHook(jwtCfg)) + } + } + + for _, hookCfg := range cfg.MainConfigBlock.PostHooks { + switch hookCfg.Name { + } + } + + return +} diff --git a/cmd/chihaya/main.go b/cmd/chihaya/main.go index 7faf9b4..3473717 100644 --- a/cmd/chihaya/main.go +++ b/cmd/chihaya/main.go @@ -54,8 +54,12 @@ func rootCmdRun(cmd *cobra.Command, args []string) error { return err } - // TODO create Hooks - logic := middleware.NewLogic(cfg.Config, peerStore, nil, nil, nil, nil) + preHooks, postHooks, err := configFile.CreateHooks() + if err != nil { + return err + } + + logic := middleware.NewLogic(cfg.Config, peerStore, preHooks, postHooks) if err != nil { return err } diff --git a/middleware/middleware.go b/middleware/middleware.go index 18cb64b..0778ce0 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -18,30 +18,20 @@ type Config struct { var _ frontend.TrackerLogic = &Logic{} -func NewLogic(config Config, peerStore storage.PeerStore, announcePreHooks, announcePostHooks, scrapePreHooks, scrapePostHooks []Hook) *Logic { +func NewLogic(cfg Config, peerStore storage.PeerStore, preHooks, postHooks []Hook) *Logic { l := &Logic{ - announceInterval: config.AnnounceInterval, - peerStore: peerStore, - announcePreHooks: announcePreHooks, - announcePostHooks: announcePostHooks, - scrapePreHooks: scrapePreHooks, - scrapePostHooks: scrapePostHooks, + announceInterval: cfg.AnnounceInterval, + peerStore: peerStore, + preHooks: preHooks, + postHooks: postHooks, } - if len(l.announcePreHooks) == 0 { - l.announcePreHooks = []Hook{nopHook{}} + if len(l.preHooks) == 0 { + l.preHooks = []Hook{nopHook{}} } - if len(l.announcePostHooks) == 0 { - l.announcePostHooks = []Hook{nopHook{}} - } - - if len(l.scrapePreHooks) == 0 { - l.scrapePreHooks = []Hook{nopHook{}} - } - - if len(l.scrapePostHooks) == 0 { - l.scrapePostHooks = []Hook{nopHook{}} + if len(l.postHooks) == 0 { + l.postHooks = []Hook{nopHook{}} } return l @@ -50,12 +40,10 @@ func NewLogic(config Config, peerStore storage.PeerStore, announcePreHooks, anno // Logic is an implementation of the TrackerLogic that functions by // executing a series of middleware hooks. type Logic struct { - announceInterval time.Duration - peerStore storage.PeerStore - announcePreHooks []Hook - announcePostHooks []Hook - scrapePreHooks []Hook - scrapePostHooks []Hook + announceInterval time.Duration + peerStore storage.PeerStore + preHooks []Hook + postHooks []Hook } // HandleAnnounce generates a response for an Announce. @@ -63,7 +51,7 @@ func (l *Logic) HandleAnnounce(ctx context.Context, req *bittorrent.AnnounceRequ resp := &bittorrent.AnnounceResponse{ Interval: l.announceInterval, } - for _, h := range l.announcePreHooks { + for _, h := range l.preHooks { if err := h.HandleAnnounce(ctx, req, resp); err != nil { return nil, err } @@ -75,7 +63,7 @@ func (l *Logic) HandleAnnounce(ctx context.Context, req *bittorrent.AnnounceRequ // AfterAnnounce does something with the results of an Announce after it has // been completed. func (l *Logic) AfterAnnounce(ctx context.Context, req *bittorrent.AnnounceRequest, resp *bittorrent.AnnounceResponse) { - for _, h := range l.announcePostHooks { + for _, h := range l.postHooks { if err := h.HandleAnnounce(ctx, req, resp); err != nil { log.Println("chihaya: post-announce hooks failed:", err.Error()) return @@ -88,7 +76,7 @@ func (l *Logic) HandleScrape(ctx context.Context, req *bittorrent.ScrapeRequest) resp := &bittorrent.ScrapeResponse{ Files: make(map[bittorrent.InfoHash]bittorrent.Scrape), } - for _, h := range l.scrapePreHooks { + for _, h := range l.preHooks { if err := h.HandleScrape(ctx, req, resp); err != nil { return nil, err } @@ -100,7 +88,7 @@ func (l *Logic) HandleScrape(ctx context.Context, req *bittorrent.ScrapeRequest) // AfterScrape does something with the results of a Scrape after it has been // completed. func (l *Logic) AfterScrape(ctx context.Context, req *bittorrent.ScrapeRequest, resp *bittorrent.ScrapeResponse) { - for _, h := range l.scrapePostHooks { + for _, h := range l.postHooks { if err := h.HandleScrape(ctx, req, resp); err != nil { log.Println("chihaya: post-scrape hooks failed:", err.Error()) return