Digest auth: reuse challenges across requests (#32842)

This commit is contained in:
andig 2026-08-21 17:32:25 +02:00 • committed by GitHub
parent 37d8aecbb2
commit c28f2ff623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 247 additions and 17 deletions

View file

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

View file

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

2
go.mod
View file

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

6
go.sum
View file

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

View file

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

View file

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

63
util/transport/digest.go Normal file
View file

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

View file

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