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,
}
}
@@ -2,9 +2,14 @@
// scoped to the Anker Solix V1 Smart EV Charger (product A5191). It is a Go
// re-implementation of the authentication and read-only data flow from the
// anker-solix-api project (https://github.com/thomluther/anker-solix-api),
// adapted to DriverVault's plugin contract. It tracks upstream v3.8.1
// (ha-anker-solix 3.8.1); the login exchange and every endpoint used here are
// unchanged since v3.7.0.
// adapted to DriverVault's plugin contract. It tracks upstream v3.8.2
// (ha-anker-solix 3.8.2); the login exchange and every endpoint used here are
// unchanged since v3.7.0. What v3.8.2 added that reaches a charger is the app's
// REST payload encryption, ported in payloadcrypto.go and off by default as
// upstream leaves it; its other changes (Solarbank expansion sizing, the Gen 2
// smart meter's MQTT map, new site parameter types) touch devices this
// connector does not address, and the A5191 MQTT map is byte-for-byte the one
// v3.8.1 had.
//
// Scope & limitations:
// - The REST half is read-only. Every endpoint below retrieves EV-charger
@@ -223,6 +228,9 @@ type Plugin struct {
email string
password string
countryId string
// encrypt turns on the app's payload encryption for every request after
// login (payloadcrypto.go). Off unless payloadEncryption is "on".
encrypt bool
mu sync.Mutex // guards the fields above and the client
apiBase string
@@ -321,6 +329,16 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Help: "Your Anker account password. Stored locally, sent only (encrypted) to Anker's login endpoint."},
{Key: "country", Label: "Country", Type: "text", Default: "DE",
Help: "ISO country code of your Anker account (e.g. DE, GB, US). Determines which Anker server the account lives on; a wrong value logs in but shows no devices."},
// payloadEncryption is a transport property of the connector as a whole,
// so it lives on the global layer only: the api package copies it into
// every per-user config it builds (ankerCloudConfig) rather than
// cascading it. Off by default, as the reference implementation ships it.
{Key: "payloadEncryption", Label: "Payload encryption", Type: "select", Default: "off",
Help: "Whether requests to Anker's cloud carry their bodies encrypted the way the mobile app does (ECDH key exchange after login, then AES on every request and response). Anker's servers accept plain bodies today, so Off is safe; switch it On if they stop accepting them. A server that refuses the key exchange fails every request until this is switched back off.",
Options: []plugins.SelectOption{
{Value: "off", Label: "Off (plain JSON, as today)"},
{Value: "on", Label: "On (encrypt like the app)"},
}},
// controlMode selects the OCPP control path. It does not affect this
// (read-only) cloud plugin — the CSMS that acts on it lives in the api
// package (internal/ocpp) — but it is advertised here so a superadmin can
@@ -363,6 +381,7 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
if p.countryId == "" {
p.countryId = "DE"
}
p.encrypt = payloadEncryptionOn(config["payloadEncryption"])
if comCountries[p.countryId] {
p.apiBase = serverCOM
} else {
@@ -1152,7 +1171,7 @@ func (p *Plugin) login(ctx context.Context) error {
"transaction": fmt.Sprintf("%d", time.Now().UnixMilli()),
}
body, status, err := p.doRequest(ctx, epLogin, reqBody, "", "", gmt)
body, status, err := p.doRequest(ctx, epLogin, reqBody, nil, "", "", gmt)
if err != nil {
return err
}
@@ -1199,17 +1218,105 @@ func (p *Plugin) storeToken(body []byte) error {
nickname: env.Data.NickName,
expiration: exp,
}
// A handshake belongs to the login it was run under; the next encrypted
// request runs a fresh one.
p.sess.enc = nil
return nil
}
// ensureCipher returns the payload cipher for the token held, running the key
// exchange if this token has not had one yet. Serialised by p.sess.mu like the
// login, so concurrent requests share one handshake. Only called when the
// connector is configured to encrypt.
func (p *Plugin) ensureCipher(ctx context.Context, tok tokenInfo) (*payloadCipher, error) {
s := p.sess
s.mu.Lock()
defer s.mu.Unlock()
if s.tok == nil || s.tok.authToken != tok.authToken {
// The token was replaced under us; the caller will fetch the new one and
// come back.
return nil, errors.New("anker-solix: token changed during request")
}
if s.enc.ready() {
return s.enc, nil
}
enc, err := newPayloadCipher()
if err != nil {
return nil, err
}
if err := p.keyExchange(ctx, tok, enc); err != nil {
return nil, err
}
s.enc = enc
return enc, nil
}
// keyExchange runs the handshake described in payloadcrypto.go: the wrapped
// client key goes up as plain JSON under the presetKey's signature, the wrapped
// server key comes back, and enc ends up holding the shared secret.
func (p *Plugin) keyExchange(ctx context.Context, tok tokenInfo, enc *payloadCipher) error {
clientKey, err := enc.clientPublicKey()
if err != nil {
return fmt.Errorf("anker-solix: key exchange: %w", err)
}
buf, err := json.Marshal(map[string]string{"client_public_key": clientKey})
if err != nil {
return fmt.Errorf("anker-solix: key exchange: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/"+epKeyExchange, bytes.NewReader(buf))
if err != nil {
return err
}
gmt, _ := timezone()
ankerHeaders(req, p.countryId, tok.authToken, tok.gtoken, gmt)
enc.signRequest(req, fmt.Sprintf("%d", time.Now().Unix()), clientKey, true)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("anker-solix: key exchange: %w", err)
}
defer drain(resp)
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("anker-solix: key exchange failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
}
if code, msg, bad := apiError(body); bad {
return fmt.Errorf("anker-solix: key exchange failed (code %d): %s", code, shorten(msg))
}
var env struct {
Data struct {
ServerPublicKey string `json:"server_public_key"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("anker-solix: decode key exchange: %w", err)
}
if env.Data.ServerPublicKey == "" {
return errors.New("anker-solix: key exchange answered without a server key")
}
if err := enc.acceptServerKey(env.Data.ServerPublicKey); err != nil {
return fmt.Errorf("anker-solix: key exchange: %w", err)
}
return nil
}
// payloadEncryptionOn reads the payloadEncryption config value.
func payloadEncryptionOn(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "on", "true", "1", "yes":
return true
}
return false
}
// ---- data requests -----------------------------------------------------------
// apiRequest performs an authenticated POST, re-authenticating once if the token
// is rejected. It returns the raw response body; a non-zero API code or non-2xx
// status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[string]any) ([]byte, error) {
return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
return p.doRequest(ctx, endpoint, payload, tok.authToken, tok.gtoken, gmt)
return p.apiCall(ctx, endpoint, func(tok tokenInfo, enc *payloadCipher, gmt string) ([]byte, int, error) {
return p.doRequest(ctx, endpoint, payload, enc, tok.authToken, tok.gtoken, gmt)
})
}
@@ -1217,22 +1324,30 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st
// connector Anker serves over GET: same headers, same credential, same one
// retry after a fresh login — a query string instead of a body.
func (p *Plugin) apiGet(ctx context.Context, endpoint string, query map[string]string) ([]byte, error) {
return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
return p.doGet(ctx, endpoint, query, tok.authToken, tok.gtoken, gmt)
return p.apiCall(ctx, endpoint, func(tok tokenInfo, enc *payloadCipher, gmt string) ([]byte, int, error) {
return p.doGet(ctx, endpoint, query, enc, tok.authToken, tok.gtoken, gmt)
})
}
// apiCall is what both of those share: a token, one attempt, and — when the
// cloud rejects the token — a fresh login and exactly one more. There is no
// refresh token, so a rejected token can only be answered with a new login.
func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenInfo, string) ([]byte, int, error)) ([]byte, error) {
// With payload encryption on, the token comes with its handshake (run once per
// login), and a new login means a new handshake before the retry.
func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenInfo, *payloadCipher, string) ([]byte, int, error)) ([]byte, error) {
tok, err := p.ensureToken(ctx)
if err != nil {
return nil, err
}
var enc *payloadCipher
if p.encrypt {
if enc, err = p.ensureCipher(ctx, tok); err != nil {
return nil, err
}
}
gmt, _ := timezone()
body, status, err := send(tok, gmt)
body, status, err := send(tok, enc, gmt)
if err != nil {
return nil, err
}
@@ -1251,7 +1366,12 @@ func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenIn
if lerr != nil {
return nil, lerr
}
body, status, err = send(newTok, gmt)
if p.encrypt {
if enc, err = p.ensureCipher(ctx, newTok); err != nil {
return nil, err
}
}
body, status, err = send(newTok, enc, gmt)
if err != nil {
return nil, err
}
@@ -1267,31 +1387,21 @@ func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenIn
}
// doRequest issues a single POST to an endpoint with the common Anker headers.
// When authToken is empty the auth headers are omitted (login request).
func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[string]any, authToken, gtoken, gmt string) ([]byte, int, error) {
// When authToken is empty the auth headers are omitted (login request). A
// non-nil enc sends the body sealed and signed, and opens the reply.
func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[string]any, enc *payloadCipher, authToken, gtoken, gmt string) ([]byte, int, error) {
buf, err := json.Marshal(payload)
if err != nil {
return nil, 0, fmt.Errorf("anker-solix: encode request: %w", err)
}
url := p.apiBase + "/" + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, 0, err
}
ankerHeaders(req, p.countryId, authToken, gtoken, gmt)
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("anker-solix: request %s: %w", endpoint, err)
}
defer drain(resp)
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
return respBody, resp.StatusCode, nil
return p.send(ctx, http.MethodPost, endpoint, p.apiBase+"/"+endpoint, buf, payload, enc, authToken, gtoken, gmt)
}
// doGet issues a single GET with the same headers a POST carries. Only the two
// message views are served this way.
func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]string, authToken, gtoken, gmt string) ([]byte, int, error) {
// message views are served this way. Under encryption a GET still carries a
// sealed body — an empty object — because that is what the reference
// implementation sends and the signature has to cover something.
func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]string, enc *payloadCipher, authToken, gtoken, gmt string) ([]byte, int, error) {
url := p.apiBase + "/" + endpoint
if len(query) > 0 {
vals := neturl.Values{}
@@ -1300,11 +1410,32 @@ func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]st
}
url += "?" + vals.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
return p.send(ctx, http.MethodGet, endpoint, url, nil, nil, enc, authToken, gtoken, gmt)
}
// send is the one HTTP exchange behind doRequest and doGet. plain is the JSON
// body a plain request carries (nil for a GET); payload is the same body as the
// map enc seals when the request is encrypted.
func (p *Plugin) send(ctx context.Context, method, endpoint, url string, plain []byte, payload map[string]any, enc *payloadCipher, authToken, gtoken, gmt string) ([]byte, int, error) {
var body io.Reader
var wire string
if enc != nil {
sealed, err := enc.sealBody(payload)
if err != nil {
return nil, 0, err
}
wire, body = sealed, strings.NewReader(sealed)
} else if plain != nil {
body = bytes.NewReader(plain)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, 0, err
}
ankerHeaders(req, p.countryId, authToken, gtoken, gmt)
if enc != nil {
enc.signRequest(req, fmt.Sprintf("%d", time.Now().Unix()), wire, false)
}
resp, err := p.client.Do(req)
if err != nil {
@@ -1312,6 +1443,11 @@ func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]st
}
defer drain(resp)
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if enc != nil {
if respBody, err = enc.unsealEnvelope(respBody); err != nil {
return nil, resp.StatusCode, err
}
}
return respBody, resp.StatusCode, nil
}
@@ -7,7 +7,7 @@ package ankersolix
// That frame is the same one the app sends, so a command is not a documented API
// call but a byte layout, reproduced here from the message maps in
// anker-solix-api (src/anker_solix_api/mqtttypes.py, mqttmap.py, mqttcmdmap.py at
// v3.8.1) and checked against a live A5191.
// v3.8.1, unchanged for the A5191 in v3.8.2) and checked against a live A5191.
//
// ff 09 2-byte marker on every Anker Solix frame
// xx xx total length in bytes, little endian, counting the checksum
@@ -0,0 +1,250 @@
package ankersolix
// Payload encryption — the mobile app's "algo_ecdh" mode for the REST API.
//
// Anker's servers currently accept plain JSON bodies, and that is what this
// connector has always sent. The app itself does not: it runs a key exchange
// once per login and then encrypts every request body and reads back every
// response body encrypted, with an HMAC over each request. anker-solix-api
// v3.8.2 worked the scheme out end to end (session.py, AnkerEncryptionHandler),
// and this file is that handler in Go, kept behind the payloadEncryption config
// switch — off by default, as upstream leaves it, because plain bodies are what
// the cloud has been answering for years and any slip in a port of the cipher
// would take every read down at once. It exists so the day Anker stops
// tolerating plain bodies is a config change, not an outage.
//
// The scheme, in the order it runs:
//
// 1. Handshake. A fresh P-256 key pair is generated; its uncompressed point
// (0x04 || X || Y) is taken as its 130-character ascii hex and wrapped —
// AES-128-CBC under the region's presetKey with a random IV prepended, then
// base64 — and POSTed as client_public_key to openapi/oauth/key/exchange with
// the usual auth headers. The x-signature on that request is
// HMAC-SHA256(key = the presetKey's ascii hex, msg = ts+once+client_public_key).
// The reply's server_public_key is wrapped the same way; unwrapping it and
// running ECDH gives the 32-byte shared secret. There is no KDF.
// 2. Requests. The body is compact JSON, AES-128-CBC under secret[:16] (the
// "securityKey"), random IV prepended, PKCS#7, base64, sent as text/plain.
// x-signature is HMAC-SHA256(key = ascii hex of secret[:16],
// msg = ts+once+body). x-key-ident is fixed for the life of the key pair
// and is how the server finds the secret again; x-request-once is fresh
// per request.
// 3. Responses come back with data as a base64 string and a signature field
// beside it; data decrypts with the same key and cipher to the JSON the
// plain mode would have received.
//
// The presetKey is an app constant, identical for both regional servers today
// (upstream's API_PRESET_KEYS), not a user secret.
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
const (
epKeyExchange = "openapi/oauth/key/exchange"
// presetKeyHex is the region AES-128 key the app carries in its stored
// userConfig as "presetKey". It wraps the two public keys of the handshake and
// keys the handshake's signature. EU and global use the same one.
presetKeyHex = "e8ad18f61bbd3fbd52d5ed12d14d3b9c"
)
// payloadCipher is one token's encryption state: the key pair the handshake was
// run with, the identifier the server files its result under, and the secret
// the handshake produced. It lives beside the token in the session and is
// discarded with it — a new login is a new handshake.
type payloadCipher struct {
priv *ecdh.PrivateKey
keyIdent string // x-key-ident: 32 hex chars, random, constant per key pair
preset []byte // the region presetKey, raw
secret []byte // ECDH shared secret; nil until the exchange has answered
}
// newPayloadCipher generates the key pair and identifier for one handshake.
func newPayloadCipher() (*payloadCipher, error) {
priv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("anker-solix: generate key: %w", err)
}
preset, err := hex.DecodeString(presetKeyHex)
if err != nil {
return nil, fmt.Errorf("anker-solix: preset key: %w", err)
}
ident := make([]byte, 16)
if _, err := rand.Read(ident); err != nil {
return nil, fmt.Errorf("anker-solix: key ident: %w", err)
}
return &payloadCipher{priv: priv, keyIdent: hex.EncodeToString(ident), preset: preset}, nil
}
// ready reports whether the handshake has produced a secret.
func (c *payloadCipher) ready() bool { return c != nil && c.secret != nil }
// clientPublicKey is the handshake body's one field: the uncompressed point as
// ascii hex, wrapped under the presetKey.
func (c *payloadCipher) clientPublicKey() (string, error) {
return aesCBCSeal(c.preset, []byte(hex.EncodeToString(c.priv.PublicKey().Bytes())))
}
// acceptServerKey unwraps the handshake reply's server_public_key and derives
// the shared secret from it.
func (c *payloadCipher) acceptServerKey(wrapped string) error {
pointHex, err := aesCBCOpen(c.preset, wrapped)
if err != nil {
return fmt.Errorf("unwrap server key: %w", err)
}
point, err := hex.DecodeString(strings.TrimSpace(string(pointHex)))
if err != nil {
return fmt.Errorf("server key is not hex: %w", err)
}
pub, err := ecdh.P256().NewPublicKey(point)
if err != nil {
return fmt.Errorf("server key is not a P-256 point: %w", err)
}
secret, err := c.priv.ECDH(pub)
if err != nil {
return fmt.Errorf("derive shared secret: %w", err)
}
c.secret = secret
return nil
}
// bodyKey is the AES-128 key both directions use after the handshake.
func (c *payloadCipher) bodyKey() []byte { return c.secret[:16] }
// sealBody encrypts a request payload for the wire. GETs, which carry no
// payload, seal an empty object — what the reference implementation sends.
func (c *payloadCipher) sealBody(payload map[string]any) (string, error) {
if payload == nil {
payload = map[string]any{}
}
plain, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("anker-solix: encode request: %w", err)
}
return aesCBCSeal(c.bodyKey(), plain)
}
// signRequest sets the encryption headers on a request whose body is already
// on the wire form given. The handshake signs under the presetKey and keeps a
// JSON content type; every request after it signs under the session key and
// goes out as text/plain.
func (c *payloadCipher) signRequest(req *http.Request, ts, wire string, handshake bool) {
var signKey string
if handshake {
signKey = presetKeyHex
req.Header.Set("content-type", "application/json")
req.Header.Set("x-auth-ts", ts)
} else {
signKey = hex.EncodeToString(c.bodyKey())
req.Header.Set("content-type", "text/plain")
}
once := make([]byte, 16)
_, _ = rand.Read(once)
onceHex := hex.EncodeToString(once)
mac := hmac.New(sha256.New, []byte(signKey))
mac.Write([]byte(ts + "+" + onceHex + "+" + wire))
req.Header.Set("x-app-key", "")
req.Header.Set("x-encryption-info", "algo_ecdh")
req.Header.Set("x-replay-info", "replay")
req.Header.Set("x-key-ident", c.keyIdent)
req.Header.Set("x-request-once", onceHex)
req.Header.Set("x-request-ts", ts)
req.Header.Set("x-signature", hex.EncodeToString(mac.Sum(nil)))
}
// unsealEnvelope turns an encrypted response into the envelope the plain mode
// receives: when a signature field is present and data is a string, data is
// decrypted and put back as the JSON it holds. Anything else — an error
// envelope, a plain reply — is returned as it came.
func (c *payloadCipher) unsealEnvelope(body []byte) ([]byte, error) {
var env map[string]json.RawMessage
if err := json.Unmarshal(body, &env); err != nil {
return body, nil
}
if _, signed := env["signature"]; !signed {
return body, nil
}
var wrapped string
if err := json.Unmarshal(env["data"], &wrapped); err != nil || wrapped == "" {
return body, nil
}
plain, err := aesCBCOpen(c.bodyKey(), wrapped)
if err != nil {
return nil, fmt.Errorf("anker-solix: decrypt response: %w", err)
}
if !json.Valid(plain) {
return nil, errors.New("anker-solix: decrypted response is not JSON")
}
env["data"] = json.RawMessage(plain)
return json.Marshal(env)
}
// aesCBCSeal is the one envelope every wrapped value in the scheme uses:
// AES-CBC under key, a random IV prepended to the ciphertext, PKCS#7 padding,
// standard base64. The key's length picks AES-128 or -256.
func aesCBCSeal(key, plain []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("aes cipher: %w", err)
}
iv := make([]byte, block.BlockSize())
if _, err := rand.Read(iv); err != nil {
return "", fmt.Errorf("iv: %w", err)
}
padded := pkcs7Pad(plain, block.BlockSize())
out := make([]byte, len(iv)+len(padded))
copy(out, iv)
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out[len(iv):], padded)
return base64.StdEncoding.EncodeToString(out), nil
}
// aesCBCOpen reverses aesCBCSeal.
func aesCBCOpen(key []byte, wrapped string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(wrapped))
if err != nil {
return nil, fmt.Errorf("base64: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
bs := block.BlockSize()
if len(raw) < 2*bs || len(raw)%bs != 0 {
return nil, fmt.Errorf("ciphertext length %d is not iv plus whole blocks", len(raw))
}
iv, ct := raw[:bs], raw[bs:]
plain := make([]byte, len(ct))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct)
return pkcs7Unpad(plain, bs)
}
// pkcs7Unpad strips the padding pkcs7Pad added, refusing anything malformed.
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("bad padding: length")
}
pad := int(data[len(data)-1])
if pad == 0 || pad > blockSize || pad > len(data) {
return nil, errors.New("bad padding: count")
}
if !bytes.Equal(data[len(data)-pad:], bytes.Repeat([]byte{byte(pad)}, pad)) {
return nil, errors.New("bad padding: bytes")
}
return data[:len(data)-pad], nil
}
@@ -0,0 +1,287 @@
package ankersolix
import (
"context"
"crypto/ecdh"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// encryptedCloud is the server half of the scheme in payloadcrypto.go, written
// from the reference implementation's description rather than from our client,
// so a test against it checks the two halves agree on every step: the presetKey
// wrapping of both public keys, the handshake signature, the per-request
// signature under the session key, the sealed body and the sealed reply.
type encryptedCloud struct {
t *testing.T
mu sync.Mutex
secrets map[string][]byte // x-key-ident -> shared secret
logins int
exchanges int
seen []map[string]any // decrypted request bodies, in order
rejectTok string // a token the cloud answers 401 to
}
func newEncryptedCloud(t *testing.T) *encryptedCloud {
return &encryptedCloud{t: t, secrets: map[string][]byte{}}
}
func (c *encryptedCloud) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.mu.Lock()
defer c.mu.Unlock()
body, _ := io.ReadAll(r.Body)
switch {
case strings.HasSuffix(r.URL.Path, epLogin):
c.logins++
tok := "tok-" + hex.EncodeToString([]byte{byte(c.logins)})
_, _ = w.Write([]byte(`{"code":0,"data":{"auth_token":"` + tok + `","user_id":"u1","nick_name":"n","token_expires_at":9999999999}}`))
case strings.HasSuffix(r.URL.Path, epKeyExchange):
c.exchanges++
if got := r.Header.Get("content-type"); got != "application/json" {
c.t.Errorf("handshake content-type = %q, want application/json", got)
}
if r.Header.Get("x-auth-ts") == "" || r.Header.Get("x-auth-token") == "" {
c.t.Error("handshake must carry x-auth-ts and the auth token")
}
var req struct {
ClientPublicKey string `json:"client_public_key"`
}
if err := json.Unmarshal(body, &req); err != nil || req.ClientPublicKey == "" {
c.t.Errorf("handshake body %q is not {client_public_key}", body)
}
c.checkSignature(r, presetKeyHex, req.ClientPublicKey)
preset, _ := hex.DecodeString(presetKeyHex)
pointHex, err := aesCBCOpen(preset, req.ClientPublicKey)
if err != nil {
c.t.Fatalf("unwrap client key: %v", err)
}
point, _ := hex.DecodeString(string(pointHex))
if len(point) != 65 || point[0] != 4 {
c.t.Fatalf("client key is %d bytes starting %x, want a 65-byte uncompressed point", len(point), point[:1])
}
clientPub, err := ecdh.P256().NewPublicKey(point)
if err != nil {
c.t.Fatalf("client key: %v", err)
}
serverPriv, _ := ecdh.P256().GenerateKey(rand.Reader)
secret, _ := serverPriv.ECDH(clientPub)
c.secrets[r.Header.Get("x-key-ident")] = secret
wrapped, _ := aesCBCSeal(preset, []byte(hex.EncodeToString(serverPriv.PublicKey().Bytes())))
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]string{"server_public_key": wrapped}})
default:
if r.Header.Get("x-auth-token") == c.rejectTok {
w.WriteHeader(http.StatusUnauthorized)
return
}
if got := r.Header.Get("content-type"); got != "text/plain" {
c.t.Errorf("%s content-type = %q, want text/plain", r.URL.Path, got)
}
if got := r.Header.Get("x-encryption-info"); got != "algo_ecdh" {
c.t.Errorf("x-encryption-info = %q", got)
}
secret := c.secrets[r.Header.Get("x-key-ident")]
if secret == nil {
c.t.Errorf("%s arrived under key ident %q the cloud never issued", r.URL.Path, r.Header.Get("x-key-ident"))
w.WriteHeader(http.StatusBadRequest)
return
}
c.checkSignature(r, hex.EncodeToString(secret[:16]), string(body))
plain, err := aesCBCOpen(secret[:16], string(body))
if err != nil {
c.t.Fatalf("open %s body: %v", r.URL.Path, err)
}
var payload map[string]any
if err := json.Unmarshal(plain, &payload); err != nil {
c.t.Fatalf("%s body decrypted to %q, not JSON", r.URL.Path, plain)
}
c.seen = append(c.seen, payload)
reply, _ := json.Marshal(map[string]any{"echo": payload, "path": r.URL.Path, "query": r.URL.RawQuery})
sealed, _ := aesCBCSeal(secret[:16], reply)
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "success!", "data": sealed, "signature": "sig"})
}
}
// checkSignature recomputes x-signature the way the reference documents it:
// HMAC-SHA256 keyed on the ascii hex of the key, over ts+once+wire.
func (c *encryptedCloud) checkSignature(r *http.Request, keyHex, wire string) {
ts, once := r.Header.Get("x-request-ts"), r.Header.Get("x-request-once")
if len(once) != 32 || len(r.Header.Get("x-key-ident")) != 32 {
c.t.Errorf("x-request-once %q / x-key-ident %q should be 32 hex chars", once, r.Header.Get("x-key-ident"))
}
mac := hmac.New(sha256.New, []byte(keyHex))
mac.Write([]byte(ts + "+" + once + "+" + wire))
if want := hex.EncodeToString(mac.Sum(nil)); r.Header.Get("x-signature") != want {
c.t.Errorf("%s x-signature = %q, want %q", r.URL.Path, r.Header.Get("x-signature"), want)
}
}
func encryptedPlugin(t *testing.T, srv *httptest.Server) *Plugin {
t.Helper()
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"email": "e@example.com", "password": "pw", "payloadEncryption": "on"}); err != nil {
t.Fatal(err)
}
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, "e@example.com", "pw")
return p
}
// TestPayloadEncryptionRoundTrip drives a POST and a GET through the sealed
// path and checks the plugin sees exactly the envelope the plain mode would.
func TestPayloadEncryptionRoundTrip(t *testing.T) {
cloud := newEncryptedCloud(t)
srv := httptest.NewServer(cloud)
defer srv.Close()
p := encryptedPlugin(t, srv)
body, err := p.apiRequest(context.Background(), epStationInfo, map[string]any{"sn": "ABC", "featuretype": 1})
if err != nil {
t.Fatal(err)
}
var env struct {
Code int `json:"code"`
Data struct {
Echo map[string]any `json:"echo"`
Path string `json:"path"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("unsealed body %s: %v", body, err)
}
if env.Code != 0 || env.Data.Echo["sn"] != "ABC" || env.Data.Echo["featuretype"] != float64(1) {
t.Fatalf("unsealed envelope = %s", body)
}
if !strings.HasSuffix(env.Data.Path, epStationInfo) {
t.Errorf("request went to %q", env.Data.Path)
}
body, err = p.apiGet(context.Background(), epMessages, map[string]string{"last_time": "7"})
if err != nil {
t.Fatal(err)
}
var got struct {
Data struct {
Echo map[string]any `json:"echo"`
Query string `json:"query"`
} `json:"data"`
}
_ = json.Unmarshal(body, &got)
if got.Data.Query != "last_time=7" {
t.Errorf("GET query = %q, want last_time=7", got.Data.Query)
}
if len(got.Data.Echo) != 0 {
t.Errorf("a GET seals an empty object, got %v", got.Data.Echo)
}
if cloud.logins != 1 || cloud.exchanges != 1 {
t.Errorf("logins = %d, exchanges = %d; want one of each for two requests", cloud.logins, cloud.exchanges)
}
}
// TestPayloadEncryptionHandshakeFollowsLogin: a rejected token costs a login and
// a new handshake, and the retry goes out under both.
func TestPayloadEncryptionHandshakeFollowsLogin(t *testing.T) {
cloud := newEncryptedCloud(t)
srv := httptest.NewServer(cloud)
defer srv.Close()
p := encryptedPlugin(t, srv)
if _, err := p.apiRequest(context.Background(), epSiteList, map[string]any{}); err != nil {
t.Fatal(err)
}
cloud.mu.Lock()
cloud.rejectTok = "tok-01" // the first login's token
cloud.mu.Unlock()
if _, err := p.apiRequest(context.Background(), epSiteList, map[string]any{}); err != nil {
t.Fatal(err)
}
if cloud.logins != 2 || cloud.exchanges != 2 {
t.Errorf("logins = %d, exchanges = %d; want 2 and 2 after a rejected token", cloud.logins, cloud.exchanges)
}
if p.sess.enc == nil || !p.sess.enc.ready() {
t.Error("the session should hold the new handshake")
}
}
// TestPayloadEncryptionOffStaysPlain guards the default: no switch, no
// handshake, JSON on the wire as before.
func TestPayloadEncryptionOffStaysPlain(t *testing.T) {
var sawExchange bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, epLogin):
_, _ = w.Write([]byte(`{"code":0,"data":{"auth_token":"t","user_id":"u1","token_expires_at":9999999999}}`))
case strings.HasSuffix(r.URL.Path, epKeyExchange):
sawExchange = true
default:
if r.Header.Get("content-type") != "application/json" || r.Header.Get("x-encryption-info") != "" {
t.Errorf("plain request carried %q / %q", r.Header.Get("content-type"), r.Header.Get("x-encryption-info"))
}
_, _ = w.Write([]byte(`{"code":0,"data":{}}`))
}
}))
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"email": "plain@example.com", "password": "pw"})
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, "plain@example.com", "pw")
if p.encrypt {
t.Fatal("payload encryption must be off by default")
}
if _, err := p.apiRequest(context.Background(), epSiteList, map[string]any{}); err != nil {
t.Fatal(err)
}
if sawExchange {
t.Error("no handshake should run with encryption off")
}
}
// TestUnsealEnvelopeLeavesUnsignedAlone: an error envelope, or a plain reply,
// is passed through untouched; a signed reply that fails to open is an error
// rather than garbage.
func TestUnsealEnvelopeLeavesUnsignedAlone(t *testing.T) {
enc, _ := newPayloadCipher()
enc.secret = make([]byte, 32)
for _, in := range []string{`{"code":26161,"msg":"Failed to request."}`, `{"code":0,"data":{"x":1}}`, `not json`} {
out, err := enc.unsealEnvelope([]byte(in))
if err != nil || string(out) != in {
t.Errorf("unsealEnvelope(%q) = %q, %v; want it untouched", in, out, err)
}
}
if _, err := enc.unsealEnvelope([]byte(`{"code":0,"data":"AAAA","signature":"s"}`)); err == nil {
t.Error("a signed reply that does not open should be an error")
}
}
func TestPayloadEncryptionSwitch(t *testing.T) {
for v, want := range map[string]bool{"": false, "off": false, "on": true, "ON": true, "true": true, "1": true, "no": false} {
if got := payloadEncryptionOn(v); got != want {
t.Errorf("payloadEncryptionOn(%q) = %v", v, got)
}
}
fields := map[string]bool{}
for _, f := range (&Plugin{}).Descriptor().ConfigFields {
fields[f.Key] = f.Default == "off"
}
if off, ok := fields["payloadEncryption"]; !ok || !off {
t.Error("payloadEncryption must be advertised with default off")
}
}
@@ -29,9 +29,15 @@ import (
// its login alone after a refusal, and the broker connection its chargers are
// commanded over.
type session struct {
mu sync.Mutex // guards tok and the backoff below, and serialises the login exchange
mu sync.Mutex // guards tok, enc and the backoff below, and serialises the login and key exchanges
tok *tokenInfo
// enc is the payload-encryption handshake run under tok, when the connector
// is configured to encrypt (payloadcrypto.go). It is tied to the token — the
// server files the secret under the login — so it is dropped whenever tok is
// replaced, and stays nil while encryption is off.
enc *payloadCipher
loginErr error
loginRetryAt time.Time
loginFails int