[sunsynk] Adapt to new authentication requirements (#19656)

* Sunsynk Client Authorisation breaking changes: SunSynk initial connection now require a nonce, sign and public key to receive the bearer token.

Signed-off-by: LeeC77 <lee.charlton00@gmail.com>
This commit is contained in:
Lee Charlton
2025-11-22 16:07:08 +01:00
committed by GitHub
parent 3a2d88e2f5
commit c9fe5077b1
6 changed files with 240 additions and 32 deletions
@@ -16,11 +16,22 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Objects;
import java.util.Properties;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
@@ -32,8 +43,10 @@ import org.openhab.binding.sunsynk.internal.api.dto.Client;
import org.openhab.binding.sunsynk.internal.api.dto.Details;
import org.openhab.binding.sunsynk.internal.api.dto.Inverter;
import org.openhab.binding.sunsynk.internal.api.dto.SunSynkLogin;
import org.openhab.binding.sunsynk.internal.api.dto.SunSynkPublicKey;
import org.openhab.binding.sunsynk.internal.api.dto.TokenRefresh;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkAuthenticateException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkClientAuthenticateException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkInverterDiscoveryException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkTokenException;
import org.openhab.core.io.net.http.HttpUtil;
@@ -56,23 +69,63 @@ public class AccountController {
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private static final String BEARER_TYPE = "Bearer ";
private static final long EXPIRYSECONDS = 100L; // 100 seconds before expiry
private static final String SECRET_KEY = "POWER_VIEW"; // Is the SunSynk Connect App secret key
private static final String SOURCE = "sunsynk"; // Is the SunSynk Connect App source identifier
private Client sunAccount = new Client();
private SunSynkPublicKey publicKey = new SunSynkPublicKey();
public AccountController() {
}
/**
* Authenticates the Account Thing as a Sunsynk Client,
* then encrypts the user password to call authentication of the user credentials
*
* @param username The username you use with Sunsynk Connect App
* @param userPassword The password you use with Sunsynk Connect App
* @throws SunSynkAuthenticateException
* @throws SunSynkTokenException
*/
public void clientAuthenticate(String username, String userPassword)
throws SunSynkClientAuthenticateException, SunSynkAuthenticateException {
long nonce = Instant.now().toEpochMilli();
try {
String authEndpoint = "nonce=" + String.valueOf(nonce) + "&source=" + SOURCE + "&sign=" + getSign(nonce);
httpGetPublicKey(authEndpoint);
String encryptedPassword = getEncryptPassword(userPassword, this.publicKey.getPublicKey());
userAuthenticate(username, encryptedPassword);
} catch (NoSuchAlgorithmException e) {
throw new SunSynkClientAuthenticateException("Error attempting to authenticate client:" + e.getMessage());
} catch (NoSuchPaddingException e) {
throw new SunSynkClientAuthenticateException(
"Required encryption algorithm or padding not available: " + e.getMessage());
} catch (InvalidKeySpecException e) {
throw new SunSynkClientAuthenticateException("The provided public key is invalid: " + e.getMessage());
} catch (IllegalBlockSizeException | BadPaddingException e) {
throw new SunSynkClientAuthenticateException("Error processing data block size/padding: " + e.getMessage());
} catch (SunSynkAuthenticateException e) {
String message = Objects.requireNonNullElse(e.getMessage(), "An unknown authentication error occurred");
throw new SunSynkAuthenticateException(message);
} catch (SunSynkTokenException e) {
String message = Objects.requireNonNullElse(e.getMessage(), "An unknown token error occurred");
throw new SunSynkAuthenticateException(message);
} catch (Exception e) { // Catch the generic one last
throw new SunSynkClientAuthenticateException("An unexpected error occurred: " + e.getMessage());
}
}
/**
* Authenticates a Sunsynk Connect API account using a username and password.
*
* @param username The username you use with Sunsynk Connect App
* @param password The password you use with Sunsynk Connect App
* @param encryptedPassword The password you use with Sunsynk Connect App, salted and encrypted with the public key
* @throws SunSynkAuthenticateException
* @throws SunSynkTokenException
*/
public void authenticate(String username, String password)
public void userAuthenticate(String username, String saltedPassword)
throws SunSynkAuthenticateException, SunSynkTokenException {
String payload = makeLoginBody(username, password);
sendHttp(payload);
String payload = makeLoginBody(username, saltedPassword);
httpTokenPost(payload);
}
/**
@@ -94,29 +147,53 @@ public class AccountController {
}
logger.debug("Account configuration token expired : {}", this.sunAccount.getData().toString());
String payload = makeRefreshBody(username, this.sunAccount.getRefreshTokenString());
sendHttp(payload);
httpTokenPost(payload);
}
@SuppressWarnings("unused") // We need client to be nullable. Then we check for null. Without this compiler warns of
// unsed block under null check
private void sendHttp(String payload) throws SunSynkAuthenticateException, SunSynkTokenException {
private void httpGetPublicKey(String endpoint) throws SunSynkClientAuthenticateException, JsonSyntaxException {
Gson gson = new Gson();
String response = "";
String httpsURL = makeLoginURL("oauth/token");
String httpsURL = makeLoginURL("anonymous/publicKey?" + endpoint);
Properties headers = new Properties();
headers.setProperty(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
headers.setProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
try {
response = HttpUtil.executeUrl(HttpMethod.GET.asString(), httpsURL, headers, null,
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
if (gson.fromJson(response, SunSynkPublicKey.class) instanceof SunSynkPublicKey key) {
this.publicKey = key;
return;
}
throw new SunSynkClientAuthenticateException("Failed get private key");
} catch (IOException | JsonSyntaxException e) {
if (logger.isDebugEnabled()) {
String message = Objects.requireNonNullElse(e.getMessage(), "unknown error message");
Throwable cause = e.getCause();
String causeMessage = cause != null ? Objects.requireNonNullElse(cause.getMessage(), "unknown cause")
: "unknown cause";
logger.debug("Error authorising Account Thing: Msg = {}. Cause = {}.", message, causeMessage);
}
throw new SunSynkClientAuthenticateException("Account Thing authorisation failed");
}
}
private void httpTokenPost(String payload) throws SunSynkAuthenticateException, SunSynkTokenException {
Gson gson = new Gson();
String response = "";
String httpsURL = makeLoginURL("oauth/token/new");
Properties headers = new Properties();
headers.setProperty(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
headers.setProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
try {
response = HttpUtil.executeUrl(HttpMethod.POST.asString(), httpsURL, headers, stream,
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
@Nullable
Client client = gson.fromJson(response, Client.class);
if (client == null) {
if (gson.fromJson(response, Client.class) instanceof Client client) {
this.sunAccount = client;
} else {
throw new SunSynkAuthenticateException(
"Sun Synk account could not be authenticated: Try re-enabling account");
"Synk account could not be authenticated: Try re-enabling account");
}
this.sunAccount = client;
} catch (IOException | JsonSyntaxException e) {
throw new SunSynkAuthenticateException("Sun Synk account could not be authenticated", e);
}
@@ -133,15 +210,43 @@ public class AccountController {
getToken();
}
private void getToken() throws SunSynkAuthenticateException {
APIdata data = this.sunAccount.getData();
APIdata.staticAccessToken = data.getAccessToken();
/**
* Performs RSA encryption on raw data using a provided public key string.
* It handles Base64 decoding, key loading, encryption with PKCS1 padding,
* and Base64 encoding of the final output.
*
* @param userPassword The raw, unpadded byte array of credentials (e.g., "password").
* @param publicKeyString The Base64 encoded X.509 public key string (the MIIC... string).
* @return The final Base64 encoded encrypted password string.
* @throws Exception if any crypto operations fail.
*/
private String getEncryptPassword(String userPassword, String publicKeyString) throws Exception {
// Prepare the credentials as bytes
byte[] rawCredentials = (userPassword).getBytes(StandardCharsets.UTF_8);
// Load the Public Key using DER format
// The public_key_string (String) is Base64 decoded into raw bytes (bytes)
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyString);
// Load the key using the X.509/DER loader
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
// Encrypt the raw data using library's built-in padding
// Use the specific algorithm/padding scheme: RSA/ECB/PKCS1Padding
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// The library handles generating 512 bytes, padding, and random bytes automatically
byte[] encryptedBytes = cipher.doFinal(rawCredentials);
// Format the Output
// The website likely sends the encrypted bytes as a Base64 encoded string
String finalEncryptedPassword = Base64.getEncoder().encodeToString(encryptedBytes);
logger.trace("The final encrypted password string is: {} ", finalEncryptedPassword);
return finalEncryptedPassword;
}
/**
* Discovers a list of all inverter tied to a Sunsynk Connect Account
*
* @return List of connected inveters
* @return List of connected inverters
* @throws SunSynkInverterDiscoveryException
*/
@SuppressWarnings("unused")
@@ -160,11 +265,11 @@ public class AccountController {
MediaType.APPLICATION_JSON, TIMEOUT_IN_MS);
logger.trace("Account Details Response: {}", response);
@Nullable
Details maybeDeats = gson.fromJson(response, Details.class);
if (maybeDeats == null) {
Details maybeDetails = gson.fromJson(response, Details.class);
if (maybeDetails == null) {
throw new SunSynkInverterDiscoveryException("Failed to discover Inverters");
}
output = maybeDeats;
output = maybeDetails;
} catch (IOException | JsonSyntaxException e) {
if (logger.isDebugEnabled()) {
String message = Objects.requireNonNullElse(e.getMessage(), "unknown error message");
@@ -196,6 +301,25 @@ public class AccountController {
return gson.toJson(refresh);
}
private void getToken() throws SunSynkAuthenticateException {
APIdata data = this.sunAccount.getData();
APIdata.staticAccessToken = data.getAccessToken();
}
private String getSign(Long nonce) throws NoSuchAlgorithmException {
String inputString = "nonce=" + String.valueOf(nonce) + "&source=" + SOURCE + SECRET_KEY;
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] hashBytes = messageDigest.digest(inputString.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
logger.trace("nonce : {} encrypted nonce : {}", nonce.toString(), sb.toString());
return sb.toString();
}
@Override
public String toString() {
try {
@@ -39,7 +39,7 @@ public class Client {
public Client() {
}
public static String getAccessTokenString() {
public String getAccessTokenString() {
return APIdata.staticAccessToken;
}
@@ -35,6 +35,8 @@ public class SunSynkLogin {
private String grantType = "password";
@SerializedName("client_id")
private String clientId = "csp-web";
@SerializedName("source")
private String source = "sunsynk";
public SunSynkLogin(String UserName, String PassWord) {
this.userName = UserName;
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.sunsynk.internal.api.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SunSynkPublicKey} is the internal class for the SunSynk public key
* for a SunSynk Connect Account.
* Login via Username and Password
*
* @author Lee Charlton - Initial contribution
*/
@NonNullByDefault
public class SunSynkPublicKey {
private int code;
private String msg = "";
private boolean success;
private String data = "";
public String getPublicKey() {
return this.data;
}
@Override
public String toString() {
return "code = " + this.code + " message = " + this.msg + " success = " + this.success;
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.sunsynk.internal.api.exception;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SunSynkClientAuthenticateException}{@link Exception}
*
* @author Lee Charlton - Initial contribution
*/
@NonNullByDefault
public class SunSynkClientAuthenticateException extends Exception {
private static final long serialVersionUID = 6L;
public SunSynkClientAuthenticateException(String message) {
super(message);
}
public SunSynkClientAuthenticateException(String message, Throwable cause) {
super(message, cause);
}
public SunSynkClientAuthenticateException(Throwable cause) {
super(cause);
}
}
@@ -14,7 +14,6 @@ package org.openhab.binding.sunsynk.internal.handler;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -23,6 +22,7 @@ import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.sunsynk.internal.api.AccountController;
import org.openhab.binding.sunsynk.internal.api.dto.Inverter;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkAuthenticateException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkClientAuthenticateException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkInverterDiscoveryException;
import org.openhab.binding.sunsynk.internal.api.exception.SunSynkTokenException;
import org.openhab.binding.sunsynk.internal.config.SunSynkAccountConfig;
@@ -108,17 +108,20 @@ public class SunSynkAccountHandler extends BaseBridgeHandler {
return;
}
try {
this.sunAccount.authenticate(accountConfig.getEmail(), accountConfig.getPassword());
} catch (SunSynkAuthenticateException | SunSynkTokenException e) {
this.sunAccount.clientAuthenticate(accountConfig.getEmail(), accountConfig.getPassword());
} catch (SunSynkClientAuthenticateException e) {
if (logger.isDebugEnabled()) {
String message = Objects.requireNonNullElse(e.getMessage(), "unkown error message");
Throwable cause = e.getCause();
String causeMessage = cause != null ? Objects.requireNonNullElse(cause.getMessage(), "unkown cause")
: "unkown cause";
logger.debug("Error attempting to authenticate account Msg = {} Cause = {}", message, causeMessage);
logger.debug("Error attempting to authenticate client: {}.", e.getMessage());
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Error attempting to authenticate account");
"Error attempting to authenticate binding with SunSynk");
return;
} catch (SunSynkAuthenticateException e) {
if (logger.isDebugEnabled()) {
logger.debug("Error attempting to authenticate user: {}.", e.getMessage());
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Error attempting to authenticate user credentials");
return;
}
updateStatus(ThingStatus.ONLINE);