Anker Solix: the encrypted transport the app uses, ported from upstream v3.8.2

anker-solix-api v3.8.2 (2026-09-13) worked out the mobile app's REST payload
encryption end to end — a second ECDH handshake after login, then every body
AES-sealed and HMAC-signed both ways. Anker's servers still accept the plain
JSON this connector has always sent, and upstream leaves the mode off for that
reason; this ports it so that the day they stop is a config change rather than
an outage.

payloadcrypto.go holds the handshake (openapi/oauth/key/exchange, both public
keys wrapped under the app's presetKey) and the per-request envelope
(AES-128-CBC under secret[:16], x-signature keyed on its ascii hex). The
handshake lives beside the token in the shared session and is dropped with it,
so a fresh login is a fresh handshake before the retry. The switch is a
global-layer field, payloadEncryption, default off: it is how the connector
talks to Anker rather than whose account it talks as, so it does not cascade —
ankerCloudConfig copies it into every per-user config, and the six places that
built that map by hand now go through it.

Verified against a fake cloud that implements the server half from upstream's
description, not against Anker's; the switch should be tried on a test account
before it is relied on. Nothing else in v3.8.2 reaches an EV charger: the
Solarbank expansion sizing, the Gen 2 smart meter and the new site parameter
types are other devices, and the A5191 MQTT block is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-21 16:40:43 +02:00
1 parent a26c608fc3
commit f8b39fa0a3
9 files changed
+733 -63

No files matched your search

+1 -5
View File
@@ -127,11 +127,7 @@ func (ankerChargerSource) gate(ctx context.Context, s *Server, who *callerIdenti
if reason := ankerGate(res, true); reason != "" {
return nil, false, reason
}
return map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}, true, ""
return ankerCloudConfig(res), true, ""
}
func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
@@ -181,6 +181,10 @@ type ankerResolution struct {
orgEnabled bool // org gate (default true; gates the org's users)
enabled bool // caller's personal enable flag
// payloadEncryption is the global layer's switch for the plugin's REST
// transport (off | on), copied into every cloud config as-is.
payloadEncryption string
// hiddenForOrg is what the global layer hides from everyone below it;
// hiddenForUser adds what the caller's organization hides from its own users.
// They gate both the pickers the clients draw and which layer's stored mode
@@ -225,6 +229,8 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
available: masterEnabled,
orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
enabled: uStored.Enabled,
payloadEncryption: g["payloadEncryption"],
}
// What each layer below may still be offered. The global list reaches
@@ -621,11 +627,7 @@ func (s *Server) handleAnkerHealth(w http.ResponseWriter, r *http.Request) {
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
cfg := ankerCloudConfig(res)
h, err := s.plugins.HealthCheckWith(r.Context(), ankerPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
@@ -653,11 +655,7 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
cfg := ankerCloudConfig(res)
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "chargers", nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
@@ -694,11 +692,7 @@ func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Reques
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
cfg := ankerCloudConfig(res)
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details",
mustJSON(map[string]any{"sn": sn}))
if err != nil {
@@ -813,11 +807,7 @@ func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerI
writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down")
return nil, "", nil, false
}
return who, sn, map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}, true
return who, sn, ankerCloudConfig(res), true
}
// ankerCardWrite runs one card capability and relays its document. The audit
@@ -338,7 +338,7 @@ func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, se
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
return "", "", errors.New("Anker account credentials are required for proxy mode")
}
cfg := map[string]string{"email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country}
cfg := ankerCloudConfig(res)
payload, _ := json.Marshal(map[string]string{"sn": serial})
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, cfg, "ocpp-info", payload)
if err != nil {
@@ -40,13 +40,18 @@ import (
// waits are shorter, so this only catches a request that is going nowhere.
const ankerMqttTimeout = 45 * time.Second
// ankerCloudConfig is the plugin config one caller's resolved credentials make.
// The cloud transport signs in as the account, so unlike Modbus it needs them.
// ankerCloudConfig is the plugin config one caller's resolved credentials make —
// every transient instance the api package builds for the cloud goes through
// here. The cloud transport signs in as the account, so unlike Modbus it needs
// them. Payload encryption rides along from the global layer: it is how the
// connector talks to Anker, not whose account it talks as, so it is not part of
// the cascade and no lower layer can set it.
func ankerCloudConfig(res ankerResolution) map[string]string {
return map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
"payloadEncryption": res.payloadEncryption,
}
}