Skip to content

Commit 6ead46d

Browse files
authored
Vaillant: solve ALTCHA challenge on login (#32070)
1 parent e3a48e4 commit 6ead46d

3 files changed

Lines changed: 273 additions & 1 deletion

File tree

charger/vaillant.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/WulfgarW/sensonet"
2929
"github.com/evcc-io/evcc/api"
3030
"github.com/evcc-io/evcc/api/implement"
31+
"github.com/evcc-io/evcc/charger/vaillant"
3132
"github.com/evcc-io/evcc/core/loadpoint"
3233
"github.com/evcc-io/evcc/util"
3334
"github.com/evcc-io/evcc/util/request"
@@ -81,7 +82,7 @@ func NewVaillantFromConfig(ctx context.Context, other map[string]any) (api.Charg
8182
logCtx := context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log))
8283

8384
oc := sensonet.Oauth2ConfigForRealm(cc.Realm)
84-
token, err := oc.PasswordCredentialsToken(logCtx, cc.User, cc.Password)
85+
token, err := vaillant.Login(logCtx, log, oc, cc.User, cc.Password)
8586
if err != nil {
8687
return nil, err
8788
}

charger/vaillant/auth.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package vaillant
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/pbkdf2"
7+
"crypto/sha256"
8+
"crypto/sha512"
9+
"encoding/base64"
10+
"encoding/binary"
11+
"encoding/hex"
12+
"encoding/json"
13+
"errors"
14+
"fmt"
15+
"io"
16+
"net/http"
17+
"net/http/cookiejar"
18+
"net/url"
19+
"regexp"
20+
"slices"
21+
"strings"
22+
23+
"github.com/WulfgarW/sensonet"
24+
"github.com/evcc-io/evcc/util"
25+
"golang.org/x/oauth2"
26+
)
27+
28+
const altchaChallengeURL = "https://identity.vaillant-group.com/api/altcha/challenge"
29+
30+
// Login replicates sensonet.Oauth2Config.PasswordCredentialsToken with the
31+
// ALTCHA proof-of-work the Vaillant login requires (https://github.com/signalkraft/myPyllant/pull/162)
32+
func Login(ctx context.Context, log *util.Logger, oc *sensonet.Oauth2Config, username, password string) (*oauth2.Token, error) {
33+
client := new(http.Client)
34+
if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok {
35+
// shallow copy to avoid mutating the shared client
36+
clone := *c
37+
client = &clone
38+
}
39+
40+
client.Jar, _ = cookiejar.New(nil)
41+
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
42+
return http.ErrUseLastResponse
43+
}
44+
45+
cv := oauth2.GenerateVerifier()
46+
47+
uri := oc.AuthCodeURL(cv, oauth2.S256ChallengeOption(cv), oauth2.SetAuthURLParam("code", "code_challenge"))
48+
resp, err := client.Get(uri)
49+
if err != nil {
50+
return nil, err
51+
}
52+
defer resp.Body.Close()
53+
54+
body, err := io.ReadAll(resp.Body)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
match := regexp.MustCompile(`action\s*=\s*"(.+?)"`).FindStringSubmatch(string(body))
60+
if len(match) < 2 {
61+
return nil, errors.New("missing login form action")
62+
}
63+
64+
params := url.Values{
65+
"username": {username},
66+
"password": {password},
67+
"credentialId": {""},
68+
}
69+
70+
// best-effort like myPyllant: continue without altcha if challenge cannot be obtained
71+
if altcha, err := altcha(client); err == nil {
72+
params.Set("altcha", altcha)
73+
} else {
74+
log.WARN.Printf("altcha challenge failed, continuing without: %v", err)
75+
}
76+
77+
req, err := http.NewRequest("POST", match[1], strings.NewReader(params.Encode()))
78+
if err != nil {
79+
return nil, err
80+
}
81+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
82+
83+
resp, err = client.Do(req)
84+
if err != nil {
85+
return nil, err
86+
}
87+
resp.Body.Close()
88+
89+
location, _ := url.Parse(resp.Header.Get("Location"))
90+
code := location.Query().Get("code")
91+
if code == "" {
92+
return nil, errors.New("could not get code")
93+
}
94+
95+
return oc.Exchange(ctx, code, oauth2.VerifierOption(cv))
96+
}
97+
98+
// altcha fetches and solves the ALTCHA challenge for the login form
99+
func altcha(client *http.Client) (string, error) {
100+
resp, err := client.Get(altchaChallengeURL)
101+
if err != nil {
102+
return "", err
103+
}
104+
defer resp.Body.Close()
105+
106+
if resp.StatusCode != http.StatusOK {
107+
return "", fmt.Errorf("status %s", resp.Status)
108+
}
109+
110+
challenge, err := io.ReadAll(resp.Body)
111+
if err != nil {
112+
return "", err
113+
}
114+
115+
return solveAltcha(challenge)
116+
}
117+
118+
// solveAltcha solves the PBKDF2 proof-of-work and returns the base64-encoded
119+
// payload the login form expects in its altcha field
120+
func solveAltcha(challenge []byte) (string, error) {
121+
var c struct {
122+
Parameters json.RawMessage `json:"parameters"`
123+
Signature string `json:"signature"`
124+
}
125+
if err := json.Unmarshal(challenge, &c); err != nil {
126+
return "", err
127+
}
128+
129+
var p struct {
130+
Algorithm string `json:"algorithm"`
131+
Cost int `json:"cost"`
132+
KeyLength int `json:"keyLength"`
133+
KeyPrefix string `json:"keyPrefix"`
134+
Nonce string `json:"nonce"`
135+
Salt string `json:"salt"`
136+
}
137+
if err := json.Unmarshal(c.Parameters, &p); err != nil {
138+
return "", err
139+
}
140+
141+
nonce, err := hex.DecodeString(p.Nonce)
142+
if err != nil {
143+
return "", err
144+
}
145+
salt, err := hex.DecodeString(p.Salt)
146+
if err != nil {
147+
return "", err
148+
}
149+
prefix, err := hex.DecodeString(p.KeyPrefix)
150+
if err != nil {
151+
return "", err
152+
}
153+
154+
newHash := sha256.New
155+
switch p.Algorithm {
156+
case "PBKDF2/SHA-512":
157+
newHash = sha512.New
158+
case "PBKDF2/SHA-384":
159+
newHash = sha512.New384
160+
}
161+
162+
keyLength := p.KeyLength
163+
if keyLength == 0 {
164+
keyLength = 32
165+
}
166+
167+
for counter := uint32(0); ; counter++ {
168+
password := binary.BigEndian.AppendUint32(slices.Clone(nonce), counter)
169+
170+
key, err := pbkdf2.Key(newHash, string(password), salt, p.Cost, keyLength)
171+
if err != nil {
172+
return "", err
173+
}
174+
175+
if !bytes.HasPrefix(key, prefix) {
176+
continue
177+
}
178+
179+
payload := struct {
180+
Challenge struct {
181+
Parameters json.RawMessage `json:"parameters"`
182+
Signature string `json:"signature"`
183+
} `json:"challenge"`
184+
Solution struct {
185+
Counter uint32 `json:"counter"`
186+
DerivedKey string `json:"derivedKey"`
187+
Time int `json:"time"`
188+
} `json:"solution"`
189+
}{}
190+
payload.Challenge.Parameters = c.Parameters
191+
payload.Challenge.Signature = c.Signature
192+
payload.Solution.Counter = counter
193+
payload.Solution.DerivedKey = hex.EncodeToString(key)
194+
195+
res, err := json.Marshal(payload)
196+
if err != nil {
197+
return "", err
198+
}
199+
200+
return base64.StdEncoding.EncodeToString(res), nil
201+
}
202+
}

