Skip to content

Commit

Permalink
feature/encryption (#2)
Browse files Browse the repository at this point in the history
* API encryption

* API encryption

* Build on feature branches

* Optimize imports

* Code optimizations

* Using secure algorithms

---------

Co-authored-by: christian <dev@bestof5.de>
  • Loading branch information
Afrouper and christian authored Jan 3, 2025
1 parent cd10103 commit fc3708e
Show file tree
Hide file tree
Showing 12 changed files with 364 additions and 32 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ permissions:

on:
push:
branches: [ main ]
branches:
- main
- 'releases/**'
- 'feature/**'
pull_request:
branches: [ main ]

Expand Down
16 changes: 9 additions & 7 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The Client is still in development and not finished.
## ToDos
- [x] Add github actions for compile, dependabot and securiy issues
- [x] Add sample client for basic usage
- [x] Support encrypted API calls (open: Change password for each call)
- [ ] Add release action
- [x] Mock JUnit Test
- [ ] **More** JUnit Test
Expand All @@ -30,13 +31,14 @@ credentials (see [Sungrow Developer Portal](#Sungrow Developer Portal))
A sample can be found at [Client.java](src/test/java/de/afrouper/server/sungrow/Client.java). If not provided the needed properties are read from
a Java SystemProperty and (if not found) from an environment variable:

| SystemProperty / Environment variable | meaning |
|---------------------------------------|-------------------------------------------------------------------------|
| APP_KEY | Your app key from Sungrow Developer Portal |
| SECRET_KEY | Your secret key from Sungrow Developer Portal |
| ACCOUNT_EMAIL | EMailadress used for login to Sungrow Developer Portal |
| ACCOUNT_PASSWORD | Password used for login to Sungrow Developer Portal |
| RSA_PUBLIC_KEY | (**Currently not Supported**) RSA Public key to call APIs E2E encrypted |
| SystemProperty / Environment variable | meaning |
|---------------------------------------|--------------------------------------------------------|
| APP_KEY | Your app key from Sungrow Developer Portal |
| SECRET_KEY | Your secret key from Sungrow Developer Portal |
| ACCOUNT_EMAIL | EMailadress used for login to Sungrow Developer Portal |
| ACCOUNT_PASSWORD | Password used for login to Sungrow Developer Portal |
| RSA_PUBLIC_KEY | RSA Public key to call APIs E2E encrypted |
| API_CALL_PASSWORD | Password for payload encryption |

The URL for accessing the correct cloud service is determined from the [Region](../src/main/java/de/afrouper/server/sungrow/api/SungrowClientFactory.java) Enum.

Expand Down
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
<scope>compile</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/commons-codec/commons-codec -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.17.1</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
161 changes: 161 additions & 0 deletions src/main/java/de/afrouper/server/sungrow/api/EncryptionUtility.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package de.afrouper.server.sungrow.api;

import de.afrouper.server.sungrow.api.dto.ApiKeyParameter;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Objects;
import java.util.UUID;

class EncryptionUtility {

private final Base64.Encoder encoder = Base64.getUrlEncoder();
private final Base64.Decoder decoder = Base64.getUrlDecoder();

private final String rsaPublicKey;

private final SecretKey secretKey;

EncryptionUtility(String rsaPublicKey, String password) {
Objects.requireNonNull(rsaPublicKey, "RSA public key cannot be null");
Objects.requireNonNull(password, "Password cannot be null");
this.rsaPublicKey = rsaPublicKey;
byte[] passwordBytes = getSecretKey(password) ;
secretKey = new SecretKeySpec(passwordBytes, "AES");
}

String createRandomPublicKey() {
try {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(decoder.decode(rsaPublicKey));
RSAPublicKey key = (RSAPublicKey)keyFactory.generatePublic(x509KeySpec);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] bytes = rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE, secretKey.getEncoded(), key.getModulus().bitLength());
return encoder.encodeToString(bytes);
} catch (IOException | GeneralSecurityException e) {
throw new RuntimeException(e);
}
}

ApiKeyParameter createApiKeyParameter() {
ApiKeyParameter apiKeyParameter = new ApiKeyParameter();
apiKeyParameter.setNonce(UUID.randomUUID().toString().replaceAll("-", ""));
apiKeyParameter.setTimestamp(Long.toString(System.currentTimeMillis()));
return apiKeyParameter;
}

/**
*AES encryption rule:
*Encryption mode:ECB
*Padding method:pkcs5padding
*data block:128 bit
*Offset:no offset
*Output:hex
*Character set:utf8 encoding
**/
String encrypt(String content) {
try {
Cipher cipher = Cipher.getInstance("AES"); //documented is unsecure "AES/ECB/PKCS5Padding"
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] result = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
return parseByte2HexStr(result);
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}

/**
*Decryption mode:ECB
*Padding method:pkcs5padding
*Data block:128 bit
*Offset:no offset
*Output:hex
*Character set:utf8 encoding;
**/
String decrypt(String content) {
try {
byte[] decryptFrom = parseHexStr2Byte(content);
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] original = cipher.doFinal(decryptFrom);
return new String(original);
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}

private byte[] rsaSplitCodec(Cipher cipher, int opmode, byte[] datas, int keySize) throws IOException, GeneralSecurityException {
int maxBlock;
if(opmode == Cipher.DECRYPT_MODE){
maxBlock = keySize / 8;
}else{
maxBlock = keySize / 8 - 11;
}
try(ByteArrayOutputStream out = new ByteArrayOutputStream()) {
int offSet = 0;
byte[] buff;
int i = 0;
while(datas.length > offSet){
if(datas.length - offSet > maxBlock){
buff = cipher.doFinal(datas, offSet, maxBlock);
}else{
buff = cipher.doFinal(datas, offSet, datas.length - offSet);
}
out.write(buff, 0, buff.length);
i++;
offSet = i * maxBlock;
}
return out.toByteArray();
}
}

private byte[] getSecretKey(String key) {
final byte paddingChar = '0';
byte[] realKey = new byte[16];
byte[] byteKey = key.getBytes(StandardCharsets.UTF_8);
for (int i =0;i<realKey.length;i++){
if (i<byteKey.length){
realKey[i] = byteKey[i];
}else{
realKey[i] = paddingChar;
}
}
return realKey;
}

private String parseByte2HexStr(byte[] buf) {
StringBuilder sb = new StringBuilder();
for (byte b : buf) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
hex = '0' + hex;
}
sb.append(hex.toUpperCase());
}
return sb.toString();
}

