Go port of the read path of pybyd 0.0.75 (the client behind jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the signing/checkcode layer, login, vehicle list, and realtime readings. Scraping runs on a cron schedule rather than per Prometheus request because each reading wakes the car's T-Box and drains the traction battery; /metrics serves the last result. Every numeric field of the realtime payload becomes a gauge, so the exporter does not need a hand-maintained field list that goes stale when BYD changes the payload. Tests pin the wire format against pybyd byte-for-byte and drive the client end to end against a fake cloud that speaks the real protocol. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
commit
52e1aaa0c0
30 files changed
+3501
No files matched your search
@@ -0,0 +1,174 @@
|
||||
// Package bangcle implements the "Bangcle" envelope every BYD API request and
|
||||
// response is wrapped in: white-box AES-CBC, base64, with an "F" prefix.
|
||||
//
|
||||
// The cipher has no key schedule. Its round functions are pre-computed lookup
|
||||
// tables extracted from the app's libencrypt.so, shipped here as tables.bin and
|
||||
// embedded in the binary. Ported from pybyd's _crypto/_bangcle_block.py.
|
||||
package bangcle
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed tables.bin
|
||||
var tablesBin []byte
|
||||
|
||||
// tables is built at init because tables.bin is compiled into the binary: if it
|
||||
// fails to parse the build is broken, and there is no runtime recovery.
|
||||
var tables = mustLoadTables(tablesBin)
|
||||
|
||||
// Encode wraps a payload as "F" + base64(whitebox-AES-CBC(pkcs7(plain))).
|
||||
func Encode(plain []byte) string {
|
||||
return "F" + base64.StdEncoding.EncodeToString(encryptCBC(tables, padPKCS7(plain)))
|
||||
}
|
||||
|
||||
// Decode unwraps an envelope produced by the BYD API.
|
||||
func Decode(envelope string) ([]byte, error) {
|
||||
s := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "-", "+", "_", "/").Replace(envelope)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("bangcle: empty input")
|
||||
}
|
||||
if !strings.HasPrefix(s, "F") {
|
||||
return nil, fmt.Errorf("bangcle: envelope must start with %q", "F")
|
||||
}
|
||||
s = s[1:]
|
||||
if r := len(s) % 4; r != 0 {
|
||||
s += strings.Repeat("=", 4-r)
|
||||
}
|
||||
ct, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bangcle: invalid base64: %w", err)
|
||||
}
|
||||
if len(ct) == 0 || len(ct)%16 != 0 {
|
||||
return nil, fmt.Errorf("bangcle: ciphertext length %d is not a positive multiple of 16", len(ct))
|
||||
}
|
||||
return StripPKCS7(decryptCBC(tables, ct)), nil
|
||||
}
|
||||
|
||||
// PadPKCS7 and StripPKCS7 are exported because the inner AES-CBC layer in
|
||||
// package byd uses the same padding rules.
|
||||
func PadPKCS7(data []byte) []byte { return padPKCS7(data) }
|
||||
|
||||
func padPKCS7(data []byte) []byte {
|
||||
n := 16 - len(data)%16 // 1..16, so an aligned input gets a whole extra block
|
||||
pad := make([]byte, n)
|
||||
for i := range pad {
|
||||
pad[i] = byte(n)
|
||||
}
|
||||
return append(data, pad...)
|
||||
}
|
||||
|
||||
// StripPKCS7 returns data unchanged when the padding is not well formed. BYD
|
||||
// occasionally returns unpadded payloads, and pybyd tolerates them too.
|
||||
func StripPKCS7(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
n := int(data[len(data)-1])
|
||||
if n == 0 || n > 16 || n > len(data) {
|
||||
return data
|
||||
}
|
||||
for _, b := range data[len(data)-n:] {
|
||||
if int(b) != n {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return data[:len(data)-n]
|
||||
}
|
||||
|
||||
// encryptCBC and decryptCBC use a zero IV, matching the app.
|
||||
|
||||
func encryptCBC(t *lookupTables, data []byte) []byte {
|
||||
res := make([]byte, len(data))
|
||||
prev := make([]byte, 16)
|
||||
buf := make([]byte, 16)
|
||||
for off := 0; off < len(data); off += 16 {
|
||||
for i := 0; i < 16; i++ {
|
||||
buf[i] = data[off+i] ^ prev[i]
|
||||
}
|
||||
enc := encryptBlock(t, buf)
|
||||
copy(res[off:], enc)
|
||||
copy(prev, enc)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func decryptCBC(t *lookupTables, data []byte) []byte {
|
||||
res := make([]byte, len(data))
|
||||
prev := make([]byte, 16)
|
||||
for off := 0; off < len(data); off += 16 {
|
||||
block := data[off : off+16]
|
||||
dec := decryptBlock(t, block)
|
||||
for i := 0; i < 16; i++ {
|
||||
res[off+i] = dec[i] ^ prev[i]
|
||||
}
|
||||
copy(prev, block)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type lookupTables struct {
|
||||
invRound, invXor, invFirst []byte
|
||||
round, xor, final []byte
|
||||
permDecrypt, permEncrypt []byte
|
||||
}
|
||||
|
||||
var tableSpecs = []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{"inv_round", 0x28000},
|
||||
{"inv_xor", 0x3C000},
|
||||
{"inv_first", 0x1000},
|
||||
{"round", 0x28000},
|
||||
{"xor", 0x3C000},
|
||||
{"final", 0x1000},
|
||||
{"perm_decrypt", 8},
|
||||
{"perm_encrypt", 8},
|
||||
}
|
||||
|
||||
func mustLoadTables(data []byte) *lookupTables {
|
||||
t, err := loadTables(data)
|
||||
if err != nil {
|
||||
panic("bangcle: " + err.Error())
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// loadTables parses the BGTB container: magic, uint16 version, uint16 count,
|
||||
// then count (offset, length) uint32 pairs, then the raw table bytes.
|
||||
func loadTables(data []byte) (*lookupTables, error) {
|
||||
const header, indexEntry, count = 8, 8, 8
|
||||
if len(data) < header+count*indexEntry {
|
||||
return nil, fmt.Errorf("table file too short (%d bytes)", len(data))
|
||||
}
|
||||
if string(data[:4]) != "BGTB" {
|
||||
return nil, fmt.Errorf("bad magic %q", data[:4])
|
||||
}
|
||||
if v := binary.LittleEndian.Uint16(data[4:]); v != 1 {
|
||||
return nil, fmt.Errorf("unsupported table version %d", v)
|
||||
}
|
||||
if n := binary.LittleEndian.Uint16(data[6:]); n != count {
|
||||
return nil, fmt.Errorf("expected %d tables, got %d", count, n)
|
||||
}
|
||||
|
||||
out := make([][]byte, count)
|
||||
for i := 0; i < count; i++ {
|
||||
p := header + i*indexEntry
|
||||
off := binary.LittleEndian.Uint32(data[p:])
|
||||
length := binary.LittleEndian.Uint32(data[p+4:])
|
||||
spec := tableSpecs[i]
|
||||
if int(length) != spec.size {
|
||||
return nil, fmt.Errorf("table %s: expected %d bytes, got %d", spec.name, spec.size, length)
|
||||
}
|
||||
if int(off)+int(length) > len(data) {
|
||||
return nil, fmt.Errorf("table %s: extends beyond file", spec.name)
|
||||
}
|
||||
out[i] = data[off : off+length]
|
||||
}
|
||||
return &lookupTables{out[0], out[1], out[2], out[3], out[4], out[5], out[6], out[7]}, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package bangcle
|
||||
|
||||
// Expected values come from pybyd 0.0.75 — regenerate with testdata/gen_fixtures.py.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
inputs := []string{
|
||||
"",
|
||||
"a",
|
||||
"0123456789abcdef", // exactly one block: must gain a whole pad block
|
||||
strings.Repeat("x", 4096),
|
||||
`{"request":"hello world"}`,
|
||||
"ünïcodé ✓",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
out, err := Decode(Encode([]byte(in)))
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(Encode(%.20q)): %v", in, err)
|
||||
}
|
||||
if string(out) != in {
|
||||
t.Errorf("round trip %.20q -> %.20q", in, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVectors pins the white-box AES port against ciphertext from pybyd. If the
|
||||
// block functions drift by a single index this fails; round-tripping alone
|
||||
// would not, since it would drift symmetrically.
|
||||
func TestVectors(t *testing.T) {
|
||||
vectors := []struct{ plain, want string }{
|
||||
{"", "FlpybH7xiohrkjagBOJY5Mg=="},
|
||||
{"hello", "FsFbBBn9/qw4cYEF9S1Jk6w=="},
|
||||
{"ünïcodé ✓", "FRqewj1Ypq1V2n8VqsiZPUA=="},
|
||||
{`{"functionType":"pwdLogin"}`, "FCHhJ73+HviiIT3dTDBeITnzHp5caNe/C50GlE8P0dqQ="},
|
||||
}
|
||||
for _, v := range vectors {
|
||||
if got := Encode([]byte(v.plain)); got != v.want {
|
||||
t.Errorf("Encode(%q)\n got %s\nwant %s", v.plain, got, v.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsMalformed(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"missing prefix": "aGVsbG8=",
|
||||
"bad base64": "F!!!!",
|
||||
"short block": "F" + "AAAA", // 3 bytes: not a multiple of 16
|
||||
}
|
||||
for name, in := range cases {
|
||||
if _, err := Decode(in); err == nil {
|
||||
t.Errorf("%s: expected an error for %q", name, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeNormalises covers the url-safe alphabet, stray whitespace and the
|
||||
// missing padding that BYD responses sometimes arrive with.
|
||||
func TestDecodeNormalises(t *testing.T) {
|
||||
want := "the quick brown fox"
|
||||
env := Encode([]byte(want))
|
||||
|
||||
variants := []string{
|
||||
env,
|
||||
strings.TrimRight(env, "="),
|
||||
strings.NewReplacer("+", "-", "/", "_").Replace(env),
|
||||
" " + env + "\n",
|
||||
}
|
||||
for _, v := range variants {
|
||||
got, err := Decode(v)
|
||||
if err != nil {
|
||||
t.Errorf("Decode(%q): %v", v, err)
|
||||
continue
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("Decode(%q) = %q, want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPKCS7LeavesUnpaddedData(t *testing.T) {
|
||||
// BYD sometimes returns payloads without padding; pybyd passes them
|
||||
// through rather than erroring, and so must this.
|
||||
for _, in := range []string{"", "no padding here", "\x05\x05\x05"} {
|
||||
if got := StripPKCS7([]byte(in)); string(got) != in {
|
||||
t.Errorf("StripPKCS7(%q) = %q, want it unchanged", in, got)
|
||||
}
|
||||
}
|
||||
padded := append([]byte("abc"), 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13)
|
||||
if got := StripPKCS7(padded); string(got) != "abc" {
|
||||
t.Errorf("StripPKCS7(valid) = %q, want %q", got, "abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadPKCS7AlwaysAddsAtLeastOneByte(t *testing.T) {
|
||||
for _, n := range []int{0, 1, 15, 16, 17, 32} {
|
||||
got := PadPKCS7(make([]byte, n))
|
||||
if len(got)%16 != 0 || len(got) <= n {
|
||||
t.Errorf("PadPKCS7(%d bytes) produced %d bytes", n, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesLoaded(t *testing.T) {
|
||||
for _, spec := range tableSpecs {
|
||||
var got int
|
||||
switch spec.name {
|
||||
case "inv_round":
|
||||
got = len(tables.invRound)
|
||||
case "inv_xor":
|
||||
got = len(tables.invXor)
|
||||
case "inv_first":
|
||||
got = len(tables.invFirst)
|
||||
case "round":
|
||||
got = len(tables.round)
|
||||
case "xor":
|
||||
got = len(tables.xor)
|
||||
case "final":
|
||||
got = len(tables.final)
|
||||
case "perm_decrypt":
|
||||
got = len(tables.permDecrypt)
|
||||
case "perm_encrypt":
|
||||
got = len(tables.permEncrypt)
|
||||
}
|
||||
if got != spec.size {
|
||||
t.Errorf("table %s: %d bytes, want %d", spec.name, got, spec.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTablesRejectsBadContainer(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"too short": []byte("BGTB"),
|
||||
"bad magic": append([]byte("XXXX"), make([]byte, 100)...),
|
||||
"bad version": append([]byte("BGTB\x02\x00\x08\x00"), make([]byte, 100)...),
|
||||
"bad count": append([]byte("BGTB\x01\x00\x03\x00"), make([]byte, 100)...),
|
||||
}
|
||||
for name, data := range cases {
|
||||
if _, err := loadTables(data); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncode(b *testing.B) {
|
||||
payload := []byte(strings.Repeat(`{"field":"value"},`, 100))
|
||||
b.SetBytes(int64(len(payload)))
|
||||
for i := 0; i < b.N; i++ {
|
||||
Encode(payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package bangcle
|
||||
|
||||
// White-box AES block functions.
|
||||
//
|
||||
// The index arithmetic below is a one-for-one transcription of decompiled code
|
||||
// (via pybyd). It is deliberately not tidied or renamed: keeping it diffable
|
||||
// against the reference is worth more than readable variable names, because the
|
||||
// only way to debug a mistake here is to compare intermediate state with pybyd.
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
// prepareMatrix transposes a 4x4 block into the working state layout (col*8+row).
|
||||
func prepareMatrix(in, state []byte) {
|
||||
for col := 0; col < 4; col++ {
|
||||
for row := 0; row < 4; row++ {
|
||||
state[col*8+row] = in[col+row*4]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decryptBlock(t *lookupTables, block []byte) []byte {
|
||||
state := make([]byte, 32)
|
||||
temp64 := make([]byte, 64)
|
||||
tmp32 := make([]byte, 32)
|
||||
out := make([]byte, 16)
|
||||
|
||||
prepareMatrix(block, state)
|
||||
|
||||
for rnd := 9; rnd >= 1; rnd-- {
|
||||
l21 := rnd * 4
|
||||
permPtr := 0
|
||||
for i := 0; i < 4; i++ {
|
||||
b := int(t.permDecrypt[permPtr])
|
||||
l16 := i * 8
|
||||
base := i * 16
|
||||
for j := 0; j < 4; j++ {
|
||||
u7 := (b + j) & 3
|
||||
idx := int(state[l16+u7]) + (i+(l21+u7)*4)*256
|
||||
binary.LittleEndian.PutUint32(temp64[base+j*4:], binary.LittleEndian.Uint32(t.invRound[idx*4:]))
|
||||
}
|
||||
permPtr += 2
|
||||
}
|
||||
|
||||
i15 := 1
|
||||
for l21x := 0; l21x < 4; l21x++ {
|
||||
pb := l21x
|
||||
for l9 := 0; l9 < 4; l9++ {
|
||||
u6 := int(temp64[pb]) & 0xF
|
||||
u26 := int(temp64[pb]) & 0xF0
|
||||
f := [3]int{int(temp64[pb+0x10]), int(temp64[pb+0x20]), int(temp64[pb+0x30])}
|
||||
|
||||
l2 := l9*0x18 + rnd*0x60
|
||||
i25 := i15
|
||||
for k := 0; k < 3; k++ {
|
||||
b := f[k]
|
||||
u27 := u6 | ((b << 4) & 0xFF)
|
||||
u26 = ((u26 >> 4) | ((b >> 4) << 4)) & 0xFF
|
||||
u6 = int(t.invXor[(l2+i25-1)*0x100+u27]) & 0xF
|
||||
u26 = (int(t.invXor[(l2+i25)*0x100+u26]) & 0xF) << 4
|
||||
i25 += 2
|
||||
}
|
||||
state[l9+l21x*8] = byte((u26 | u6) & 0xFF)
|
||||
pb += 4
|
||||
}
|
||||
i15 += 6
|
||||
}
|
||||
}
|
||||
|
||||
copy(tmp32, state)
|
||||
u8, u10, u12 := 1, 3, 2
|
||||
for row := 0; row < 4; row++ {
|
||||
state[row] = t.invFirst[int(tmp32[row])+row*0x400]
|
||||
|
||||
r1 := u10 & 3
|
||||
state[8+row] = t.invFirst[int(tmp32[8+r1])+r1*0x400+0x100]
|
||||
|
||||
r2 := u12 & 3
|
||||
state[0x10+row] = t.invFirst[int(tmp32[0x10+r2])+r2*0x400+0x200]
|
||||
|
||||
r3 := u8 & 3
|
||||
state[0x18+row] = t.invFirst[int(tmp32[0x18+r3])+r3*0x400+0x300]
|
||||
|
||||
u8++
|
||||
u10++
|
||||
u12++
|
||||
}
|
||||
|
||||
for col := 0; col < 4; col++ {
|
||||
for row := 0; row < 4; row++ {
|
||||
out[col+row*4] = state[col*8+row]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encryptBlock(t *lookupTables, block []byte) []byte {
|
||||
state := make([]byte, 32)
|
||||
temp64 := make([]byte, 64)
|
||||
tmp32 := make([]byte, 32)
|
||||
out := make([]byte, 16)
|
||||
|
||||
prepareMatrix(block, state)
|
||||
|
||||
for rnd := 0; rnd < 9; rnd++ {
|
||||
l21 := rnd * 4
|
||||
permPtr := 0
|
||||
for i := 0; i < 4; i++ {
|
||||
b := int(t.permEncrypt[permPtr])
|
||||
l16 := i * 8
|
||||
base := i * 16
|
||||
for j := 0; j < 4; j++ {
|
||||
u8 := (b + j) & 3
|
||||
idx := int(state[l16+u8]) + (i+(l21+u8)*4)*256
|
||||
binary.LittleEndian.PutUint32(temp64[base+j*4:], binary.LittleEndian.Uint32(t.round[idx*4:]))
|
||||
}
|
||||
permPtr += 2
|
||||
}
|
||||
|
||||
i16 := 1
|
||||
for l22 := 0; l22 < 4; l22++ {
|
||||
pb := l22
|
||||
for l10 := 0; l10 < 4; l10++ {
|
||||
u7 := int(temp64[pb]) & 0xF
|
||||
u26 := int(temp64[pb]) & 0xF0
|
||||
f := [3]int{int(temp64[pb+0x10]), int(temp64[pb+0x20]), int(temp64[pb+0x30])}
|
||||
|
||||
l2 := l10*0x18 + rnd*0x60
|
||||
i25 := i16
|
||||
for k := 0; k < 3; k++ {
|
||||
b := f[k]
|
||||
u27 := u7 | ((b << 4) & 0xFF)
|
||||
u26 = ((u26 >> 4) | ((b >> 4) << 4)) & 0xFF
|
||||
u7 = int(t.xor[(l2+i25-1)*0x100+u27]) & 0xF
|
||||
u26 = (int(t.xor[(l2+i25)*0x100+u26]) & 0xF) << 4
|
||||
i25 += 2
|
||||
}
|
||||
state[l10+l22*8] = byte((u26 | u7) & 0xFF)
|
||||
pb += 4
|
||||
}
|
||||
i16 += 6
|
||||
}
|
||||
}
|
||||
|
||||
copy(tmp32, state)
|
||||
for row := 0; row < 4; row++ {
|
||||
r0 := row & 3
|
||||
state[row] = t.final[int(tmp32[r0])+r0*0x400]
|
||||
|
||||
r1 := (1 + row) & 3
|
||||
state[8+row] = t.final[int(tmp32[8+r1])+r1*0x400+0x100]
|
||||
|
||||
r2 := (2 + row) & 3
|
||||
state[0x10+row] = t.final[int(tmp32[0x10+r2])+r2*0x400+0x200]
|
||||
|
||||
r3 := (3 + row) & 3
|
||||
state[0x18+row] = t.final[int(tmp32[0x18+r3])+r3*0x400+0x300]
|
||||
}
|
||||
|
||||
for col := 0; col < 4; col++ {
|
||||
for row := 0; row < 4; row++ {
|
||||
out[col+row*4] = state[col*8+row]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user