Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/payloadcrypto.go
T
tajniak81andClaude Opus 5 f8b39fa0a3 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>
2026-09-21 16:40:43 +02:00

251 lines
9.4 KiB
Go

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
}