private byte[] parseHexStr2Byte(String hexStr) {
if (hexStr.isEmpty()) {
return null;
}
byte[] result = new byte[hexStr.length() / 2];
for (int i = 0; i < hexStr.length() / 2; i++) {
int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16);
int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2),
16);
result[i] = (byte) (high * 16 + low);
}
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,8 @@ static String getRSAPublicKey() {
return get("RSA_PUBLIC_KEY", null);
}

static URI getURI() {
try {
return new URI(get("SUNGROW_URI", "https://gateway.isolarcloud.eu/"));
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
static String getApiCallPassword() {
return get("API_CALL_PASSWORD", null);
}

static Duration getConnectionTimeout() {
Expand Down
79 changes: 62 additions & 17 deletions src/main/java/de/afrouper/server/sungrow/api/SungrowClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class SungrowClient {
Expand All @@ -26,6 +28,7 @@ public class SungrowClient {
private final URI uri;
private final Gson gson;
private LoginResponse loginResponse;
private EncryptionUtility encryptionUtility;
private LocalDateTime lastAPICall;

SungrowClient(URI uri, String appKey, String secretKey, Duration connectTimeout, Duration requestTimeout) {
Expand All @@ -44,6 +47,11 @@ public class SungrowClient {
.build();
gson = new GsonBuilder()
.create();

}

void activateEncryption(String rsaPublicKey, String password) {
encryptionUtility = new EncryptionUtility(rsaPublicKey, password);
}

public void login() throws IOException {
Expand All @@ -52,15 +60,30 @@ public void login() throws IOException {

public void login(String username, String password) throws IOException {
try {
Login login = new Login(username, password, appKey);

String json;
if(encryptionUtility != null) {
login.setApiKey(encryptionUtility.createApiKeyParameter());
json = encryptionUtility.encrypt(gson.toJson(login));
}
else {
json = gson.toJson(login);
}

HttpRequest request = HttpRequest.newBuilder(uri.resolve("/openapi/login"))
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(new Login(username, password, appKey))))
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(requestTimeout)
.headers(getDefaultHeaders())
.build();

HttpResponse<String> send = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String body = send.body();
if(send.statusCode() >= 200 && send.statusCode() < 500 && encryptionUtility != null) {
body = encryptionUtility.decrypt(body);
}
if (send.statusCode() == 200) {
LoginResponse loginResponse = gson.fromJson(send.body(), LoginResponse.class);
LoginResponse loginResponse = gson.fromJson(body, LoginResponse.class);
if(loginResponse.isSuccess()) {
LoginResponse.LoginResult loginResult = loginResponse.getData();
if(loginResult.getLoginState().equals(LoginState.SUCCESS)) {
Expand All @@ -72,11 +95,11 @@ public void login(String username, String password) throws IOException {
}
}
else {
throw new IOException("Login error: '" + send.body() + "'");
throw new IOException("Login error: '" + body + "'");
}
}
else {
throw new IOException("Login failed. ResponseCode " + send.statusCode() + ": '" + send.body() + "'");
throw new IOException("Login failed. ResponseCode " + send.statusCode() + ": '" + body + "'");
}
}
catch (InterruptedException e) {
Expand All @@ -85,11 +108,18 @@ public void login(String username, String password) throws IOException {
}

private String[] getDefaultHeaders() {
return new String[] {
"Content-Type", "application/json",
"x-access-key", secretKey,
"sys_code", "901"
};
List<String> headers = new ArrayList<>();
headers.add("Content-Type");
headers.add("application/json");
headers.add("x-access-key");
headers.add(secretKey);
headers.add("sys_code");
headers.add("901");
if(encryptionUtility != null) {
headers.add("x-random-secret-key");
headers.add(encryptionUtility.createRandomPublicKey());
}
return headers.toArray(new String[0]);
}

private void apiCallSuccess() {
Expand All @@ -100,38 +130,53 @@ public void execute(APIOperation operation) throws IOException {
if(operation.getMethod() != APIOperation.Method.POST) {
throw new IOException("Method not supported: " + operation.getMethod());
}
String json = null;
String jsonResponse = null;
try {
BaseRequest baseRequest = operation.getRequest();
baseRequest.setAppKey(appKey);
baseRequest.setToken(loginResponse.getData().getToken());

String jsonRequest;
if(encryptionUtility != null) {
baseRequest.setApiKey(encryptionUtility.createApiKeyParameter());
jsonRequest = encryptionUtility.encrypt(gson.toJson(baseRequest));
}
else {
jsonRequest = gson.toJson(baseRequest);
}

HttpRequest request = HttpRequest.newBuilder(uri.resolve(operation.getPath()))
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(baseRequest)))
.POST(HttpRequest.BodyPublishers.ofString(jsonRequest))
.timeout(requestTimeout)
.headers(getDefaultHeaders())
.build();

HttpResponse<String> send = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
json = send.body();
jsonResponse = send.body();
if(send.statusCode() >= 200 && send.statusCode() < 500 && encryptionUtility != null) {
jsonResponse = encryptionUtility.decrypt(jsonResponse);
}

if(send.statusCode() == 200) {
//System.out.println(json);
//System.out.println(jsonResponse);
Type baseResponseType = getResponseType(operation);
BaseResponse<?> baseResponse = gson.fromJson(json, baseResponseType);
BaseResponse<?> baseResponse = gson.fromJson(jsonResponse, baseResponseType);

if("1".equals(baseResponse.getErrorCode())) {
apiCallSuccess();
operation.setResponse(baseResponse.getData());
}
else {
throw new IOException("Operation error: '" + json + "'");
throw new IOException("Operation error: '" + jsonResponse + "'");
}
}
else {
throw new IOException("Operation failed. ResponseCode " + send.statusCode() + ": '" + json + "'");
throw new IOException("Operation failed. ResponseCode " + send.statusCode() + ": '" + jsonResponse + "'");
}
} catch (InterruptedException | NumberFormatException e) {
throw new RuntimeException("Unable to execute Operation. Json from server: '" + json + "'.", e);
throw new RuntimeException("Unable to execute Operation. Json from server: '" + jsonResponse + "'.", e);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

Expand Down
Loading

0 comments on commit fc3708e

Please sign in to comment.