charger/vaillant/auth_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package vaillant
2+
3+
import (
4+
"bytes"
5+
"crypto/pbkdf2"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/binary"
9+
"encoding/hex"
10+
"encoding/json"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// low cost and one-byte keyPrefix so the proof-of-work solves almost instantly
17+
func TestSolveAltcha(t *testing.T) {
18+
challenge := []byte(`{
19+
"parameters": {
20+
"algorithm": "PBKDF2/SHA-256",
21+
"cost": 10,
22+
"keyLength": 32,
23+
"keyPrefix": "00",
24+
"nonce": "19398d35354f4059a03226019c7b9915",
25+
"salt": "df78709ec7a451e5eacc099b09e2e9a7"
26+
},
27+
"signature": "some-server-signature"
28+
}`)
29+
30+
payload, err := solveAltcha(challenge)
31+
require.NoError(t, err)
32+
33+
res, err := base64.StdEncoding.DecodeString(payload)
34+
require.NoError(t, err)
35+
36+
var decoded struct {
37+
Challenge struct {
38+
Parameters struct {
39+
Cost int `json:"cost"`
40+
KeyLength int `json:"keyLength"`
41+
KeyPrefix string `json:"keyPrefix"`
42+
Nonce string `json:"nonce"`
43+
Salt string `json:"salt"`
44+
} `json:"parameters"`
45+
Signature string `json:"signature"`
46+
} `json:"challenge"`
47+
Solution struct {
48+
Counter uint32 `json:"counter"`
49+
DerivedKey string `json:"derivedKey"`
50+
} `json:"solution"`
51+
}
52+
require.NoError(t, json.Unmarshal(res, &decoded))
53+
require.Equal(t, "some-server-signature", decoded.Challenge.Signature)
54+
55+
p := decoded.Challenge.Parameters
56+
nonce, err := hex.DecodeString(p.Nonce)
57+
require.NoError(t, err)
58+
salt, err := hex.DecodeString(p.Salt)
59+
require.NoError(t, err)
60+
prefix, err := hex.DecodeString(p.KeyPrefix)
61+
require.NoError(t, err)
62+
63+
password := binary.BigEndian.AppendUint32(nonce, decoded.Solution.Counter)
64+
key, err := pbkdf2.Key(sha256.New, string(password), salt, p.Cost, p.KeyLength)
65+
require.NoError(t, err)
66+
67+
require.Equal(t, hex.EncodeToString(key), decoded.Solution.DerivedKey)
68+
require.True(t, bytes.HasPrefix(key, prefix))
69+
}

0 commit comments

Comments
 (0)