Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/payloadcrypto_test.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

288 lines
10 KiB
Go

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")
}
}