From 82459297a42112a7ea32be5fdb3785e6ddb02a4a Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 5 Jan 2016 16:57:15 -0500 Subject: [PATCH] add support for jwt validation of infohashes --- CONFIGURATION.md | 23 ++++++ README.md | 9 ++- config/config.go | 7 ++ example_config.json | 3 + http/tracker.go | 6 ++ tracker/announce.go | 8 ++- tracker/jwt.go | 147 +++++++++++++++++++++++++++++++++++++++ tracker/models/models.go | 1 + tracker/tracker.go | 44 +++++++++--- 9 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 tracker/jwt.go diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 63bdab5..81370b0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -212,3 +212,26 @@ Whether the information about memory should be verbose. default: "5s" Interval at which to collect statistics about memory. + + +##### `jwkSetURI` + + type: string + default: "" + +If this string is not empty, then the tracker will attempt to use JWTs to validate infohashes before announces. The format for the JSON at this endpoint can be found at [the RFC for JWKs](https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41#page-10) with the addition of an "issuer" key. Simply stated, this feature requires two fields at this JSON endpoint: "keys" and "issuer". "keys" is a list of JWKs that can be used to validate JWTs and "issuer" should match the "iss" claim in the JWT. The lifetime of a JWK is based upon standard HTTP caching headers and falls back to 5 minutes if no cache headers are provided. + + +#### `jwkSetUpdateInterval` + + type: duration + default: "5m" + +The interval at which keys are updated from JWKSetURI. Because the fallback lifetime for keys without cache headers is 5 minutes, this value should never be below 5 minutes unless you know your jwkSetURI has caching headers. + +#### `jwtAudience` + + type: string + default: "" + +The audience claim that is used to validate JWTs. diff --git a/README.md b/README.md index 8e4a075..fa5850f 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,18 @@ programming language. It is still heavily under development and the current `master` branch should probably not be used in production (unless you know what you're doing). -Features include: +Current features include: - Public tracker feature-set with full compatibility with what exists of the BitTorrent spec -- Private tracker feature-set with compatibility for a [Gazelle]-like deployment (WIP) - Low resource consumption, and fast, asynchronous request processing - Full IPv6 support, including handling for dual-stacked peers - Extensive metrics for visibility into the tracker and swarm's performance - Ability to prioritize peers in local subnets to reduce backbone contention -- Pluggable backend driver that can coordinate with an external database +- JWT Validation to approve the usage of a given infohash. + +Planned features include: + +- Private tracker feature-set with compatibility for a [Gazelle]-like deployment (WIP) [BitTorrent tracker]: http://en.wikipedia.org/wiki/BitTorrent_tracker [gazelle]: https://github.com/whatcd/gazelle diff --git a/config/config.go b/config/config.go index fe50f45..87f93f6 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,10 @@ type TrackerConfig struct { NumWantFallback int `json:"defaultNumWant"` TorrentMapShards int `json:"torrentMapShards"` + JWKSetURI string `json:"jwkSetURI"` + JWKSetUpdateInterval Duration `json:"jwkSetUpdateInterval"` + JWTAudience string `json:"jwtAudience"` + NetConfig WhitelistConfig } @@ -119,6 +123,9 @@ var DefaultConfig = Config{ ReapRatio: 1.25, NumWantFallback: 50, TorrentMapShards: 1, + JWKSetURI: "", + JWKSetUpdateInterval: Duration{5 * time.Minute}, + JWTAudience: "", NetConfig: NetConfig{ AllowIPSpoofing: true, diff --git a/example_config.json b/example_config.json index 7342849..15e7873 100644 --- a/example_config.json +++ b/example_config.json @@ -7,6 +7,9 @@ "reapRatio": 1.25, "defaultNumWant": 50, "torrentMapShards": 1, + "jwkSetURI": "", + "jwkSetUpdateInterval": "5m", + "jwtAudience": "", "allowIPSpoofing": true, "dualStackedPeers": true, "realIPHeader": "", diff --git a/http/tracker.go b/http/tracker.go index 3a1aa59..5f37c41 100644 --- a/http/tracker.go +++ b/http/tracker.go @@ -38,6 +38,11 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno return nil, models.ErrMalformedRequest } + jwt, exists := q.Params["jwt"] + if s.config.JWKSetURI != "" && !exists { + return nil, models.ErrMalformedRequest + } + port, err := q.Uint64("port") if err != nil { return nil, models.ErrMalformedRequest @@ -78,6 +83,7 @@ func (s *Server) newAnnounce(r *http.Request, p httprouter.Params) (*models.Anno NumWant: numWant, PeerID: peerID, Uploaded: uploaded, + JWT: jwt, }, nil } diff --git a/tracker/announce.go b/tracker/announce.go index bb8424e..2f4bc3c 100644 --- a/tracker/announce.go +++ b/tracker/announce.go @@ -18,8 +18,14 @@ func (tkr *Tracker) HandleAnnounce(ann *models.Announce, w Writer) (err error) { } } - torrent, err := tkr.FindTorrent(ann.Infohash) + if tkr.Config.JWKSetURI != "" { + err := tkr.validateJWT(ann.JWT, ann.Infohash) + if err != nil { + return err + } + } + torrent, err := tkr.FindTorrent(ann.Infohash) if err == models.ErrTorrentDNE && tkr.Config.CreateOnAnnounce { torrent = &models.Torrent{ Infohash: ann.Infohash, diff --git a/tracker/jwt.go b/tracker/jwt.go new file mode 100644 index 0000000..3e05a5a --- /dev/null +++ b/tracker/jwt.go @@ -0,0 +1,147 @@ +// Copyright 2015 The Chihaya Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license, +// which can be found in the LICENSE file. + +package tracker + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + oidchttp "github.com/coreos/go-oidc/http" + "github.com/coreos/go-oidc/jose" + "github.com/golang/glog" +) + +const jwkTTLFallback = 5 * time.Minute + +func (tkr *Tracker) updateJWKSetForever() { + defer tkr.shutdownWG.Done() + + client := &http.Client{Timeout: 5 * time.Second} + + // Get initial JWK Set. + err := tkr.updateJWKSet(client) + if err != nil { + glog.Warningf("Failed to get initial JWK Set: %s", err) + } + + for { + select { + case <-tkr.shuttingDown: + return + + case <-time.After(tkr.Config.JWKSetUpdateInterval.Duration): + err = tkr.updateJWKSet(client) + if err != nil { + glog.Warningf("Failed to update JWK Set: %s", err) + } + } + } +} + +type jwkSet struct { + Keys []jose.JWK `json:"keys"` + Issuer string `json:"issuer"` + validUntil time.Time +} + +func (tkr *Tracker) updateJWKSet(client *http.Client) error { + glog.Info("Attemping to update JWK Set") + resp, err := client.Get(tkr.Config.JWKSetURI) + if err != nil { + return err + } + defer resp.Body.Close() + + var jwks jwkSet + err = json.NewDecoder(resp.Body).Decode(&jwks) + if err != nil { + return err + } + + if len(jwks.Keys) == 0 { + return errors.New("Failed to find any keys from JWK Set URI") + } + + if jwks.Issuer == "" { + return errors.New("Failed to find any issuer from JWK Set URI") + } + + ttl, _, _ := oidchttp.Cacheable(resp.Header) + if ttl == 0 { + ttl = jwkTTLFallback + } + jwks.validUntil = time.Now().Add(ttl) + + tkr.jwkSet = jwks + glog.Info("Successfully updated JWK Set") + return nil +} + +func validateJWTSignature(jwt *jose.JWT, jwkSet *jwkSet) (bool, error) { + for _, jwk := range jwkSet.Keys { + v, err := jose.NewVerifier(jwk) + if err != nil { + return false, err + } + + if err := v.Verify(jwt.Signature, []byte(jwt.Data())); err == nil { + return true, nil + } + } + + return false, nil +} + +func (tkr *Tracker) validateJWT(jwtStr, infohash string) error { + jwkSet := tkr.jwkSet + if time.Now().After(jwkSet.validUntil) { + return fmt.Errorf("Failed verify JWT due to stale JWK Set") + } + + jwt, err := jose.ParseJWT(jwtStr) + if err != nil { + return err + } + + validated, err := validateJWTSignature(&jwt, &jwkSet) + if err != nil { + return err + } else if !validated { + return errors.New("Failed to verify JWT with all available verifiers") + } + + claims, err := jwt.Claims() + if err != nil { + return err + } + + if claimedIssuer, ok, err := claims.StringClaim("iss"); claimedIssuer != jwkSet.Issuer || err != nil || !ok { + return errors.New("Failed to validate JWT issuer claim") + } + + if claimedAudience, ok, err := claims.StringClaim("aud"); claimedAudience != tkr.Config.JWTAudience || err != nil || !ok { + return errors.New("Failed to validate JWT audience claim") + } + + claimedInfohash, ok, err := claims.StringClaim("infohash") + if err != nil || !ok { + return errors.New("Failed to validate JWT infohash claim") + } + + unescapedInfohash, err := url.QueryUnescape(claimedInfohash) + if err != nil { + return errors.New("Failed to unescape JWT infohash claim") + } + + if unescapedInfohash != infohash { + return errors.New("Failed to match infohash claim with requested infohash") + } + + return nil +} diff --git a/tracker/models/models.go b/tracker/models/models.go index 5d777d0..dc1d2a7 100644 --- a/tracker/models/models.go +++ b/tracker/models/models.go @@ -136,6 +136,7 @@ type Announce struct { NumWant int `json:"numwant"` PeerID string `json:"peer_id"` Uploaded uint64 `json:"uploaded"` + JWT string `json:"jwt"` Torrent *Torrent `json:"-"` Peer *Peer `json:"-"` diff --git a/tracker/tracker.go b/tracker/tracker.go index 34cb6c1..7ee38e5 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -7,6 +7,7 @@ package tracker import ( + "sync" "time" "github.com/golang/glog" @@ -19,6 +20,12 @@ import ( // independently of the underlying data transports used. type Tracker struct { Config *config.Config + + jwkSet jwkSet + + shuttingDown chan struct{} + shutdownWG sync.WaitGroup + *Storage } @@ -26,16 +33,25 @@ type Tracker struct { // Maintenance routines are automatically spawned in the background. func New(cfg *config.Config) (*Tracker, error) { tkr := &Tracker{ - Config: cfg, - Storage: NewStorage(cfg), + Config: cfg, + Storage: NewStorage(cfg), + shuttingDown: make(chan struct{}), } + glog.Info("Starting garbage collection goroutine") + tkr.shutdownWG.Add(1) go tkr.purgeInactivePeers( cfg.PurgeInactiveTorrents, time.Duration(float64(cfg.MinAnnounce.Duration)*cfg.ReapRatio), cfg.ReapInterval.Duration, ) + if tkr.Config.JWKSetURI != "" { + glog.Info("Starting JWK Set update goroutine") + tkr.shutdownWG.Add(1) + go tkr.updateJWKSetForever() + } + if cfg.ClientWhitelistEnabled { tkr.LoadApprovedClients(cfg.ClientWhitelist) } @@ -45,8 +61,8 @@ func New(cfg *config.Config) (*Tracker, error) { // Close gracefully shutdowns a Tracker by closing any database connections. func (tkr *Tracker) Close() error { - - // TODO(jzelinskie): shutdown purgeInactivePeers goroutine. + close(tkr.shuttingDown) + tkr.shutdownWG.Wait() return nil } @@ -73,13 +89,21 @@ type Writer interface { // purgeInactivePeers periodically walks the torrent database and removes // peers that haven't announced recently. func (tkr *Tracker) purgeInactivePeers(purgeEmptyTorrents bool, threshold, interval time.Duration) { - for _ = range time.NewTicker(interval).C { - before := time.Now().Add(-threshold) - glog.V(0).Infof("Purging peers with no announces since %s", before) + defer tkr.shutdownWG.Done() - err := tkr.PurgeInactivePeers(purgeEmptyTorrents, before) - if err != nil { - glog.Errorf("Error purging torrents: %s", err) + for { + select { + case <-tkr.shuttingDown: + return + + case <-time.NewTicker(interval).C: + before := time.Now().Add(-threshold) + glog.V(0).Infof("Purging peers with no announces since %s", before) + + err := tkr.PurgeInactivePeers(purgeEmptyTorrents, before) + if err != nil { + glog.Errorf("Error purging torrents: %s", err) + } } } }