Digest auth: reuse challenges across requests (#32842)
This commit is contained in:
parent
37d8aecbb2
commit
c28f2ff623
8 changed files with 247 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
63
util/transport/digest.go
Normal 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
|
||||
}
|
||||
173
util/transport/digest_test.go
Normal file
173
util/transport/digest_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue