add support for jwt validation of infohashes

This commit is contained in:
Jimmy Zelinskie 2016-01-05 16:57:15 -05:00
parent 4d018d5081
commit 82459297a4
9 changed files with 234 additions and 14 deletions

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -7,6 +7,9 @@
"reapRatio": 1.25,
"defaultNumWant": 50,
"torrentMapShards": 1,
"jwkSetURI": "",
"jwkSetUpdateInterval": "5m",
"jwtAudience": "",
"allowIPSpoofing": true,
"dualStackedPeers": true,
"realIPHeader": "",

View file

@ -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
}

View file

@ -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,

147
tracker/jwt.go Normal file
View file

@ -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
}

View file

@ -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:"-"`

View file

@ -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)
}
}
}
}