Vaillant: solve ALTCHA challenge on login (#32070)

This commit is contained in:
andig 2026-07-23 09:44:20 +02:00 • committed by GitHub
parent e3a48e42f9
commit 6ead46d97f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 273 additions and 1 deletions

View file

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

202
charger/vaillant/auth.go Normal file
View file

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

View file

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