From 6ead46d97ff052cfb96e7da198ac4c14359ed82e Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 23 Jul 2026 09:44:20 +0200 Subject: [PATCH] Vaillant: solve ALTCHA challenge on login (#32070) --- charger/vaillant.go | 3 +- charger/vaillant/auth.go | 202 ++++++++++++++++++++++++++++++++++ charger/vaillant/auth_test.go | 69 ++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 charger/vaillant/auth.go create mode 100644 charger/vaillant/auth_test.go diff --git a/charger/vaillant.go b/charger/vaillant.go index 69971e7dc..e41d2efff 100644 --- a/charger/vaillant.go +++ b/charger/vaillant.go @@ -28,6 +28,7 @@ import ( "github.com/WulfgarW/sensonet" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/implement" + "github.com/evcc-io/evcc/charger/vaillant" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" @@ -81,7 +82,7 @@ func NewVaillantFromConfig(ctx context.Context, other map[string]any) (api.Charg logCtx := context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) oc := sensonet.Oauth2ConfigForRealm(cc.Realm) - token, err := oc.PasswordCredentialsToken(logCtx, cc.User, cc.Password) + token, err := vaillant.Login(logCtx, log, oc, cc.User, cc.Password) if err != nil { return nil, err } diff --git a/charger/vaillant/auth.go b/charger/vaillant/auth.go new file mode 100644 index 000000000..45dac9177 --- /dev/null +++ b/charger/vaillant/auth.go @@ -0,0 +1,202 @@ +package vaillant + +import ( + "bytes" + "context" + "crypto/pbkdf2" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/WulfgarW/sensonet" + "github.com/evcc-io/evcc/util" + "golang.org/x/oauth2" +) + +const altchaChallengeURL = "https://identity.vaillant-group.com/api/altcha/challenge" + +// Login replicates sensonet.Oauth2Config.PasswordCredentialsToken with the +// ALTCHA proof-of-work the Vaillant login requires (https://github.com/signalkraft/myPyllant/pull/162) +func Login(ctx context.Context, log *util.Logger, oc *sensonet.Oauth2Config, username, password string) (*oauth2.Token, error) { + client := new(http.Client) + if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok { + // shallow copy to avoid mutating the shared client + clone := *c + client = &clone + } + + client.Jar, _ = cookiejar.New(nil) + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + cv := oauth2.GenerateVerifier() + + uri := oc.AuthCodeURL(cv, oauth2.S256ChallengeOption(cv), oauth2.SetAuthURLParam("code", "code_challenge")) + resp, err := client.Get(uri) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + match := regexp.MustCompile(`action\s*=\s*"(.+?)"`).FindStringSubmatch(string(body)) + if len(match) < 2 { + return nil, errors.New("missing login form action") + } + + params := url.Values{ + "username": {username}, + "password": {password}, + "credentialId": {""}, + } + + // best-effort like myPyllant: continue without altcha if challenge cannot be obtained + if altcha, err := altcha(client); err == nil { + params.Set("altcha", altcha) + } else { + log.WARN.Printf("altcha challenge failed, continuing without: %v", err) + } + + req, err := http.NewRequest("POST", match[1], strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err = client.Do(req) + if err != nil { + return nil, err + } + resp.Body.Close() + + location, _ := url.Parse(resp.Header.Get("Location")) + code := location.Query().Get("code") + if code == "" { + return nil, errors.New("could not get code") + } + + return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) +} + +// altcha fetches and solves the ALTCHA challenge for the login form +func altcha(client *http.Client) (string, error) { + resp, err := client.Get(altchaChallengeURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %s", resp.Status) + } + + challenge, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return solveAltcha(challenge) +} + +// solveAltcha solves the PBKDF2 proof-of-work and returns the base64-encoded +// payload the login form expects in its altcha field +func solveAltcha(challenge []byte) (string, error) { + var c struct { + Parameters json.RawMessage `json:"parameters"` + Signature string `json:"signature"` + } + if err := json.Unmarshal(challenge, &c); err != nil { + return "", err + } + + var p struct { + Algorithm string `json:"algorithm"` + Cost int `json:"cost"` + KeyLength int `json:"keyLength"` + KeyPrefix string `json:"keyPrefix"` + Nonce string `json:"nonce"` + Salt string `json:"salt"` + } + if err := json.Unmarshal(c.Parameters, &p); err != nil { + return "", err + } + + nonce, err := hex.DecodeString(p.Nonce) + if err != nil { + return "", err + } + salt, err := hex.DecodeString(p.Salt) + if err != nil { + return "", err + } + prefix, err := hex.DecodeString(p.KeyPrefix) + if err != nil { + return "", err + } + + newHash := sha256.New + switch p.Algorithm { + case "PBKDF2/SHA-512": + newHash = sha512.New + case "PBKDF2/SHA-384": + newHash = sha512.New384 + } + + keyLength := p.KeyLength + if keyLength == 0 { + keyLength = 32 + } + + for counter := uint32(0); ; counter++ { + password := binary.BigEndian.AppendUint32(slices.Clone(nonce), counter) + + key, err := pbkdf2.Key(newHash, string(password), salt, p.Cost, keyLength) + if err != nil { + return "", err + } + + if !bytes.HasPrefix(key, prefix) { + continue + } + + payload := struct { + Challenge struct { + Parameters json.RawMessage `json:"parameters"` + Signature string `json:"signature"` + } `json:"challenge"` + Solution struct { + Counter uint32 `json:"counter"` + DerivedKey string `json:"derivedKey"` + Time int `json:"time"` + } `json:"solution"` + }{} + payload.Challenge.Parameters = c.Parameters + payload.Challenge.Signature = c.Signature + payload.Solution.Counter = counter + payload.Solution.DerivedKey = hex.EncodeToString(key) + + res, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(res), nil + } +} diff --git a/charger/vaillant/auth_test.go b/charger/vaillant/auth_test.go new file mode 100644 index 000000000..3f335cf03 --- /dev/null +++ b/charger/vaillant/auth_test.go @@ -0,0 +1,69 @@ +package vaillant + +import ( + "bytes" + "crypto/pbkdf2" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// low cost and one-byte keyPrefix so the proof-of-work solves almost instantly +func TestSolveAltcha(t *testing.T) { + challenge := []byte(`{ + "parameters": { + "algorithm": "PBKDF2/SHA-256", + "cost": 10, + "keyLength": 32, + "keyPrefix": "00", + "nonce": "19398d35354f4059a03226019c7b9915", + "salt": "df78709ec7a451e5eacc099b09e2e9a7" + }, + "signature": "some-server-signature" + }`) + + payload, err := solveAltcha(challenge) + require.NoError(t, err) + + res, err := base64.StdEncoding.DecodeString(payload) + require.NoError(t, err) + + var decoded struct { + Challenge struct { + Parameters struct { + Cost int `json:"cost"` + KeyLength int `json:"keyLength"` + KeyPrefix string `json:"keyPrefix"` + Nonce string `json:"nonce"` + Salt string `json:"salt"` + } `json:"parameters"` + Signature string `json:"signature"` + } `json:"challenge"` + Solution struct { + Counter uint32 `json:"counter"` + DerivedKey string `json:"derivedKey"` + } `json:"solution"` + } + require.NoError(t, json.Unmarshal(res, &decoded)) + require.Equal(t, "some-server-signature", decoded.Challenge.Signature) + + p := decoded.Challenge.Parameters + nonce, err := hex.DecodeString(p.Nonce) + require.NoError(t, err) + salt, err := hex.DecodeString(p.Salt) + require.NoError(t, err) + prefix, err := hex.DecodeString(p.KeyPrefix) + require.NoError(t, err) + + password := binary.BigEndian.AppendUint32(nonce, decoded.Solution.Counter) + key, err := pbkdf2.Key(sha256.New, string(password), salt, p.Cost, p.KeyLength) + require.NoError(t, err) + + require.Equal(t, hex.EncodeToString(key), decoded.Solution.DerivedKey) + require.True(t, bytes.HasPrefix(key, prefix)) +}