evcc-io/vehicle/bluelink/cci.go
2026-08-20 13:36:53 +02:00

440 lines
14 KiB
Go

package bluelink
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"uuid"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// CCIConfig holds the OneApp/CCI login parameters. Only set for EU Kia/Hyundai,
// where the legacy IDPConnect authorize endpoint is WAF-blocked.
type CCIConfig struct {
OneAppClientID string // OneApp OAuth2 client_id (not on the WAF block list)
OneAppRedirectURI string
APIURL string // e.g. https://cci-api-eu.kia.com
PackageID string // "client-id" header value (mobile app bundle id)
ClientName string // "client-name" header value
OSVersion string // "client-os-version" header value
NotificationProvider string // "client-notification-provider-type" header value
}
const (
cciClientVersion = "1.3.3"
cciMobileUserAgent = "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19_CCS_APP_AOS"
cciSettingsKeyFmt = "bluelink-cci.%s.%s"
)
// cciBundle is the CCI/CCS token state. AccessToken is the CCS token used on
// the legacy ccapi endpoints, the remaining fields are required to refresh it.
type cciBundle struct {
AccessToken string `json:"access_token"` // CCS token (no "Bearer " prefix)
RefreshToken string `json:"refresh_token"` // CCI refresh token
Expiry time.Time `json:"expiry"` // CCS token expiry
DeviceID string `json:"device_id"` // client-device-id used for all CCI calls
CCIAccessToken string `json:"cci_access_token"`
ExchangeableToken string `json:"exchangeable_token"`
ExchangeableRefreshToken string `json:"exchangeable_refresh_token"`
NonCcsToken string `json:"non_ccs_token"`
NonCcsRefreshToken string `json:"non_ccs_refresh_token"`
IDToken string `json:"id_token"`
}
func (b cciBundle) token() *oauth2.Token {
return &oauth2.Token{
AccessToken: b.AccessToken,
RefreshToken: b.RefreshToken,
Expiry: b.Expiry,
}
}
// settingsKey returns the settings key the CCI token bundle is persisted under
func (v *Identity) settingsKey() string {
return fmt.Sprintf(cciSettingsKeyFmt, v.config.Brand, v.user)
}
// loginCCI obtains a CCS access token via the OneApp/CCI flow, preferring a
// bundle persisted from an earlier login over a fresh password login
func (v *Identity) loginCCI(password string) (*oauth2.Token, error) {
if err := settings.Json(v.settingsKey(), &v.bundle); err == nil {
if token := v.bundle.token(); token.Valid() {
v.log.DEBUG.Println("cci: using persisted token")
return token, nil
}
if v.bundle.RefreshToken != "" {
token, err := v.refreshCCI(nil)
if err == nil {
return token, nil
}
v.log.WARN.Printf("cci: refreshing persisted token failed: %v", err)
}
}
return v.loginCCIPassword(password)
}
// loginCCIPassword performs the headless OneApp/CCI password login: authorize,
// fetch RSA cert, signin, exchange the auth code for CCI and then CCS tokens
func (v *Identity) loginCCIPassword(password string) (*oauth2.Token, error) {
c := v.config.CCI
deviceID := uuid.New().String()
v.log.DEBUG.Println("cci: logging in via OneApp/CCI password login")
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
v.Client.Jar = jar
defer func() {
v.Client.Jar = nil
v.Client.CheckRedirect = nil
}()
// the OneApp client_id is not on the WAF block list
authURL := fmt.Sprintf(
"%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&lang=en&state=ccsp&country=de",
v.config.LoginFormHost, c.OneAppClientID, c.OneAppRedirectURI,
)
authReq, err := request.New(http.MethodGet, authURL, nil, map[string]string{
"User-Agent": cciMobileUserAgent,
})
if err != nil {
return nil, err
}
authResp, err := v.Client.Do(authReq)
if err != nil {
return nil, err
}
authBody, _ := io.ReadAll(authResp.Body)
authResp.Body.Close()
if strings.Contains(strings.ToLower(string(authBody)), "abusing") ||
strings.Contains(authResp.Request.URL.String(), "/error?status=400") {
return nil, errors.New("authorize rejected as 'abusing request' — server-side WAF block, not a credentials problem")
}
if authResp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("authorize failed: HTTP %d (%s)", authResp.StatusCode, request.Truncate(string(authBody)))
}
// rsa public key used to encrypt the password for signin
certReq, err := request.New(http.MethodGet, v.config.LoginFormHost+"/auth/api/v1/accounts/certs", nil, map[string]string{
"User-Agent": cciMobileUserAgent,
"Accept": request.JSONContent,
})
if err != nil {
return nil, err
}
var certRes struct {
RetValue struct {
Kid string
N string
E string
}
}
if err := v.DoJSON(certReq, &certRes); err != nil {
return nil, fmt.Errorf("fetching rsa certs failed: %w", err)
}
encryptedPassword, err := encryptCCIPassword(certRes.RetValue.N, certRes.RetValue.E, password)
if err != nil {
return nil, fmt.Errorf("encrypting password failed: %w", err)
}
data := url.Values{
"client_id": {c.OneAppClientID},
"encryptedPassword": {"true"},
"password": {encryptedPassword},
"redirect_uri": {c.OneAppRedirectURI},
"scope": {""},
"nonce": {""},
"state": {"ccsp"},
"username": {v.user},
"connector_session_key": {""},
"kid": {certRes.RetValue.Kid},
"_csrf": {""},
}
signinReq, err := request.New(http.MethodPost, v.config.LoginFormHost+"/auth/account/signin", strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"User-Agent": cciMobileUserAgent,
})
if err != nil {
return nil, err
}
// the auth code arrives in the Location header, so redirects must not be followed
v.Client.CheckRedirect = request.DontFollow
signinResp, err := v.Client.Do(signinReq)
v.Client.CheckRedirect = nil
if err != nil {
return nil, err
}
signinBody, _ := io.ReadAll(signinResp.Body)
signinResp.Body.Close()
if signinResp.StatusCode != http.StatusFound {
return nil, fmt.Errorf("signin failed: HTTP %d (%s)", signinResp.StatusCode, request.Truncate(string(signinBody)))
}
loc, err := signinResp.Location()
if err != nil {
return nil, fmt.Errorf("signin returned no valid redirect: %w", err)
}
code := loc.Query().Get("code")
if code == "" {
switch {
case strings.Contains(loc.Path, "/web/v1/user/authorization"):
return nil, errors.New("account consent required: log in via the manufacturer app once to accept the terms, then retry")
case loc.Query().Get("error_description") != "":
return nil, fmt.Errorf("signin rejected: %s", loc.Query().Get("error_description"))
default:
return nil, fmt.Errorf("unexpected redirect after signin: %s", request.Truncate(loc.String()))
}
}
bundle, err := v.exchangeCCIToken(deviceID, code)
if err != nil {
return nil, err
}
bundle.DeviceID = deviceID
bundle.AccessToken, bundle.Expiry, err = v.exchangeCCSToken(deviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken)
if err != nil {
return nil, err
}
v.persistBundle(bundle)
v.log.DEBUG.Println("cci: login successful")
return bundle.token(), nil
}
// refreshCCI refreshes the CCI token set and re-exchanges the CCS token. The
// token set lives on the Identity, so the passed oauth2 token is ignored.
func (v *Identity) refreshCCI(_ *oauth2.Token) (*oauth2.Token, error) {
v.log.DEBUG.Println("cci: refreshing token")
bundle := v.bundle
headers := v.cciHeaders(bundle.DeviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken, request.JSONContent)
body := map[string]string{
"accessToken": bundle.CCIAccessToken,
"refreshToken": bundle.RefreshToken,
"exchangeableAccessToken": bundle.ExchangeableToken,
"exchangeableRefreshToken": bundle.ExchangeableRefreshToken,
"nonCcsToken": bundle.NonCcsToken,
"nonCcsRefreshToken": bundle.NonCcsRefreshToken,
"idToken": bundle.IDToken,
}
uri := v.config.CCI.APIURL + "/domain/api/v2/auth/token-refresh"
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(body), headers)
if err != nil {
return nil, err
}
var res cciTokenResponse
if err := v.DoJSON(req, &res); err != nil {
return nil, fmt.Errorf("cci token refresh failed: %w", err)
}
res.apply(&bundle)
bundle.AccessToken, bundle.Expiry, err = v.exchangeCCSToken(bundle.DeviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken)
if err != nil {
return nil, err
}
v.persistBundle(bundle)
v.log.DEBUG.Println("cci: refresh successful")
return bundle.token(), nil
}
func (v *Identity) persistBundle(bundle cciBundle) {
v.bundle = bundle
if err := settings.SetJson(v.settingsKey(), bundle); err != nil {
v.log.WARN.Printf("cci: persisting token failed: %v", err)
}
}
// cciTokenResponse is the response of the CCI token and token-refresh endpoints
type cciTokenResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
NonCcsToken string `json:"nonCcsToken"`
ExchangeableAccessToken string `json:"exchangeableAccessToken"`
ExchangeableRefreshToken string `json:"exchangeableRefreshToken"`
NonCcsRefreshToken string `json:"nonCcsRefreshToken"`
IDToken string `json:"idToken"`
}
// apply copies the non-empty response fields into bundle, keeping unchanged ones
func (res cciTokenResponse) apply(bundle *cciBundle) {
for _, f := range []struct {
val string
dst *string
}{
{res.AccessToken, &bundle.CCIAccessToken},
{res.RefreshToken, &bundle.RefreshToken},
{res.NonCcsToken, &bundle.NonCcsToken},
{res.ExchangeableAccessToken, &bundle.ExchangeableToken},
{res.ExchangeableRefreshToken, &bundle.ExchangeableRefreshToken},
{res.NonCcsRefreshToken, &bundle.NonCcsRefreshToken},
{res.IDToken, &bundle.IDToken},
} {
if f.val != "" {
*f.dst = f.val
}
}
}
// exchangeCCIToken exchanges an authorization code for the CCI token set
func (v *Identity) exchangeCCIToken(deviceID, code string) (cciBundle, error) {
uri := v.config.CCI.APIURL + "/domain/api/v1/auth/token?code=" + url.QueryEscape(code)
req, err := request.New(http.MethodPost, uri, nil, v.cciHeaders(deviceID, "", "", "", ""))
if err != nil {
return cciBundle{}, err
}
var res cciTokenResponse
if err := v.DoJSON(req, &res); err != nil {
return cciBundle{}, fmt.Errorf("cci token exchange failed: %w", err)
}
var bundle cciBundle
res.apply(&bundle)
return bundle, nil
}
// exchangeCCSToken exchanges a CCI access token for a CCS token, which the
// legacy ccapi vehicle/control endpoints accept as Bearer access_token
func (v *Identity) exchangeCCSToken(deviceID, cciAccessToken, nonCcsToken, exchangeableToken string) (string, time.Time, error) {
uri := v.config.CCI.APIURL + "/domain/api/v1/auth/token-exchange?serviceType=CCS"
headers := v.cciHeaders(deviceID, cciAccessToken, nonCcsToken, exchangeableToken, "")
req, err := request.New(http.MethodPost, uri, nil, headers)
if err != nil {
return "", time.Time{}, err
}
var res struct {
AccessToken string `json:"accessToken"`
ExpiresTime int64 `json:"expiresTime"` // unix seconds
}
if err := v.DoJSON(req, &res); err != nil {
return "", time.Time{}, fmt.Errorf("ccs token exchange failed: %w", err)
}
if res.AccessToken == "" {
return "", time.Time{}, errors.New("ccs token exchange returned no access token")
}
return res.AccessToken, parseCCSExpiry(res.ExpiresTime), nil
}
const (
ccsExpiryFallback = time.Hour // used when expiresTime is missing or implausible
ccsExpiryMaxValidity = 24 * time.Hour // upper bound of a plausible expiry
)
// parseCCSExpiry interprets the token-exchange expiresTime, falling back to a
// fixed lifetime rather than risking an always-expired token
func parseCCSExpiry(expiresTime int64) time.Time {
now := time.Now()
if t := time.Unix(expiresTime, 0); t.After(now) && t.Before(now.Add(ccsExpiryMaxValidity)) {
return t
}
return now.Add(ccsExpiryFallback)
}
// cciHeaders builds the headers required by the CCI API. The token parameters
// are empty before the initial code exchange, contentType for bodyless requests
func (v *Identity) cciHeaders(deviceID, cciAccessToken, nonCcsToken, exchangeableToken, contentType string) map[string]string {
c := v.config.CCI
headers := map[string]string{
"client-id": c.PackageID,
"client-name": c.ClientName,
"client-version": cciClientVersion,
"client-os-code": "ios",
"client-os-version": c.OSVersion,
"client-device-id": deviceID,
"client-device-model": "iPhone",
"client-notification-provider-type": c.NotificationProvider,
"locale": strings.ToUpper(v.language),
"timezone": time.Now().Format("-07:00"),
"Accept": request.JSONContent,
"Accept-Language": v.language,
"User-Agent": cciMobileUserAgent,
}
if nonCcsToken != "" {
headers["Authentication"] = nonCcsToken
}
if cciAccessToken != "" {
headers["authorization"] = "Bearer " + strings.TrimPrefix(strings.TrimSpace(cciAccessToken), "Bearer ")
}
if exchangeableToken != "" {
headers["exchangeable-token"] = exchangeableToken
headers["non-ccs-token"] = nonCcsToken
}
if contentType != "" {
headers["Content-Type"] = contentType
}
return headers
}
// encryptCCIPassword RSA/PKCS1v15-encrypts password using the JWK public key
// returned by the /accounts/certs endpoint and hex-encodes the ciphertext
func encryptCCIPassword(nb64, eb64, password string) (string, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(nb64, "="))
if err != nil {
return "", fmt.Errorf("invalid rsa modulus: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(eb64, "="))
if err != nil {
return "", fmt.Errorf("invalid rsa exponent: %w", err)
}
e := 0
for _, b := range eBytes {
e = e<<8 | int(b)
}
pub := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: e,
}
ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, pub, []byte(password))
if err != nil {
return "", err
}
return hex.EncodeToString(ciphertext), nil
}