Vaillant: solve ALTCHA challenge on login (#32070)
This commit is contained in:
parent
e3a48e42f9
commit
6ead46d97f
3 changed files with 273 additions and 1 deletions
|
|
@ -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
202
charger/vaillant/auth.go
Normal 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
|
||||
}
|
||||
}
|
||||
69
charger/vaillant/auth_test.go
Normal file
69
charger/vaillant/auth_test.go
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue