GoSungrow
GoSungrow copied to clipboard
Error 'er_invalid_appkey' / 'Request is not encrypted'
Since tonight I get with https://gateway.isolarcloud.eu
in HA the following error:
ERROR: appkey is incorrect 'er_invalid_appkey
Checking with https://portaleu.isolarcloud.com/
I realized that the appkey could have changed to B0455FBE7AA0328DB57B59AA729F05D8
(at least I find this key when searching for the term appkey
) .
When doing a direct request at /v1/userService/login
at least I don't get any more an invalid_appkey error but now an Request is not encrypted
error.
When looking at the source of https://portaleu.isolarcloud.com/#/dashboard
there is the following function:
e.data.set("appkey", a.a.encryptHex(e.data.get("appkey"), h))
Did Sungrow changed the API access? How to deal with this change?
It seems that the entire payload is encrypted:
e.data = a.a.encryptHex(JSON.stringify(e.data), h))
with a new encryption key per request ex.: "webDK6Xl15mzc2RW"
I think they are using the standard CryptoJS
CryptoJS.AES.encrypt("Message", "Secret Passphrase").ciphertext
as seen in their source:
encryptHex: function(e, t) { return i(e, t).ciphertext.toString() }
Hi, same problem.
[09:36:41] INFO: Login to iSolarCloud using gateway https://gateway.isolarcloud.eu ... Error: appkey is incorrect 'er_invalid_appkey' Usage: GoSungrow api login [flags]
The key not change: 93D72E60331ABDCDC7B39ADC2D1F32B3
| --appkey | | GOSUNGROW_APPKEY | SunGrow: api application key. | 93D72E60331ABDCDC7B39ADC2D1F32B3 |
| | | | | * |
| --host | | GOSUNGROW_HOST | SunGrow: Provider API URL. | https://gateway.isolarcloud.eu
Regards
I have the same Problem.
[13:45:06] INFO: Login to iSolarCloud using gateway https://gateway.isolarcloud.eu ... Error: appkey is incorrect 'er_invalid_appkey'
| --appkey | | GOSUNGROW_APPKEY | SunGrow: api application key. | 93D72E60331ABDCDC7B39ADC2D1F32B3
ERROR: appkey is incorrect 'er_invalid_appkey' s6-rc: info: service legacy-services: stopping s6-rc: info: service legacy-services successfully stopped s6-rc: info: service legacy-cont-init: stopping s6-rc: info: service legacy-cont-init successfully stopped s6-rc: info: service fix-attrs: stopping s6-rc: info: service fix-attrs successfully stopped s6-rc: info: service s6rc-oneshot-runner: stopping s6-rc: info: service s6rc-oneshot-runner successfully stopped
Hallo, same problem, with the same error messages...
Same problem
Same problem with the Australian server as well. I did upgrade my firmware and noticed I got a Session Expired error in the app which forced me to log in again
Same here, issue appears to start last week
https://augateway.isolarcloud.eu/
[3.0.7] - 2023-09-04
Tried
init: false Init: true hassio_role: default host_pid: true
Hi,
I have the same Problem.
[20:55:36] INFO: Login to iSolarCloud using gateway https://gateway.isolarcloud.eu ... Error: appkey is incorrect 'er_invalid_appkey' Usage: GoSungrow api login [flags]
Examples: GoSungrow api login
Flags: Use "GoSungrow help flags" for more info.
Additional help topics:
ERROR: appkey is incorrect 'er_invalid_appkey' s6-rc: info: service legacy-services: stopping s6-rc: info: service legacy-services successfully stopped s6-rc: info: service legacy-cont-init: stopping s6-rc: info: service legacy-cont-init successfully stopped s6-rc: info: service fix-attrs: stopping s6-rc: info: service fix-attrs successfully stopped s6-rc: info: service s6rc-oneshot-runner: stopping s6-rc: info: service s6rc-oneshot-runner successfully stopped
It also says: | --token-expiry | | GOSUNGROW_TOKEN_EXPIRY | SunGrow: last login. | 2023-11-19T16:14:03
same problem, the same error
As @BTDrink mentioned the payload is encrypted. The session_key (ie. "webDK6Xl15mzc2RW") is generated on client side. It is then encrypted with public key (rsaEncryption (PKCS 1)) and send in the header:
var _ = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNA ...."
var h = e.randomKey;
return c.a.isUndefinedOrNull(h) || (e.headers["x-random-secret-key"] = v.a.sgEncrypt(h, _),
Same issue, following for a fix
Same Problem here
Same problme here - appkex 93D72E60331ABDCDC7B39ADC2D1F32B3 is used
[07:02:06] INFO: Login to iSolarCloud using gateway https://gateway.isolarcloud.eu ... Error: appkey is incorrect 'er_invalid_appkey' Usage: GoSungrow api login [flags]
Examples: GoSungrow api login
same here
Same here. I digged into the web interface's source code, and found they use an other key, which is called WebAppKey in the current repo here: https://github.com/MickMake/GoSungrow/blob/391253aaadd2cae9df32c95bec9e6b9bbf83f4d6/iSolarCloud/WebAppService/getMqttConfigInfoByAppkey/data.go#L17
If I use this appkey "B0455FBE7AA0328DB57B59AA729F05D8" than I get an other error... about encryption. They probably changed the encryption method.
./GoSungrow api login
Error: unknown error 'Request is not encrypted'
Usage:
GoSungrow api login [flags]
Examples:
GoSungrow api login
Flags: Use "GoSungrow help flags" for more info.
Additional help topics:
ERROR: unknown error 'Request is not encrypted'
same problem here. Hoping for a fix ... 😳 @MickMake
same problem, waiting for the correction.. thx
Body is encrypted using the randomKey and standard AES in ECB mode with PKCS7 padding. Decryption is done using the same key and parameters.
The x-random-secret-key
is the randomKey encrypted using the secretKey (app and web seem to have different keys) with RSA and pkcs1.
The secretKey seems to be related to x-access-key
as changing one will break the encryption.
The randomKey is random generated by using a prefix (web
or and
) based on if you are using web or the android app. Guessing that ios would be ios
but can't verify that atm.
Hope that helps bringing the project back to working.
Got the functions done in NodeJS but not in Go at this time:
import * as CryptoJS from "crypto-js";
import NodeRSA from "node-rsa";
export function randomKey() {
return "and" + randomString(13);
}
export function encryptAES<T>(data: T, key: string): string {
const d = CryptoJS.enc.Utf8.parse(JSON.stringify(data));
const k = CryptoJS.enc.Utf8.parse(key);
return CryptoJS.AES.encrypt(d, k, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
})
.ciphertext.toString()
.toUpperCase();
}
export function decryptAES<T>(data: string, key: string): T {
const d = CryptoJS.format.Hex.parse(data);
const k = CryptoJS.enc.Utf8.parse(key);
const dec = CryptoJS.AES.decrypt(d, k, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return JSON.parse(CryptoJS.enc.Utf8.stringify(dec)) as T;
}
export function encryptRSA(publicKey: string, value: string): string {
const key = new NodeRSA();
key.setOptions({ encryptionScheme: "pkcs1" });
key.importKey(publicKey, "pkcs8-public-pem");
return key.encrypt(value, "base64");
}
The key values for the app are:
const ACCESS_KEY = "kme8xdq4fp88wps563qd5d57vw6jxrf4"
const APP_KEY = "3A51762ED80A39AD3DF3DB3CE6767884"
const SECRET_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDlcwFJfpjOsy5U6KBpDEC9ZU_sgjD4AQ_Io0MuuGmQq8wdeLoozOdXRlkyZ2GovikSa6IXMkJ25NeChWGwBDTsnXuvZ3JIFqiTNt5eMtb42u2iHumWtv7fsjj17FFknOIIVzUMPBJ3eIb2"
Additionally there is the x-limit-obj
header that needs to be send on all non-login requests. It is constructed by encrypting the user_id
with the secretKey using the same RSA method used for the x-random-secret-key
header
It looks like there was an app key that allowed non-encrypted requests, but it's gone now. Some kind of integration got discontinued on their end perhaps? This is some terrible practice from Sungrow... I guess they want to charge money for integration with their api
Keys found for the web interface:
X_ACCESS_KEY = "9grzgbmxdsp3arfmmgq347xjbza4ysps"
SECRET_KEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkecphb6vgsBx4LJknKKes-eyj7-RKQ3fikF5B67EObZ3t4moFZyMGuuJPiadYdaxvRqtxyblIlVM7omAasROtKRhtgKwwRxo2a6878qBhTgUVlsqugpI_7ZC9RmO2Rpmr8WzDeAapGANfHN5bVr7G7GYGwIrjvyxMrAVit_oM4wIDAQAB'
APP_KEY = 'B0455FBE7AA0328DB57B59AA729F05D8'
is there an easy patch path here? Changing definitely sees the change in error message, happy to test, as I can replicate this, just crawling through their .js tome
Same issue. Following for fix. Please!
Same error here, pls explain way around
Hi, Same issue It would be very generous to fix this issue. Sorry my Go knowhow is too bad to do it on my own. Thank you
Same issue here
Here we go. A first minimal MVP. The api_key_param
is updated with each request before encryption.
import json
import random
import string
from base64 import b64decode, b64encode, urlsafe_b64decode
from datetime import datetime
import requests
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad, unpad
def encrypt_hex(data_str, key):
cipher = AES.new(key.encode("UTF-8"), AES.MODE_ECB)
date_byte = cipher.encrypt(pad(data_str.encode("UTF-8"), 16))
return date_byte.hex()
def decrypt_hex(data_hex_str, key):
cipher = AES.new(key.encode("UTF-8"), AES.MODE_ECB)
text = unpad(cipher.decrypt(bytes.fromhex(data_hex_str)), 16).decode("UTF-8")
return json.loads(text)
public_key = RSA.import_key(
urlsafe_b64decode(
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkecphb6vgsBx4LJknKKes-eyj7-RKQ3fikF5B67EObZ3t4moFZyMGuuJPiadYdaxvRqtxyblIlVM7omAasROtKRhtgKwwRxo2a6878qBhTgUVlsqugpI_7ZC9RmO2Rpmr8WzDeAapGANfHN5bVr7G7GYGwIrjvyxMrAVit_oM4wIDAQAB"
)
)
cipher = PKCS1_v1_5.new(public_key)
def encrypt_RSA(data_str):
ciphertext = cipher.encrypt(data_str.encode("UTF-8"))
return b64encode(ciphertext).decode("UTF-8")
def random_word(length):
characters = string.ascii_lowercase + string.ascii_uppercase + string.digits
random_word = "".join(random.choice(characters) for _ in range(length))
return random_word
def get_data(url, data):
random_key = "web" + random_word(13)
data["api_key_param"] = {
"timestamp": int(datetime.now().timestamp() * 1000),
"nonce": random_word(32),
}
data["appkey"] = "B0455FBE7AA0328DB57B59AA729F05D8"
data_str = json.dumps(data, separators=(",", ":"))
data_hex = encrypt_hex(data_str, random_key)
headers = {
"content-type": "application/json;charset=UTF-8",
"sys_code": "200",
"x-access-key": "9grzgbmxdsp3arfmmgq347xjbza4ysps",
}
headers["x-random-secret-key"] = encrypt_RSA(random_key)
response = requests.post(url, data=data_hex, headers=headers)
return decrypt_hex(response.text, random_key)
token = get_data(
"https://gateway.isolarcloud.eu/v1/userService/login",
{
"user_account": "[email protected]",
"user_password": "XXXXXXXX",
},
)["result_data"]["token"]
get_data(
"https://gateway.isolarcloud.eu/v1/commonService/queryMutiPointDataList",
{
"ps_key": "XXXXXXX_14_1_2",
"points": "p13003",
"start_time_stamp": "20231108000000",
"end_time_stamp": "20231109000000",
"token": token,
},
)
@0SkillAllLuck: Grüsse nach Bern!
Here is another working Python example of how to use query the new Sungrow API. Sadly, I forgot to refresh the page while working and didn't see the post of @rob3r7 ...
The scope of the code below is very similar to his contribution, with some extra additions:
- I have added a call to log in to the portal (this requires a different RSA key)
- My x-limit-obj adds the RSA encrypted user id, which is required for some calls, as also mentioned by @0SkillAllLuck
You will see that the post method has a isFormData flag. The code associated with that flag being true is reverse engineered based on what I was able to make out of minified Sungrow encryption code. I do not know a call for which the flag should be set to true, so in that particular case there may be some issues...
import base64
import string
import random
from typing import Optional
import time
import json
import requests
from cryptography.hazmat.primitives import serialization, asymmetric, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
# TODO: Getting these values directly from the files by the Sungrow API is better than hardcoding them...
LOGIN_RSA_PUBLIC_KEY: asymmetric.rsa.RSAPublicKey = serialization.load_pem_public_key(b"-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJRGV7eyd9peLPOIqFg3oionWqpmrjVik2wyJzWqv8it3yAvo/o4OR40ybrZPHq526k6ngvqHOCNJvhrN7wXNUEIT+PXyLuwfWP04I4EDBS3Bn3LcTMAnGVoIka0f5O6lo3I0YtPWwnyhcQhrHWuTietGC0CNwueI11Juq8NV2nwIDAQAB\n-----END PUBLIC KEY-----")
APP_RSA_PUBLIC_KEY: asymmetric.rsa.RSAPublicKey = serialization.load_pem_public_key(bytes("-----BEGIN PUBLIC KEY-----\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkecphb6vgsBx4LJknKKes-eyj7-RKQ3fikF5B67EObZ3t4moFZyMGuuJPiadYdaxvRqtxyblIlVM7omAasROtKRhtgKwwRxo2a6878qBhTgUVlsqugpI_7ZC9RmO2Rpmr8WzDeAapGANfHN5bVr7G7GYGwIrjvyxMrAVit_oM4wIDAQAB".replace("-", "+").replace("_", "/") + "\n-----END PUBLIC KEY-----", 'utf8'))
ACCESS_KEY = "9grzgbmxdsp3arfmmgq347xjbza4ysps"
APP_KEY = "B0455FBE7AA0328DB57B59AA729F05D8"
def encrypt_rsa(value: str, key: asymmetric.rsa.RSAPublicKey) -> str:
# Encrypt the value
encrypted = key.encrypt(
value.encode(),
asymmetric.padding.PKCS1v15(),
)
return base64.b64encode(encrypted).decode()
def encrypt_aes(data: str, key: str):
key_bytes = key.encode('utf-8')
data_bytes = data.encode('utf-8')
# Ensure the key is 16 bytes (128 bits)
if len(key_bytes) != 16:
raise ValueError("Key must be 16 characters long")
cipher = Cipher(algorithms.AES(key_bytes), modes.ECB(), backend=default_backend())
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data_bytes) + padder.finalize()
encrypted_data = encryptor.update(padded_data) + encryptor.finalize()
return encrypted_data.hex()
def decrypt_aes(data: str, key: str):
key_bytes = key.encode('utf-8')
# Ensure the key is 16 bytes (128 bits)
if len(key_bytes) != 16:
raise ValueError("Key must be 16 characters long")
encrypted_data = bytes.fromhex(data)
cipher = Cipher(algorithms.AES(key_bytes), modes.ECB(), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_padded_data = decryptor.update(encrypted_data) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
decrypted_data = unpadder.update(decrypted_padded_data) + unpadder.finalize()
return decrypted_data.decode('utf-8')
def generate_random_word(length: int):
char_pool = string.ascii_letters + string.digits
random_word = ''.join(random.choice(char_pool) for _ in range(length))
return random_word
class SungrowScraper:
def __init__(self, username: str, password: str):
self.baseUrl = "https://www.isolarcloud.com"
# TODO: Set the gateway during the login procedure
self.gatewayUrl = "https://gateway.isolarcloud.eu"
self.username = username
self.password = password
self.session: "requests.Session" = requests.session()
self.userToken: "str|None" = None
def login(self):
self.session = requests.session()
resp = self.session.post(
f"{self.baseUrl}/userLoginAction_login",
data={
"userAcct": self.username,
"userPswd": encrypt_rsa(self.password, LOGIN_RSA_PUBLIC_KEY),
},
headers={
"_isMd5": "1"
},
timeout=60,
)
self.userToken = resp.json()["user_token"]
return self.userToken
def post(self, relativeUrl: str, jsn: "Optional[dict]"=None, isFormData=False):
userToken = self.userToken if self.userToken is not None else self.login()
jsn = dict(jsn) if jsn is not None else {}
nonce = generate_random_word(32)
# TODO: Sungrow also adjusts for time difference between server and client
# This is probably not a must though. The relevant call is:
# https://gateway.isolarcloud.eu/v1/timestamp
unixTimeMs = int(time.time() * 1000)
jsn["api_key_param"] = {"timestamp": unixTimeMs, "nonce": nonce}
randomKey = "web" + generate_random_word(13)
userToken = self.userToken
userId = userToken.split('_')[0]
jsn["appkey"] = APP_KEY
if "token" not in jsn:
jsn["token"] = userToken
jsn["sys_code"] = 200
data: "dict|str"
if isFormData:
jsn["api_key_param"] = encrypt_aes(json.dumps(jsn["api_key_param"]), randomKey)
jsn["appkey"] = encrypt_aes(jsn["appkey"], randomKey)
jsn["token"] = encrypt_aes(jsn["token"], randomKey)
data = jsn
else:
data = encrypt_aes(json.dumps(jsn, separators=(",", ":")), randomKey)
resp = self.session.post(
f"{self.gatewayUrl}{relativeUrl}",
data=data,
headers={
"x-access-key": ACCESS_KEY,
"x-random-secret-key": encrypt_rsa(randomKey, APP_RSA_PUBLIC_KEY),
"x-limit-obj": encrypt_rsa(userId, APP_RSA_PUBLIC_KEY),
"content-type": "application/json;charset=UTF-8"
}
)
return decrypt_aes(resp.text, randomKey)
s = SungrowScraper("MY_USERNAME", "MY_PASSWORD")
resp = s.post(
"/v1/powerStationService/getPsListNova",
jsn={
"share_type_list": ["0", "1", "2"]
}
)
print(resp)
@Pistro @rob3r7 Thank you for the effort. But what do I have to do with these codes? Unfortunately I have no idea...
It would be great if someone could explain it to me step by step