From c28f2ff62319eb3d3b754946c3e8fd7987d2938e Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 21 Aug 2026 17:32:25 +0200 Subject: [PATCH] Digest auth: reuse challenges across requests (#32842) --- charger/shelly-topac.go | 3 +- charger/warp/connection.go | 4 +- go.mod | 2 +- go.sum | 6 +- meter/shelly/gen2.go | 4 +- plugin/http_auth.go | 9 +- util/transport/digest.go | 63 +++++++++++++ util/transport/digest_test.go | 173 ++++++++++++++++++++++++++++++++++ 8 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 util/transport/digest.go create mode 100644 util/transport/digest_test.go diff --git a/charger/shelly-topac.go b/charger/shelly-topac.go index 092577418..d17c9cf22 100644 --- a/charger/shelly-topac.go +++ b/charger/shelly-topac.go @@ -29,7 +29,6 @@ import ( "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/util/transport" - "github.com/jpfielding/go-http-digest/pkg/digest" ) // ShellyTopAC charger implementation for Shelly Top AC Portable EV Charger @@ -82,7 +81,7 @@ func NewShellyTopAC(uri, user, password string) (api.Charger, error) { // Setup digest authentication for Shelly Gen2 if user != "" { - c.Client.Transport = digest.NewTransport(user, password, c.Client.Transport) + c.Client.Transport = transport.Digest(user, password, c.Client.Transport) } // Setup cached status getters diff --git a/charger/warp/connection.go b/charger/warp/connection.go index 6f2b4b704..8fdcdc2a2 100644 --- a/charger/warp/connection.go +++ b/charger/warp/connection.go @@ -5,7 +5,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/jpfielding/go-http-digest/pkg/digest" + "github.com/evcc-io/evcc/util/transport" ) type Connection struct { @@ -24,7 +24,7 @@ func NewConnection(log *util.Logger, uri, user, pass string) *Connection { } if c.Username != "" && c.Password != "" { - c.Client.Transport = digest.NewTransport(c.Username, c.Password, c.Client.Transport) + c.Client.Transport = transport.Digest(c.Username, c.Password, c.Client.Transport) } return c diff --git a/go.mod b/go.mod index eed038174..163173651 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/hashicorp/yamux v0.1.2 github.com/hasura/go-graphql-client v0.16.0 github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 + github.com/icholy/digest v1.2.0 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/insomniacslk/tapo v1.1.0 github.com/itchyny/gojq v0.12.19 @@ -62,7 +63,6 @@ require ( github.com/jinzhu/now v1.1.5 github.com/joeshaw/carwings v0.0.0-20250704173606-1708e349f36c github.com/joho/godotenv v1.5.1 - github.com/jpfielding/go-http-digest v0.0.0-20260421181648-7215c19bbaa3 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/koron/go-ssdp v0.9.1 github.com/korylprince/ipnetgen v1.0.1 diff --git a/go.sum b/go.sum index 8ee8c4fc8..7741ecedf 100644 --- a/go.sum +++ b/go.sum @@ -282,6 +282,8 @@ github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DX github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/icholy/digest v1.2.0 h1:oTbG4IsNOmidJ+421ehG7Ty93yt1yotq13kFMG569yw= +github.com/icholy/digest v1.2.0/go.mod h1:1P1+LzUv48ybX7bu8tVpZ2QWdd+xRuePNuGawHjwRUE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= @@ -308,8 +310,6 @@ github.com/joeshaw/carwings v0.0.0-20250704173606-1708e349f36c h1:qAJHVJ+s+pbLuv github.com/joeshaw/carwings v0.0.0-20250704173606-1708e349f36c/go.mod h1:rNwwhAzMSNqlTsRQZ7a+jXHu4Mgn2ztr3aYCqRgWGlA= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jpfielding/go-http-digest v0.0.0-20260421181648-7215c19bbaa3 h1:+El1JZu41AGMguCgZQWT0wZKm0cuh6UwzBTzDKwDJTE= -github.com/jpfielding/go-http-digest v0.0.0-20260421181648-7215c19bbaa3/go.mod h1:sWrIFyaVqJ19RBvAB81OZMFFURUUfa0tiYB/Yrd6DmQ= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -747,6 +747,8 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go index 1d18314b0..44ab93163 100644 --- a/meter/shelly/gen2.go +++ b/meter/shelly/gen2.go @@ -8,7 +8,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/jpfielding/go-http-digest/pkg/digest" + "github.com/evcc-io/evcc/util/transport" ) // Gen2API endpoint reference: https://shelly-api-docs.shelly.cloud/gen2/ @@ -132,7 +132,7 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw // Shelly gen 2 rfc7616 authentication // https://shelly-api-docs.shelly.cloud/gen2/General/Authentication if user != "" { - c.Client.Transport = digest.NewTransport(user, password, c.Client.Transport) + c.Client.Transport = transport.Digest(user, password, c.Client.Transport) } var res Gen2Methods diff --git a/plugin/http_auth.go b/plugin/http_auth.go index 87dd2714a..2dad0dc0f 100644 --- a/plugin/http_auth.go +++ b/plugin/http_auth.go @@ -2,7 +2,6 @@ package plugin import ( "context" - "crypto/sha256" "fmt" "net/http" "strings" @@ -10,15 +9,9 @@ import ( "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/transport" - "github.com/jpfielding/go-http-digest/pkg/digest" "golang.org/x/oauth2" ) -func init() { - // some servers send SHA256 instead of the RFC 7616 compliant SHA-256 - digest.Algs["SHA256"] = sha256.New -} - // Auth is the authorization config type Auth struct { Type, User, Password, Token string @@ -30,7 +23,7 @@ type Auth struct { func (p *Auth) Transport(ctx context.Context, log *util.Logger, base http.RoundTripper) (http.RoundTripper, error) { switch strings.ToLower(p.Type) { case "digest": - return digest.NewTransport(p.User, p.Password, base), nil + return transport.Digest(p.User, p.Password, base), nil case "basic": return transport.BasicAuth(p.User, p.Password, base), nil diff --git a/util/transport/digest.go b/util/transport/digest.go new file mode 100644 index 000000000..ba8e7cba4 --- /dev/null +++ b/util/transport/digest.go @@ -0,0 +1,63 @@ +package transport + +import ( + "errors" + "net/http" + "strings" + + "github.com/icholy/digest" +) + +// nonRfcSha256 is sent by some servers instead of the RFC 7616 compliant SHA-256 +const nonRfcSha256 = "SHA256" + +// Digest creates an http transport performing digest auth. The challenge is +// cached per host, so all requests after the first authenticate preemptively +// instead of paying a 401 challenge round trip each time (RFC 7616 ยง3.3). +func Digest(user, password string, base http.RoundTripper) http.RoundTripper { + return &digest.Transport{ + Username: user, + Password: password, + Transport: base, + FindChallenge: digestChallenge, + Digest: digestCredentials, + } +} + +// digestChallenge additionally accepts challenges announcing the non-RFC SHA256 +func digestChallenge(h http.Header) (*digest.Challenge, error) { + chal, err := digest.FindChallenge(h) + if err == nil || !errors.Is(err, digest.ErrNoChallenge) { + return chal, err + } + + for _, header := range h.Values("WWW-Authenticate") { + if !digest.IsDigest(header) { + continue + } + if chal, err := digest.ParseChallenge(header); err == nil && strings.EqualFold(chal.Algorithm, nonRfcSha256) { + return chal, nil + } + } + + return nil, err +} + +// digestCredentials hashes a non-RFC SHA256 challenge as SHA-256, echoing back +// the spelling the server used +func digestCredentials(_ *http.Request, chal *digest.Challenge, opt digest.Options) (*digest.Credentials, error) { + if !strings.EqualFold(chal.Algorithm, nonRfcSha256) { + return digest.Digest(chal, opt) + } + + rfc := *chal + rfc.Algorithm = "SHA-256" + + cred, err := digest.Digest(&rfc, opt) + if err != nil { + return nil, err + } + cred.Algorithm = chal.Algorithm + + return cred, nil +} diff --git a/util/transport/digest_test.go b/util/transport/digest_test.go new file mode 100644 index 000000000..075c9352f --- /dev/null +++ b/util/transport/digest_test.go @@ -0,0 +1,173 @@ +package transport + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + digestUser = "admin" + digestPass = "secret" + digestRealm = "shellypro4pm-f008d1d8b8b8" +) + +var reDigestParam = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,\s]+))`) + +func digestParams(auth string) map[string]string { + res := make(map[string]string) + for _, m := range reDigestParam.FindAllStringSubmatch(auth, -1) { + if m[2] != "" { + res[m[1]] = m[2] + } else { + res[m[1]] = m[3] + } + } + return res +} + +func sha256hex(parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, ":"))) + return hex.EncodeToString(sum[:]) +} + +// digestDevice verifies digest credentials the way a Shelly Gen2+ device does: +// one nonce per challenge, reusable while nc is strictly increasing. +type digestDevice struct { + mu sync.Mutex + + algorithm string // algorithm advertised in the challenge + nonce string + seq int + lastNC int + + challenges int // nonces minted + unauth int // requests without Authorization + ncSeen []int + seenAlg string // algorithm echoed by the client +} + +func (d *digestDevice) challengeLocked(w http.ResponseWriter) { + d.seq++ + d.nonce = fmt.Sprintf("nonce-%d", d.seq) + d.lastNC = 0 + d.challenges++ + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Digest qop="auth", realm=%q, nonce=%q, algorithm=%s`, + digestRealm, d.nonce, d.algorithm)) + w.WriteHeader(http.StatusUnauthorized) +} + +func (d *digestDevice) ServeHTTP(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + defer d.mu.Unlock() + + auth := r.Header.Get("Authorization") + if auth == "" { + d.unauth++ + d.challengeLocked(w) + return + } + + p := digestParams(auth) + d.seenAlg = p["algorithm"] + nc, err := strconv.ParseInt(p["nc"], 16, 64) + if p["nonce"] != d.nonce || err != nil || int(nc) <= d.lastNC { + d.challengeLocked(w) + return + } + + // the device always hashes with SHA-256, whatever spelling it advertised + ha1 := sha256hex(digestUser, digestRealm, digestPass) + ha2 := sha256hex(r.Method, r.URL.RequestURI()) + if want := sha256hex(ha1, d.nonce, p["nc"], p["cnonce"], "auth", ha2); want != p["response"] { + w.WriteHeader(http.StatusForbidden) + return + } + + d.lastNC = int(nc) + d.ncSeen = append(d.ncSeen, int(nc)) + w.WriteHeader(http.StatusOK) +} + +func testDigestClient(t *testing.T, algorithm string) (*digestDevice, *http.Client, string) { + t.Helper() + + dev := &digestDevice{algorithm: algorithm} + srv := httptest.NewServer(dev) + t.Cleanup(srv.Close) + + return dev, &http.Client{Transport: Digest(digestUser, digestPass, nil)}, srv.URL +} + +// TestDigestPreemptive asserts the challenge is reused. Devices bound the +// number of nonces they issue, so re-challenging per request gets us throttled. +func TestDigestPreemptive(t *testing.T) { + dev, client, uri := testDigestClient(t, "SHA-256") + + for i := 0; i < 4; i++ { + resp, err := client.Get(uri + "/rpc/Switch.GetStatus") + require.NoError(t, err, "request %d", i) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "request %d", i) + } + + dev.mu.Lock() + defer dev.mu.Unlock() + assert.Equal(t, 1, dev.challenges, "only the first request may trigger a challenge") + assert.Equal(t, 1, dev.unauth, "only the first request may be unauthenticated") + assert.Equal(t, []int{1, 2, 3, 4}, dev.ncSeen, "nc must increase across reuse") +} + +// TestDigestNonRfcAlgorithm covers servers advertising SHA256 instead of the +// RFC 7616 compliant SHA-256. The non-RFC spelling takes a separate challenge +// path, so it has to reuse challenges just the same. +func TestDigestNonRfcAlgorithm(t *testing.T) { + for _, algorithm := range []string{"SHA-256", "SHA256"} { + t.Run(algorithm, func(t *testing.T) { + dev, client, uri := testDigestClient(t, algorithm) + + for i := 0; i < 3; i++ { + resp, err := client.Get(uri) + require.NoError(t, err, "request %d", i) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "request %d", i) + } + + dev.mu.Lock() + defer dev.mu.Unlock() + assert.Equal(t, algorithm, dev.seenAlg, "client must echo the announced algorithm") + assert.Equal(t, 1, dev.challenges) + assert.Equal(t, 1, dev.unauth) + assert.Equal(t, []int{1, 2, 3}, dev.ncSeen) + }) + } +} + +// TestDigestWrongPassword asserts a rejected response surfaces to the caller +// instead of being retried until the device throttles us. +func TestDigestWrongPassword(t *testing.T) { + dev, _, uri := testDigestClient(t, "SHA-256") + client := &http.Client{Transport: Digest(digestUser, "wrong", nil)} + + resp, err := client.Get(uri) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + + dev.mu.Lock() + defer dev.mu.Unlock() + assert.Equal(t, 1, dev.challenges, "a rejected response must not be retried") + assert.Equal(t, 1, dev.unauth) + assert.Empty(t, dev.ncSeen) +}