From f8361e2b8bbb190f0760f08b5ae766cc85fd9a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:16:09 +0000 Subject: [PATCH 01/18] pair: add O5 mode and key derivation Add Mode enum (Dash, O5) and a -mode CLI flag (default "dash" so existing users see no behavior change). Plumb the selected mode through Pod into the Pair instance constructed in StartActivation. Add the Omnipod 5 KDF: SHA-256 over a length-prefixed buffer of FIRMWARE_ID || 0 || pdmPublic || podPublic || sharedSecret, split into 16-byte conf and 16-byte ltk. In computePairData, when Mode == O5, derive (conf, ltk) via the new KDF and skip the Dash CMAC chain entirely. The KDF expects 64-byte raw P-256 public keys; real P-256 key plumbing arrives with the SPS2 work in a later commit. Tests in o5kdf_test.go exercise the KDF in isolation with synthetic 64-byte fixtures. Source: jwoglom/five commit 6e2fa66 (Step 1). --- main.go | 11 ++++-- pkg/pair/mode.go | 33 ++++++++++++++++++ pkg/pair/o5kdf.go | 58 +++++++++++++++++++++++++++++++ pkg/pair/o5kdf_test.go | 78 ++++++++++++++++++++++++++++++++++++++++++ pkg/pair/pair.go | 26 ++++++++++++-- pkg/pod/pod.go | 10 +++--- 6 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 pkg/pair/mode.go create mode 100644 pkg/pair/o5kdf.go create mode 100644 pkg/pair/o5kdf_test.go diff --git a/main.go b/main.go index 12a6bb5..cf6c43a 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/avereha/pod/pkg/api" "github.com/avereha/pod/pkg/bluetooth" + "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/pod" "github.com/sirupsen/logrus" @@ -15,12 +16,18 @@ import ( func main() { var stateFile = flag.String("state", "state.toml", "pod state") var freshState = flag.Bool("fresh", false, "start fresh. not activated, empty state") + var modeFlag = flag.String("mode", "dash", "pairing mode: dash or o5") // if both verbose and quiet are chosen, e.g., -v -q, the verbose dominates var traceLevel = flag.Bool("v", false, "verbose off by default, TraceLevel") var infoLevel = flag.Bool("q", false, "quiet off by default, InfoLevel") flag.Parse() + pairMode, err := pair.ParseMode(*modeFlag) + if err != nil { + log.Fatalf("%v", err) + } + if *traceLevel { log.SetLevel(log.TraceLevel) } else if *infoLevel { @@ -39,7 +46,6 @@ func main() { state := &pod.PODState{ Filename: *stateFile, } - var err error if !(*freshState) { state, err = pod.NewState(*stateFile) if err != nil { @@ -55,7 +61,8 @@ func main() { log.Fatalf("Could not start BLE: %s", err) } - p := pod.New(ble, *stateFile, *freshState) + log.Infof("pairing mode: %s", pairMode) + p := pod.New(ble, *stateFile, *freshState, pairMode) go func() { p.StartAcceptingCommands() }() diff --git a/pkg/pair/mode.go b/pkg/pair/mode.go new file mode 100644 index 0000000..846b006 --- /dev/null +++ b/pkg/pair/mode.go @@ -0,0 +1,33 @@ +package pair + +import "fmt" + +// Mode selects which pairing protocol variant the simulator implements. +type Mode int + +const ( + ModeDash Mode = iota + ModeO5 +) + +func (m Mode) String() string { + switch m { + case ModeDash: + return "dash" + case ModeO5: + return "o5" + } + return fmt.Sprintf("Mode(%d)", int(m)) +} + +// ParseMode parses "dash" or "o5"; case-insensitive is not supported because +// the flag value comes from a known small set. +func ParseMode(s string) (Mode, error) { + switch s { + case "dash": + return ModeDash, nil + case "o5": + return ModeO5, nil + } + return 0, fmt.Errorf("invalid pair mode %q (want dash or o5)", s) +} diff --git a/pkg/pair/o5kdf.go b/pkg/pair/o5kdf.go new file mode 100644 index 0000000..e89aaac --- /dev/null +++ b/pkg/pair/o5kdf.go @@ -0,0 +1,58 @@ +package pair + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" +) + +// FirmwareID is the fixed 6-byte value the Omnipod 5 PDM firmware mixes into +// the pairing KDF. Source: OmnipodKit O5LTKExchanger.swift FIRMWARE_ID. +var FirmwareID = []byte{0x9b, 0x0a, 0xb9, 0x6a, 0x76, 0xf4} + +// o5KDFInput builds the length-prefixed buffer that is hashed with SHA-256 to +// derive the (conf, ltk) pair during O5 pairing. +// +// Layout (each length is a 64-bit big-endian unsigned integer): +// +// be64(len(FirmwareID)) || FirmwareID (6 bytes) +// be64(4) || 0x00000000 (4 bytes) +// be64(len(pdmPublic)) || pdmPublic (64 bytes raw X||Y) +// be64(len(podPublic)) || podPublic (64 bytes raw X||Y) +// be64(len(sharedSecret))|| sharedSecret (32 bytes) +// +// Total: 8+6 + 8+4 + 8+64 + 8+64 + 8+32 = 210 bytes. +// +// Mirrors O5KeyExchange.swift:88-99. +func o5KDFInput(pdmPublic, podPublic, sharedSecret []byte) []byte { + var buf bytes.Buffer + writeLP := func(b []byte) { + var lp [8]byte + binary.BigEndian.PutUint64(lp[:], uint64(len(b))) + buf.Write(lp[:]) + buf.Write(b) + } + writeLP(FirmwareID) + writeLP([]byte{0x00, 0x00, 0x00, 0x00}) + writeLP(pdmPublic) + writeLP(podPublic) + writeLP(sharedSecret) + return buf.Bytes() +} + +// o5DeriveKeys derives the (conf, ltk) 16-byte keys from pairing inputs. +// Mirrors O5KeyExchange.swift:83-108. +func o5DeriveKeys(pdmPublic, podPublic, sharedSecret []byte) (conf, ltk []byte, err error) { + if len(pdmPublic) != 64 { + return nil, nil, fmt.Errorf("o5DeriveKeys: pdmPublic must be 64 bytes (raw X||Y), got %d", len(pdmPublic)) + } + if len(podPublic) != 64 { + return nil, nil, fmt.Errorf("o5DeriveKeys: podPublic must be 64 bytes (raw X||Y), got %d", len(podPublic)) + } + if len(sharedSecret) != 32 { + return nil, nil, fmt.Errorf("o5DeriveKeys: sharedSecret must be 32 bytes, got %d", len(sharedSecret)) + } + sum := sha256.Sum256(o5KDFInput(pdmPublic, podPublic, sharedSecret)) + return sum[0:16], sum[16:32], nil +} diff --git a/pkg/pair/o5kdf_test.go b/pkg/pair/o5kdf_test.go new file mode 100644 index 0000000..15850c8 --- /dev/null +++ b/pkg/pair/o5kdf_test.go @@ -0,0 +1,78 @@ +package pair + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" +) + +// TestO5KDFInputLayout asserts the exact byte layout of the KDF input buffer. +// If this drifts, OmnipodKit will compute different keys and pairing will fail. +func TestO5KDFInputLayout(t *testing.T) { + // Distinguishable test vectors (not real key material). + pdmPub := bytes.Repeat([]byte{0xAA}, 64) + podPub := bytes.Repeat([]byte{0xBB}, 64) + shared := bytes.Repeat([]byte{0xCC}, 32) + + got := o5KDFInput(pdmPub, podPub, shared) + + // Hand-built expected layout: each field length-prefixed by a 64-bit + // big-endian length. + var want bytes.Buffer + want.Write([]byte{0, 0, 0, 0, 0, 0, 0, 6}) + want.Write(FirmwareID) + want.Write([]byte{0, 0, 0, 0, 0, 0, 0, 4}) + want.Write([]byte{0, 0, 0, 0}) + want.Write([]byte{0, 0, 0, 0, 0, 0, 0, 64}) + want.Write(pdmPub) + want.Write([]byte{0, 0, 0, 0, 0, 0, 0, 64}) + want.Write(podPub) + want.Write([]byte{0, 0, 0, 0, 0, 0, 0, 32}) + want.Write(shared) + + if !bytes.Equal(got, want.Bytes()) { + t.Fatalf("KDF input mismatch\n got: %x\nwant: %x", got, want.Bytes()) + } + if len(got) != 210 { + t.Fatalf("KDF input expected 210 bytes, got %d", len(got)) + } +} + +// TestO5DeriveKeysShape verifies the SHA-256 output is split as conf||ltk. +func TestO5DeriveKeysShape(t *testing.T) { + pdmPub := bytes.Repeat([]byte{0xAA}, 64) + podPub := bytes.Repeat([]byte{0xBB}, 64) + shared := bytes.Repeat([]byte{0xCC}, 32) + + conf, ltk, err := o5DeriveKeys(pdmPub, podPub, shared) + if err != nil { + t.Fatal(err) + } + if len(conf) != 16 || len(ltk) != 16 { + t.Fatalf("expected 16+16-byte keys, got %d+%d", len(conf), len(ltk)) + } + full := sha256.Sum256(o5KDFInput(pdmPub, podPub, shared)) + if !bytes.Equal(conf, full[0:16]) || !bytes.Equal(ltk, full[16:32]) { + t.Fatalf("conf/ltk should be SHA-256(input)[0:16] / [16:32]") + } +} + +// TestO5KDFFirmwareID pins FIRMWARE_ID to the value baked into OmnipodKit. +func TestO5KDFFirmwareID(t *testing.T) { + want, _ := hex.DecodeString("9b0ab96a76f4") + if !bytes.Equal(FirmwareID, want) { + t.Fatalf("FirmwareID mismatch: got %x want %x", FirmwareID, want) + } +} + +func TestO5DeriveKeysRejectsBadSizes(t *testing.T) { + _, _, err := o5DeriveKeys(make([]byte, 63), make([]byte, 64), make([]byte, 32)) + if err == nil { + t.Fatal("expected error for short pdmPublic") + } + _, _, err = o5DeriveKeys(make([]byte, 64), make([]byte, 64), make([]byte, 16)) + if err == nil { + t.Fatal("expected error for short sharedSecret") + } +} diff --git a/pkg/pair/pair.go b/pkg/pair/pair.go index 99ac2da..ea17604 100644 --- a/pkg/pair/pair.go +++ b/pkg/pair/pair.go @@ -25,6 +25,9 @@ const ( ) type Pair struct { + // Mode selects Dash (CMAC chain) or O5 (SHA-256/FIRMWARE_ID) key derivation. + Mode Mode + podPublic []byte podPrivate []byte podNonce []byte @@ -39,7 +42,10 @@ type Pair struct { podID []byte ltk []byte - confKey []byte // key used to sign the "Conf" values + confKey []byte // key used to sign the "Conf" values; Dash only + + // O5-only: 16-byte AES-CCM key for SPS2.1/SPS2. + conf []byte } func parseStringByte(expectedNames []string, data []byte) (map[string][]byte, error) { @@ -215,7 +221,23 @@ func (c *Pair) computePairData() error { if err != nil { return err } - log.Debugf("Donna LTK: %x", c.curve25519LTK) + log.Infof("Shared secret %x :: %d", c.curve25519LTK, len(c.curve25519LTK)) + + if c.Mode == ModeO5 { + conf, ltk, err := o5DeriveKeys(c.pdmPublic, c.podPublic, c.curve25519LTK) + if err != nil { + return err + } + c.conf = conf + c.ltk = ltk + log.Infof("O5 conf key %x", c.conf) + log.Infof("O5 LTK %x", c.ltk) + // SPS2.1/SPS2 in O5 are AES-CCM payloads, not CMAC confirmation values; + // they are populated in dedicated handlers (Step 3). Skip the Dash + // CMAC chain. + return nil + } + //first_key = data.pod_public[-4:] + data.pdm_public[-4:] + data.pod_nonce[-4:] + data.pdm_nonce[-4:] firstKey := append(c.podPublic[28:], c.pdmPublic[28:]...) firstKey = append(firstKey, c.podNonce[12:]...) diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 3949478..b8ce737 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -31,6 +31,7 @@ type PodMsgBody struct { type Pod struct { ble *bluetooth.Ble state *PODState + pairMode pair.Mode mtx sync.Mutex webMessageHook func([]byte) } @@ -39,7 +40,7 @@ type Pod struct { var crashBeforeProcessingCommand bool var crashAfterProcessingCommand bool -func New(ble *bluetooth.Ble, stateFile string, freshState bool) *Pod { +func New(ble *bluetooth.Ble, stateFile string, freshState bool, pairMode pair.Mode) *Pod { var err error state := &PODState{ @@ -55,8 +56,9 @@ func New(ble *bluetooth.Ble, stateFile string, freshState bool) *Pod { } ret := &Pod{ - ble: ble, - state: state, + ble: ble, + state: state, + pairMode: pairMode, } return ret @@ -103,7 +105,7 @@ func (p *Pod) StartAcceptingCommands() { func (p *Pod) StartActivation() { - pair := &pair.Pair{} + pair := &pair.Pair{Mode: p.pairMode} msg, _ := p.ble.ReadMessage() if err := pair.ParseSP1SP2(msg); err != nil { log.Fatalf("pkg pod; error parsing SP1SP2 %s", err) From 33f2e7514dc7ac3b0d344a1a9b0da74c38560462 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:19:02 +0000 Subject: [PATCH 02/18] testfixtures: add Omnipod 5 pairing captures Embed BTSNOOP captures from real Omnipod 5 pairing sessions as Go test fixtures. The new pkg/testfixtures package exposes Captures(), returning three deterministic PairingCapture values with SPS1/SPS2.1/SPS2 byte strings from two different pod sessions. These will be consumed by SPS2 and Type-4 verification tests in later commits. Bump go.mod 1.15 -> 1.20 (go:embed requires 1.16+; 1.20 matches the rest of the porting work). go.sum already had the needed entries from prior vendoring; vendor/modules.txt picks up the explicit marker for the two indirect modules that Go 1.17+ requires to be declared. Source: jwoglom/five commit 9551b13 (Step 0). --- go.mod | 4 +- pkg/testfixtures/btsnoop.go | 97 ++++++++++++++++++ .../btsnoop1/SPS1_src__dst__1382.bin | 2 + .../btsnoop1/SPS1_src__dst__1387.bin | 1 + .../btsnoop1/SPS1_src__dst__1590.bin | Bin 0 -> 80 bytes .../btsnoop1/SPS1_src__dst__1598.bin | 1 + .../btsnoop1/session1_SPS2.1_phone_to_pod.bin | Bin 0 -> 642 bytes .../btsnoop1/session1_SPS2.1_pod_to_phone.bin | Bin 0 -> 641 bytes .../btsnoop1/session1_SPS2_phone_to_pod.bin | Bin 0 -> 953 bytes .../btsnoop1/session2_SPS2.1_phone_to_pod.bin | Bin 0 -> 642 bytes .../btsnoop1/session2_SPS2.1_pod_to_phone.bin | Bin 0 -> 641 bytes .../btsnoop1/session2_SPS2_phone_to_pod.bin | Bin 0 -> 953 bytes .../btsnoop1/session2_SPS2_pod_to_phone.bin | Bin 0 -> 895 bytes .../btsnoop2/SPS1_src__dst__1694.bin | 3 + .../btsnoop2/SPS1_src__dst__1701.bin | 2 + .../btsnoop2/SPS2.1_phone_to_pod.bin | Bin 0 -> 642 bytes .../btsnoop2/SPS2.1_pod_to_phone.bin | Bin 0 -> 641 bytes .../btsnoop2/SPS2_phone_to_pod.bin | Bin 0 -> 952 bytes .../btsnoop2/SPS2_pod_to_phone.bin | Bin 0 -> 895 bytes pkg/testfixtures/btsnoop_test.go | 29 ++++++ vendor/modules.txt | 2 + 21 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 pkg/testfixtures/btsnoop.go create mode 100644 pkg/testfixtures/btsnoop1/SPS1_src__dst__1382.bin create mode 100644 pkg/testfixtures/btsnoop1/SPS1_src__dst__1387.bin create mode 100644 pkg/testfixtures/btsnoop1/SPS1_src__dst__1590.bin create mode 100644 pkg/testfixtures/btsnoop1/SPS1_src__dst__1598.bin create mode 100644 pkg/testfixtures/btsnoop1/session1_SPS2.1_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop1/session1_SPS2.1_pod_to_phone.bin create mode 100644 pkg/testfixtures/btsnoop1/session1_SPS2_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop1/session2_SPS2.1_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop1/session2_SPS2.1_pod_to_phone.bin create mode 100644 pkg/testfixtures/btsnoop1/session2_SPS2_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop1/session2_SPS2_pod_to_phone.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS1_src__dst__1694.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS1_src__dst__1701.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS2.1_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS2.1_pod_to_phone.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS2_phone_to_pod.bin create mode 100644 pkg/testfixtures/btsnoop2/SPS2_pod_to_phone.bin create mode 100644 pkg/testfixtures/btsnoop_test.go diff --git a/go.mod b/go.mod index d8d9512..87f7e91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/avereha/pod -go 1.15 +go 1.20 require ( github.com/davecgh/go-spew v1.1.1 @@ -11,9 +11,11 @@ require ( github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff // indirect github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11 // indirect github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf github.com/pelletier/go-toml v1.8.1 + github.com/pkg/errors v0.9.1 // indirect github.com/pschlump/AesCCM v0.0.0-20160925022350-c5df73b5834e github.com/pschlump/godebug v1.0.1 // indirect github.com/sirupsen/logrus v1.6.0 diff --git a/pkg/testfixtures/btsnoop.go b/pkg/testfixtures/btsnoop.go new file mode 100644 index 0000000..e3501f3 --- /dev/null +++ b/pkg/testfixtures/btsnoop.go @@ -0,0 +1,97 @@ +// Package testfixtures embeds real Omnipod 5 BLE captures from +// /Users/james/repos/Omnipod5APK/BTSNOOP for use in unit tests. +// +// All payloads are the post-StringLengthPrefixEncoding values, i.e. the bytes +// after the "SPS1=", "SPS2.1=", "SPS2=" length-prefixed key-value envelope. +// +// - SPS1 payloads are 80 bytes = 64-byte P-256 public key (raw X||Y, no leading 0x04) +// followed by a 16-byte nonce. +// - SPS2.1 payloads are AES-CCM(conf, sps_nonce_write|read, 8-byte tag) of an +// intermediate-CA cert DER. Phone->pod is 642 bytes (634 cert + 8 tag); +// pod->phone is 641 bytes (633 cert + 8 tag). +// - SPS2 payloads are AES-CCM of TLS_cert_DER || ECDSA_signature(64). +// +// The KDF inputs (pdmPublic/podPublic/pdmNonce/podNonce) are extractable but +// the corresponding private keys are not in the captures, so these fixtures +// cannot be used to KAT the SHA-256 KDF directly via decryption. They are +// useful for structural/parsing tests and for end-to-end replay against the +// simulator's pod side. +package testfixtures + +import ( + "embed" + "fmt" +) + +//go:embed btsnoop1/*.bin btsnoop2/*.bin +var fs embed.FS + +// SPS1Payload is the parsed SPS1 contents: a 64-byte raw P-256 public key +// (X||Y, no leading 0x04 prefix) and a 16-byte nonce. +type SPS1Payload struct { + Public []byte // 64 bytes + Nonce []byte // 16 bytes +} + +// PairingCapture bundles the SPS payloads for one captured pairing session. +// Any field may be nil if that direction was not captured. +type PairingCapture struct { + Name string + + PDMSPS1 *SPS1Payload // phone -> pod + PodSPS1 *SPS1Payload // pod -> phone + + PDMSPS21Ciphertext []byte // phone -> pod, AES-CCM + PodSPS21Ciphertext []byte // pod -> phone, AES-CCM + PDMSPS2Ciphertext []byte // phone -> pod, AES-CCM + PodSPS2Ciphertext []byte // pod -> phone, AES-CCM +} + +func mustRead(path string) []byte { + b, err := fs.ReadFile(path) + if err != nil { + panic(fmt.Sprintf("testfixtures: %v", err)) + } + return b +} + +func parseSPS1(b []byte) *SPS1Payload { + if len(b) != 80 { + panic(fmt.Sprintf("testfixtures: SPS1 expected 80 bytes, got %d", len(b))) + } + return &SPS1Payload{Public: b[0:64], Nonce: b[64:80]} +} + +// Captures returns every pairing capture available, in deterministic order. +// btsnoop1 has two sessions; btsnoop2 has one. +func Captures() []*PairingCapture { + return []*PairingCapture{ + { + Name: "btsnoop1/session1", + PDMSPS1: parseSPS1(mustRead("btsnoop1/SPS1_src__dst__1382.bin")), + PodSPS1: parseSPS1(mustRead("btsnoop1/SPS1_src__dst__1387.bin")), + PDMSPS21Ciphertext: mustRead("btsnoop1/session1_SPS2.1_phone_to_pod.bin"), + PodSPS21Ciphertext: mustRead("btsnoop1/session1_SPS2.1_pod_to_phone.bin"), + PDMSPS2Ciphertext: mustRead("btsnoop1/session1_SPS2_phone_to_pod.bin"), + // session1 pod->phone SPS2 was not captured. + }, + { + Name: "btsnoop1/session2", + PDMSPS1: parseSPS1(mustRead("btsnoop1/SPS1_src__dst__1590.bin")), + PodSPS1: parseSPS1(mustRead("btsnoop1/SPS1_src__dst__1598.bin")), + PDMSPS21Ciphertext: mustRead("btsnoop1/session2_SPS2.1_phone_to_pod.bin"), + PodSPS21Ciphertext: mustRead("btsnoop1/session2_SPS2.1_pod_to_phone.bin"), + PDMSPS2Ciphertext: mustRead("btsnoop1/session2_SPS2_phone_to_pod.bin"), + PodSPS2Ciphertext: mustRead("btsnoop1/session2_SPS2_pod_to_phone.bin"), + }, + { + Name: "btsnoop2", + PDMSPS1: parseSPS1(mustRead("btsnoop2/SPS1_src__dst__1694.bin")), + PodSPS1: parseSPS1(mustRead("btsnoop2/SPS1_src__dst__1701.bin")), + PDMSPS21Ciphertext: mustRead("btsnoop2/SPS2.1_phone_to_pod.bin"), + PodSPS21Ciphertext: mustRead("btsnoop2/SPS2.1_pod_to_phone.bin"), + PDMSPS2Ciphertext: mustRead("btsnoop2/SPS2_phone_to_pod.bin"), + PodSPS2Ciphertext: mustRead("btsnoop2/SPS2_pod_to_phone.bin"), + }, + } +} diff --git a/pkg/testfixtures/btsnoop1/SPS1_src__dst__1382.bin b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1382.bin new file mode 100644 index 0000000..0bd2ca6 --- /dev/null +++ b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1382.bin @@ -0,0 +1,2 @@ +5tGjI4|7lQh9X4?'+ +m5MTn@F FU(MNTaK \ No newline at end of file diff --git a/pkg/testfixtures/btsnoop1/SPS1_src__dst__1387.bin b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1387.bin new file mode 100644 index 0000000..991849d --- /dev/null +++ b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1387.bin @@ -0,0 +1 @@ +, 54Kps+0S,Q#h. >" g8į!Tѣ2o;4 {p5VOٺwi󒉰 \ No newline at end of file diff --git a/pkg/testfixtures/btsnoop1/SPS1_src__dst__1590.bin b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1590.bin new file mode 100644 index 0000000000000000000000000000000000000000..f405180ef9765926e09b75d66e4ba74ab1bf5875 GIT binary patch literal 80 zcmV-W0I&Z<^AsP0h}xO8R&m3~^8P&lx4lrUKmQt#nPtLc68}LPpgK*5cWpH-9n+X( mseNxMRH3-uV(oPvoiUf#ov@;R!f_LvOU3+fW4=1Mm$KPqLMc)J literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop1/SPS1_src__dst__1598.bin b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1598.bin new file mode 100644 index 0000000..fb5cce9 --- /dev/null +++ b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1598.bin @@ -0,0 +1 @@ +=w }j̬Z*"Or֍D::&m:= woN.!0(0Ht \ No newline at end of file diff --git a/pkg/testfixtures/btsnoop1/session1_SPS2.1_phone_to_pod.bin b/pkg/testfixtures/btsnoop1/session1_SPS2.1_phone_to_pod.bin new file mode 100644 index 0000000000000000000000000000000000000000..9eccbf28250987467940750510e6134293e7e06e GIT binary patch literal 642 zcmV-|0)73?CAdY88q9>CQuNGn^9<*$A0lYA{fr1NZ$1ZZXMzJ*sxoMCt8D%GBO%6W zfWPjlb?YC<$16rA=o7BYWcU{SIVa*)M%F_G6@!&!wdDJu23Je>v)dYT()B@#hHH$x zz3lW?4YkiEjn{p&FL@+yv*5m&$ zJ8-U^n9Ul(fT_aA=Gu>ob1!W`d*Gs*{&pJ&FVlZDon><(5=5#uV@>=dT>+vmT`M1X zMuxz={!}p4rHU!yjy)5EK%1;$;iXz(k6+ zXq`t8*!r={(L)IIx&%%66U6*=?AKp^JzB*V-M!YDBm@hHxUze%XJJ(cC!pZhFl`)W z;QFSTo{T$J2Yv+}_4AAa%xK#Ys|phylZkDLJJ3krJr^w0f(DogCyNy|FOo%neMS)& z;-sKdRzl}ME+On(eGy$i>GjsCXSs=E*`fC*>mm)}QgoDh1?SW*LzeJocNs~=!CQTK cm}$ZfS!#dXj{@wXKWl~+unKO_#Vd^l+Rz0(1ONa4 literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop1/session1_SPS2.1_pod_to_phone.bin b/pkg/testfixtures/btsnoop1/session1_SPS2.1_pod_to_phone.bin new file mode 100644 index 0000000000000000000000000000000000000000..31d11b75d5ec8df8db5852f3e61263e9fc1fbfc5 GIT binary patch literal 641 zcmV-{0)G7*CBKz7VyIH+P>z*wz_v~4&<#!togbdh?)(N++Wv$@WnFsB3-lYB(rheH zvIZ?I72nYBDIQ*tCVhM9KVV8dB}$IYSk-)VKs&42#xga_-+=t@8LKP9L}6ThdT`z6 z1njc4Rc0(rkNFWxZ(yk51YhTz-r-RABYUH^nkbq}yAh>3KP5ci-b(kh2Lk1RfM$AJ zgV<=#HPs)zYd`?PnF>9A$3-A0SBRZp2`_yqM3ujvk4KAbBc#ezr854aX_(96$*gb! zS{N1J!2nno9Def0d57xXDIY}J#2ASYoG@)yw%UmRDw2I$ySYr8|NhCYZU!Evs9W5{ z%}FwZZVH&C)T){`6gm;ep6Eyl(*VbV+AGrIJfR3{-S`6VuU!ipNYhsY!OyU|h8^am z;~-mq$iqsN6S!N5TCE62ZQnux_VHh=y4(_9Cdg&*sq< zlg z1cE^%rpG5Oip0K;K>R3ogMkR<<11LBLyHGY(U&13K*@vWwZX5InyymH7BC7HV}7W$ zT&-opy&Lypx$KtFGilyoF<6g5T%zM0x3k0Z8myZ?6nazkb7$9Xp)KWnijr^0R2n0i z(FIjz==TY`335&jPFFr8f8v^wFpoKW z&$SIZ?jhE@I5;Db%bduuy~q0CTBI(&%WU|-Ye?B=V1a7Ta{y)TE}~wIXR^`%!}n(L zDg*B)TV}3WYUxc z8GvY^7deYHt(!mkZZMx^q#7~$;7wRcc8rmYt^>|jpOX z8x(_hAfKccn^{Cy56@?4q#(kzXT_!}kSUos97^2n2gIx2fc_Z;a1=&MDSp(C6`X-X zk`x|^{M}n{F`<_=%v=xpF(Rh>ApA8h6Pcbt-TH7qa4Rj4WoapfOR~wqzl~z3Mt0KW zub3hGIqqkoVpf;eflB*L+#|Ag22^P*bs0E4*o`$cp*tm`3<8vu3%dOsBWp33`9VlT z=<>xja1L#5%X(q+Bh)YS4t@kwhy%bNNLg!_)Yd|J78423SZc}0jdAO&UvtJr_TI2L zc%S~i9t6TB?X5kh@!U6r=eE8leo`L9HOCU$XBO;|3?%L?7V(f>!Rd|nWG>Fa)GSGN zZa-K z+U_xE{F<3`s7@B}4fj=|{F|16;Gte0Se5S%hG#y1CG)*Qa+646P@pMnj9DStJQd<0 z+OzpbCk$b%3;1%|z>pp)VRu*A?sFz!ye2DtIDjho`NPyqzRTG6y*I`MTK@5R`ly3i zh)wCq#pLU~9ekxp10sk8{yKHrHo~=-`owe9j4+7RtY?FqUDoylZZH5z#=os$VXk`( zq#~2@Bxb+~Zt4PBQx$9Gp~}uVTI?y!zQ2C{=C|OdMdfOTxfmdNl;}qM*uim#(I8L% zZKDj|4nA+OLRiNEF literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop1/session2_SPS2.1_phone_to_pod.bin b/pkg/testfixtures/btsnoop1/session2_SPS2.1_phone_to_pod.bin new file mode 100644 index 0000000000000000000000000000000000000000..0ef1902e0478039021bd69fd1ecfffa8f2f5bf54 GIT binary patch literal 642 zcmV-|0)71tc;DLJd~CzMN&bg%w$wnwwu{*>M#P_u-9gEuCcQ|&2WW`=MCdV0!p&FU zSX2Pu^tgmP!Bi_DG0Ms9uFU(OGB|3V`I)@dNxLyfG_e2eFX$(v;JTH8OhAS8G#FLFWvEJc7MDjfm0i2Ptv?ci){>; zG|WLz6eWNl9;S%@E1=UUD;0e!k5`IO&!5_NOacIr`%}&r>nsPc>+h2df{rNblYu_r z{>#(FvwSQ=#UrAHed2EZ&0KxC&LW$yR2OC7YfAv%K5R=s2?luh!MMxJo81iyVhlxy zG?8YzS=kO!k^P-PdlMj+56k(Jy;C0Kq|lJ)+8Lg#=`rD#PQd=I>?#`;I~Y^f4f|kZvg(0NZ!7fHr=Nq6vJ$c)5s!(z4VexE7zc+ChBx8L1CmoieZHu$n(70tQ)P)xQ=-?dDI zvH;(yzGPpHK6+GB?DK-)=9l*vnVVz;%0op5HwCXRtPTmSpzmK^VZ67ug57;&wN%1J zm98P#84=0<-bdWBqS;W6vM}S119+xwIn%PNR^#aj^ zBcUtkMNZ`?*X$5ZQT;t+q1HO^!z{0v=dg2;lG-Th*T)q5O=l7uFjZHOoxS4}t^UtkB^dWznF;Vw2Xh+7VK8U<(R(mTVJ#+Kz8DP|kNByZNPe$4}wA ziYCKlx48rUUXKxV`BE`&iEn?^d^jmX7!tFmQ*sX|OQ4bO-8NKgF=FitgUv# zU;rteuAftCNB-#ULYYqyAvf&|Ev(%QtaRjH5_RG5qoBYACR=lF-YZt1T0#VyLqo*U zWq0M%lkPNBHxbF85-%YNs%Lt34}`&Y4vHHb>&jN>GEQpn@7Ak|j# z236fwygvz~+FL)b#vEXHHKalUco3bsF|(r*pRqXP$@qVfebJWl$c5Ss$(;+aLeAc zV3_N{%Q*+9$;2JxuTJV&^c0s!fhT;(hv(xHf6K!(or=UNeQ zcE^SWZ6)D$L7U#&a#DFW83T6E2FIs@P}rp|190QGd%I=WU`B!kip;1mpt52+ue(qi zBz(m+3N~galyLY-l*ftqnIR#BD>sV2SI@8GKvP8UPGCMG%fM6Qv~!`x{oj_y!|{!K basLNLoaLASAT`HY7H+jNR}`dag~G=giy%H+ literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop1/session2_SPS2_phone_to_pod.bin b/pkg/testfixtures/btsnoop1/session2_SPS2_phone_to_pod.bin new file mode 100644 index 0000000000000000000000000000000000000000..58ba57eca6a3f38101e97acd362e33264e0e8420 GIT binary patch literal 953 zcmV;q14jHx-&bIp_UtIR9m;<6ob8-0zts%dg`VI;7fdv3xS~D2Z3I|u%!P3Beq&_j zAPUyfPzT{(m!|vrC6tZ49A4sv{rI__4L5~iou*Z*4&bUbVh!TP+tcPwl)An`n*jqw zl4col_%bDmG-@Q8okCNA?EG`?>HhBMm7V5TuTy*@)ygYS?v4Q8P-)Mz-SzjIdBgW{ zl=h6ak)h#TJ@X5caJF9qXAA*@IUG=8W;Ws9EMr5zE9#sXl}A>cbSEXET$FJ=w}!*9 zP3^Qh)cPEvpy(6gXGb80^aa1z2Llmzm!g+hn?)iYJYBi;xJ?sorS5SX>k@gJZn;@q z=o8^!Lq%dK{s04L%XHYA!Tb6VUwFe>NBHJlhPc~7#M3r*EwPQkBo00Rz96=H$P`3w z#kF|(SDiM)|6F^_Xwx?;k-)Aykq5yd^bXn#a8ox(Ovz5D!Pw5?=a?qP`Q;;-?8o6J zZdJc8I9)rkFwCB_7iv(_auX|*W<($8E21`hQp*~1%<$m-b7A@ttv5Y zT4%F_Ugxvh@9bksMX%~QA!>L*K|VIleOcvqx&0_$g$$OGwQ6*M&4MipyKjtaOPZ8z zVvj?2Um*r)=dND(LfgVMV)?7)! zlTAbbqJk-fr*l~xx)|26f&R=0k(&Dg9}8O6mRKv8-k2U#S_319N)L$ z={K{Qet9FSW&(8FUNvm5v+CL}Adn?^F3ZfzCp%3ZAZKmlT*u=`I_i}V8}gPgEs)eJ zOaD`E-Pv0hkk(QbnHP~#Bt@5OP=1>A8+CzUVvKX=MG<|GlZf)3V>oep!V6<(pSYk0 zaBQ!(BY6u>$gN^(bTOXeD&xOOZsq9D#OK7!*kOHbUgL&yglRUyG@}#3R0tLy2h6i@ zeuxmFc7)0P>ZdDF%fus=MY)bfHyppx|FrRTZi-aY$FwBbp03$@&BR7pulTxSwJrcv zSIwzo`LVaI5lHhqVmP(aV@d$hWK&rv2*S^^)M{i}E3wT5M0gG3n^q-OGR6`Fh^v$s zJUN!2+f;E3A`GNTq|vU_O<*!GcDeV~L3=n`eaT3|XL2>-K&A2fX^5Tx1VjS+zNS^! bCj2N)##^@<q-eFmo~){c-{Y-g}T}Z<^TQ literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop1/session2_SPS2_pod_to_phone.bin b/pkg/testfixtures/btsnoop1/session2_SPS2_pod_to_phone.bin new file mode 100644 index 0000000000000000000000000000000000000000..4fb28795244ebef7f165ef43ee0d87dca6474fba GIT binary patch literal 895 zcmV-_1AzQJDZdElH=rl5Y1F0`7s@yxy0A|O&8^XMkuWzw|Bh*Ku4dPXKSg@dcIVRn zs8Ys~sj_*WD~*GbD}V>rxJ1D~T!3{fQV$Jq3IfqY3Fa$*wZ!hEQKrX9jl5_HB2gF( z>0ajz%(riDTBF+hm7fZavU_FO%HEOsMGD{+akcc1MA_0IqD0Ts5yrO}`arAqrC_0x zjaP>IR;9Nz=d(+W^DZ1zQqbEQXHTON~dEcB3tj*gI;`B z8($|4ViazY^T0cE1W_-lk8u=bdD`=kxFN?Xg4n@V==BhVBlJ7BlW0>E6PqNkIW|>L z)R7Ll^g{ZL8qK|J6mGiIk2~zDvO1Nwj)uZA4*i_FX%A$^2wLSD77kA=y8arD4Opt& z>P21o1RjK9Y|1Yw?W4f2h_lEd9d72 zqr>;&?_pMWlhLZ^n@KYy(K zIZ+*4Oi`3M`(#L=fha0z=vr>1Vi#^<;Lh>pU}HJ6e1ZcbiD->*Y{MdLSKCL1oC%I# zKPHEdp5U*i?;6^7`(r@lDG@OfTGjV|w#OnejrRHxZ>KaAu8oiHgcXkg5@M?$W;eT8 zg|NVi$!q^?PiB!ovu%Qca04suC90GiE8Bc4)V&Zxzv9;1lXZ1YURR!@VYBVWuds@n V+3v-(9oD&YSQV+G$o;IUR?xn>yh;E7 literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop2/SPS1_src__dst__1694.bin b/pkg/testfixtures/btsnoop2/SPS1_src__dst__1694.bin new file mode 100644 index 0000000..b60e9fc --- /dev/null +++ b/pkg/testfixtures/btsnoop2/SPS1_src__dst__1694.bin @@ -0,0 +1,3 @@ +ťJY v&GwEMn|> Mɾ +cV" +A7؜Ep T \ No newline at end of file diff --git a/pkg/testfixtures/btsnoop2/SPS1_src__dst__1701.bin b/pkg/testfixtures/btsnoop2/SPS1_src__dst__1701.bin new file mode 100644 index 0000000..b37e4eb --- /dev/null +++ b/pkg/testfixtures/btsnoop2/SPS1_src__dst__1701.bin @@ -0,0 +1,2 @@ +,C"V^ >6tMq2}RcPB1>)ީN s +'h". ;Pw5 \ No newline at end of file diff --git a/pkg/testfixtures/btsnoop2/SPS2.1_phone_to_pod.bin b/pkg/testfixtures/btsnoop2/SPS2.1_phone_to_pod.bin new file mode 100644 index 0000000000000000000000000000000000000000..fb8c93ae2a657ab0def088a56624c04174929b4f GIT binary patch literal 642 zcmV-|0)73@>(<2ba4yRQUC^cL9Z0xAx+gz`3XWKpTCh-AJ#-%ktf8R~+(*Lb-a5l6 zjIL~3uGnb@iVf+Gw~PhVP7W#z1h2Bt#EpdNh#YCLdG;5{_lVE1-15-~nK^>@eL+C0Z|Eh|3JF1cdg&~t2+u0c=3rKTt_w;aH-ye4sn5f^@Xvfx2%vE?HjZy z7Imi7;Oalj`8;+s*O0ZB;@%k%Tz;E1y0i=S&s}i$+v$ zXG-Kgo<{B9hQAik6A=|@Jdj~qGW@lln=BrKLvcomIdbTguhr74CvT7*BjV>NOb1pz zhCFVhRo{t$-oCGTb4+1?BZjuNXt@*`P&GEqe*Dn)n)wM@GHM2m|0uMW#E!QFxFoRDj8kA#~-m85N|q z{Lj^%JGPtqc-9WJ1Zu{a+3zj^Ir4p`b;7P+yVl&IxC@hdiVLDvoacy9^ju7{tafhP zbeGRbr#Tm5`)v<#6id>n4=V;4aGAs3c(mGM(czw}*>kgzrv%X1(wbLDL8w^9`Lb^w zv01Totu}3QJFt1-mA=U?;{ZZzlB!%N_$fn%^m`u~qwuu-lDy!?9eHnG(w3N-l6qGK c1%ZNWSsC0 z>Pj7YAF$ChljJu_^!65V1|(PBTYx0@V5<(<;w_UV!FYgSNSkQjrL`-Re;Cz^6&oAS zIA%o^$YTKk*2AVN;vim5Hiui+XA%dJeB^f6^P?b{A9hwizB4>7LJ;ARzZ{ zune>)mVDRdF;0gmz6oh4P7ZTO2Svy1D1CoZnIH)pcG##%(q+vCDDLn>Tw%V4h%Xnh zqvA%lsCk$w%&^U)l_Et`_w@VEHlY3TC zXMug~pbn|EkFuHL{>hpgF?Pl__{Pb*Qp%6tp$wS9pjC&{A^`ESOFKU-?YGdj)Nlx< biuNdEdloL0!hO0Q4D-tDCY5jA669}c4H7kw literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop2/SPS2_phone_to_pod.bin b/pkg/testfixtures/btsnoop2/SPS2_phone_to_pod.bin new file mode 100644 index 0000000000000000000000000000000000000000..8951fc9bef273d96994a647fc38a7a4868f039c0 GIT binary patch literal 952 zcmV;p14sOs#GW-*L-|!z2%~=M1vsAIwfRC^5!IIH-B^H&uYSdvm1`nDgDE@;EsILg zzs5Ay=jvqsNY;y`?T|J5C0{8on}rLA*7i4Ce*HR&xf5oyjj?K)az1U>fZtR*>Ak@h zUV&~6w~lP5_D>Rjcb`g^!`SdYE|%<$q9IK5oR}M=y-Vdmqs!q6(*nXT@sm*GUMc1* zEY#5%agsI1`WPC9&5vTO^V}Q771tQhgb^lF(1kR}T|F_m^jM6gH1gQFVR=oXy!JOD?VnT{kBVZlq00vhN<*xWhouCHa@>fTwDX_FPKk1# zcWki`58@`{G$5H-U3`Xk=RV917%jg#hVNRu44!5ti$ zW|D<>YgT7_4p&oCuEXM6tNe1@%#IUyRn&b%E7%`Nav3K)4+P#s&>Bjz<39<-YO=*K zb70_QA+39 zJwIf7Ir&H{4nX-Il8y(!U5rtgwL6CPi}8eE#`W6eS&X|3e6{jGD4@%H46GopaRuVO zB(Ip(Waub-25a`ZpL!68T#@5JSfAS+nCJu=>F`#$o~8^b1G+^9!r5Q73=@e%A_mtG+#Ts(3I*abdE{0@F*>1r{r2;>0Ajg;3KzVee z3(kpjLTDZy{oRe-1vTb`pt)n0{HC0vd%1zt;_W+vG3lVSiQDnB*;Xf(cOfz! z=H!&gKd7S0tEd%)Z@#(;+}`xMMHGPV=olW@U#L?uCaoa{WUr^Ze*$TDkhD!@Z^{@$ aN>CXNs?9hMj2JfvI8XWl3!y6n-8OP>0n%~+ literal 0 HcmV?d00001 diff --git a/pkg/testfixtures/btsnoop2/SPS2_pod_to_phone.bin b/pkg/testfixtures/btsnoop2/SPS2_pod_to_phone.bin new file mode 100644 index 0000000000000000000000000000000000000000..e1c8760915d70182acf9e011581d0afca9de528a GIT binary patch literal 895 zcmV-_1AzR-^SjcSyTitzm>e81)~XbgUBz2rXc|DHyQ>P+Evhusm)n1aq>~Dtz0=nK z;T_Np>4w9>FyO!>L4<(kiL7JoGIM3O&QDyvXrEQG<^XCL4^Wy((SXJGZCpX$hQ#^x zu9e|$e~H+LboJ?Dn8w-nmXa3XDG(uvC6o;sw3%%)u}fClW;;Y6a4qK#rV%h*%CS`% z>;PgRj@C_>Prbku)QANILMqmCGBSZl1#d>*FC1d^D51br;rnsl_4!DKf zUZ(Ze+#Ro=eU(}`70ESccktcmu`t|H2!&1GZ-Gij@tRW3=eq0C4=jw?ApYw1(Ku$l zUS1uDJ00*8_L|m3X^A6{umSe{gOoRNQP>bxn zXns*T1=DhOH37{t0H2@m)6L<(L~qE;Z6;npFzLoUpg)1Rn#5$RA{ItaiC}##?X*Pk z9t;s^yj7Yg3$_s`%&aB?@+G}TTGQ@!MFdE?wvPh zQpAJg){PG47*|6m<=JXM589chdT;=axEvEOz&B^Pe5NMyC06UH%`1JqEnQ17B3V>- z-LXjGc>;JO%o3NbR#&%wwUKgj|0MpcXM!K$-S-|pPeP)vTji59FdPFnL#_SY zS-++U^t_T=%d~<`o+1|jpqyRr6pS{%6C5hQG@^&1^Q>(l$W9JnVL?2i^|=ipwCoun z=@KMtg+n|NX#;&WOH)Tvn9PX%Oe9x~{Rz83t&QZ_gBXT=zFe+-+-q6x;@%Dg91<2) z^}aeP<=sH0FS9H3atCW57&B~WzCS4mjM$jy*F0;YYb-hJshSD73Y~j$5lC2_G?ig; z1|Dym#`{6p#_n%}!s+4n;+eWqY5-spu`m27r@B0dW{M(HElgTOkb`xAAqe@IBC|UW znE(idB3$NVg~L^K3HW(3j;t6DqY*34e?N9u(}f*-y8pod is 634 cert + 8 tag = 642 + if len(c.PDMSPS21Ciphertext) != 642 { + t.Errorf("%s: PDM SPS2.1 expected 642 bytes, got %d", c.Name, len(c.PDMSPS21Ciphertext)) + } + // SPS2.1 pod->phone is 633 cert + 8 tag = 641 + if len(c.PodSPS21Ciphertext) != 641 { + t.Errorf("%s: pod SPS2.1 expected 641 bytes, got %d", c.Name, len(c.PodSPS21Ciphertext)) + } + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index bd57f97..08eb44a 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -24,6 +24,7 @@ github.com/jacobsa/crypto/common # github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb ## explicit # github.com/konsorten/go-windows-terminal-sequences v1.0.3 +## explicit github.com/konsorten/go-windows-terminal-sequences # github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e ## explicit @@ -41,6 +42,7 @@ github.com/paypal/gatt/xpc ## explicit github.com/pelletier/go-toml # github.com/pkg/errors v0.9.1 +## explicit github.com/pkg/errors # github.com/pschlump/AesCCM v0.0.0-20160925022350-c5df73b5834e ## explicit From ab51b7e15d5737a96c2ccfecbbc5cb0342f7c282 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:21:44 +0000 Subject: [PATCH 03/18] message,packet: add packet subpackage and fix TWi length decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pkg/bluetooth/packet — a platform-neutral implementation of the OmnipodKit BLE packet split/join wire format. Split() emits properly framed fragments (single-packet, first/middle/last/optional+1) and Join() reassembles them using each fragment's actual length, so smaller negotiated MTUs (down to ~30 bytes) work without code changes. Tests cover the round-trip, CRC tamper detection, index mismatch rejection, the exact-fit boundary, and shorter fragments. Fix the TWi length-decode bug in pkg/message: `data[6]<<3 | data[7]>>5` was a uint8 operation, silently truncating any payload longer than 255 bytes (an SPS2.1 frame at 651 bytes decoded as 139). Cast both halves to uint16 before shifting; update the three slice expressions to take int(n) so the type widens cleanly. New length_test.go pins this at SPS2.1 size and at the 11-bit field maximum (2047 bytes). Add MessageTypeEncryptedSigned = 4 constant so later commits don't re-touch this file just to declare it. Full Type-4 Marshal/Unmarshal plumbing arrives in a later commit. bluetooth.go still uses its existing I/O paths; the swap to packet.Split /Join happens in the next commit. Source: jwoglom/five commits a472229, a9bdabd, 0be4858 (TWi fix). --- pkg/bluetooth/packet/packet.go | 276 ++++++++++++++++++++++++++++ pkg/bluetooth/packet/packet_test.go | 208 +++++++++++++++++++++ pkg/message/length_test.go | 62 +++++++ pkg/message/message.go | 13 +- 4 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 pkg/bluetooth/packet/packet.go create mode 100644 pkg/bluetooth/packet/packet_test.go create mode 100644 pkg/message/length_test.go diff --git a/pkg/bluetooth/packet/packet.go b/pkg/bluetooth/packet/packet.go new file mode 100644 index 0000000..b436050 --- /dev/null +++ b/pkg/bluetooth/packet/packet.go @@ -0,0 +1,276 @@ +// Package packet implements the BLE packet split/join used by the Omnipod 5 +// (and Dash) BLE protocol. Sources of truth: +// - OmnipodKit/Bluetooth/Packet/PayloadSplitter.swift +// - OmnipodKit/Bluetooth/Packet/PayloadJoiner.swift +// - OmnipodKit/Bluetooth/Packet/BLEPacket.swift +// - OmnipodKit/Bluetooth/BlePodProfile.swift +// +// Lives in its own subpackage so the pure split/join logic can be unit-tested +// on any host (the parent bluetooth package imports paypal/gatt which is +// Linux-only). +package packet + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + + log "github.com/sirupsen/logrus" +) + +// BLE packet layout used by Omnipod 5 (and Dash, with a different MTU). +// Header sizes are identical between Dash and O5; only MaxPayloadSize differs +// (20 for Dash, 244 for O5). +const ( + MaxPayloadSize = 244 + FirstPacketHeaderWithoutMiddle = 7 // idx(1) + fullFragments(1) + crc32(4) + size(1) + FirstPacketHeaderWithMiddle = 2 // idx(1) + fullFragments(1) + MiddlePacketHeader = 1 // idx(1) + LastPacketHeader = 6 // idx(1) + size(1) + crc32(4) + LastOptionalPlusOnePacketHeader = 2 // idx(1) + size(1) +) + +func firstPacketCapacityWithoutMiddle() int { return MaxPayloadSize - FirstPacketHeaderWithoutMiddle } +func firstPacketCapacityWithMiddle() int { return MaxPayloadSize - FirstPacketHeaderWithMiddle } +func middlePacketCapacity() int { return MaxPayloadSize - MiddlePacketHeader } +func lastPacketCapacity() int { return MaxPayloadSize - LastPacketHeader } + +// Split fragments a Marshal'd TWi message into the BLE packets the O5 +// protocol expects. The wire layout matches OmnipodKit's PayloadSplitter. +func Split(payload []byte) [][]byte { + first := firstPacketCapacityWithMiddle() + if len(payload) <= firstPacketCapacityWithoutMiddle() { + return splitOne(payload) + } + mid := middlePacketCapacity() + last := lastPacketCapacity() + + middleFragments := (len(payload) - first) / mid + rest := byte(len(payload) - middleFragments*mid - first) + + sum := crc32.ChecksumIEEE(payload) + + out := make([][]byte, 0, middleFragments+3) + + // First fragment: [0][fullFragments][payload[0..242]] + { + buf := make([]byte, 0, MaxPayloadSize) + buf = append(buf, 0) + buf = append(buf, byte(middleFragments+1)) + buf = append(buf, payload[:first]...) + out = append(out, buf) + } + + // Middle fragments: [idx][payload[..243]] + for i := 1; i <= middleFragments; i++ { + start := first + (i-1)*mid + end := start + mid + buf := make([]byte, 0, MaxPayloadSize) + buf = append(buf, byte(i)) + buf = append(buf, payload[start:end]...) + out = append(out, buf) + } + + // Last fragment: [idx][size][crc32(4)][payload (up to 238B)] [zero pad to MTU] + lastIdx := byte(middleFragments + 1) + lastStart := first + middleFragments*mid + endInLast := int(rest) + if endInLast > last { + endInLast = last + } + { + buf := make([]byte, 0, MaxPayloadSize) + buf = append(buf, lastIdx) + buf = append(buf, rest) + var crcBytes [4]byte + binary.BigEndian.PutUint32(crcBytes[:], sum) + buf = append(buf, crcBytes[:]...) + buf = append(buf, payload[lastStart:lastStart+endInLast]...) + // Zero-pad to MaxPayloadSize so the wire write matches MTU exactly, + // matching OmnipodKit's LastBlePacket.toData (it pads the difference). + if pad := MaxPayloadSize - len(buf); pad > 0 { + buf = append(buf, make([]byte, pad)...) + } + out = append(out, buf) + } + + // Optional last+1: [idx+1][size][payload tail] [zero pad] + if int(rest) > last { + extraSize := byte(int(rest) - last) + buf := make([]byte, 0, MaxPayloadSize) + buf = append(buf, lastIdx+1) + buf = append(buf, extraSize) + extraStart := lastStart + last + buf = append(buf, payload[extraStart:]...) + if pad := MaxPayloadSize - len(buf); pad > 0 { + buf = append(buf, make([]byte, pad)...) + } + out = append(out, buf) + } + + return out +} + +// splitPayloadOne builds the single-packet wire form (and an optional +// extra-packet continuation when the payload is small but doesn't fit in +// the first packet's capacity). Mirrors PayloadSplitter.splitInOnePacket. +func splitOne(payload []byte) [][]byte { + cap := firstPacketCapacityWithoutMiddle() + end := len(payload) + if end > cap { + end = cap + } + sum := crc32.ChecksumIEEE(payload) + + // First packet: [0][0][crc32(4)][size(1)][payload[0..end]] [zero pad] + first := make([]byte, 0, MaxPayloadSize) + first = append(first, 0) + first = append(first, 0) // fullFragments == 0 means single-packet + var crcBytes [4]byte + binary.BigEndian.PutUint32(crcBytes[:], sum) + first = append(first, crcBytes[:]...) + first = append(first, byte(len(payload))) + first = append(first, payload[:end]...) + if pad := MaxPayloadSize - len(first); pad > 0 { + first = append(first, make([]byte, pad)...) + } + + out := [][]byte{first} + + if len(payload) > cap { + // LastOptionalPlusOne: [1][size of tail][payload tail] [zero pad] + extraSize := byte(len(payload) - cap) + buf := make([]byte, 0, MaxPayloadSize) + buf = append(buf, 1) + buf = append(buf, extraSize) + buf = append(buf, payload[cap:]...) + if pad := MaxPayloadSize - len(buf); pad > 0 { + buf = append(buf, make([]byte, pad)...) + } + out = append(out, buf) + } + + return out +} + +// joinPackets reassembles a complete TWi message from BLE fragments using +// the OmnipodKit on-wire format. `first` is the initial fragment already +// pulled off the data channel; `nextFragment` is called to read each +// subsequent fragment. +func Join(first []byte, nextFragment func() ([]byte, error)) ([]byte, error) { + if len(first) < FirstPacketHeaderWithMiddle { + return nil, fmt.Errorf("first fragment too short: %d bytes", len(first)) + } + if first[0] != 0 { + return nil, fmt.Errorf("first fragment idx %d, want 0", first[0]) + } + fullFragments := int(first[1]) + + var buf bytes.Buffer + var crc []byte + var oneExtraBytes int + + if fullFragments == 0 { + // Single-packet form + if len(first) < FirstPacketHeaderWithoutMiddle { + return nil, fmt.Errorf("single-packet first fragment too short: %d", len(first)) + } + crc = append(crc, first[2:6]...) + size := int(first[6]) + cap := firstPacketCapacityWithoutMiddle() + take := size + if take > cap { + take = cap + oneExtraBytes = size - cap + } + end := FirstPacketHeaderWithoutMiddle + take + if end > len(first) { + return nil, fmt.Errorf("single-packet first fragment truncated: need %d, have %d", end, len(first)) + } + buf.Write(first[FirstPacketHeaderWithoutMiddle:end]) + } else { + // Multi-packet first: copy from offset 2 up to MTU. + end := MaxPayloadSize + if end > len(first) { + end = len(first) + } + buf.Write(first[FirstPacketHeaderWithMiddle:end]) + } + + expectedIdx := 1 + for i := 1; i <= fullFragments; i++ { + pkt, err := nextFragment() + if err != nil { + return nil, fmt.Errorf("fragment %d read: %w", i, err) + } + if len(pkt) < 1 { + return nil, fmt.Errorf("fragment %d empty", i) + } + if int(pkt[0]) != expectedIdx { + log.Warnf("pkg bluetooth; fragment idx mismatch: got %d, want %d", pkt[0], expectedIdx) + return nil, fmt.Errorf("fragment idx mismatch") + } + if i == fullFragments { + // Last packet + if len(pkt) < LastPacketHeader { + return nil, fmt.Errorf("last fragment too short: %d", len(pkt)) + } + size := int(pkt[1]) + crc = append(crc[:0], pkt[2:6]...) + cap := lastPacketCapacity() + take := size + if take > cap { + take = cap + oneExtraBytes = size - cap + } + end := LastPacketHeader + take + if end > len(pkt) { + return nil, fmt.Errorf("last fragment truncated: need %d, have %d", end, len(pkt)) + } + buf.Write(pkt[LastPacketHeader:end]) + } else { + // Middle packet + end := MaxPayloadSize + if end > len(pkt) { + end = len(pkt) + } + buf.Write(pkt[MiddlePacketHeader:end]) + } + expectedIdx++ + } + + if oneExtraBytes > 0 { + pkt, err := nextFragment() + if err != nil { + return nil, fmt.Errorf("optional extra fragment read: %w", err) + } + if len(pkt) < LastOptionalPlusOnePacketHeader { + return nil, fmt.Errorf("extra fragment too short: %d", len(pkt)) + } + if int(pkt[0]) != expectedIdx { + return nil, fmt.Errorf("extra fragment idx mismatch: got %d want %d", pkt[0], expectedIdx) + } + size := int(pkt[1]) + if size != oneExtraBytes { + log.Warnf("pkg bluetooth; extra fragment size %d != expected %d", size, oneExtraBytes) + } + end := LastOptionalPlusOnePacketHeader + size + if end > len(pkt) { + return nil, fmt.Errorf("extra fragment truncated: need %d, have %d", end, len(pkt)) + } + buf.Write(pkt[LastOptionalPlusOnePacketHeader:end]) + } + + out := buf.Bytes() + sum := crc32.ChecksumIEEE(out) + if crc == nil { + return nil, errors.New("no CRC found in packets") + } + if got := binary.BigEndian.Uint32(crc); got != sum { + log.Warnf("pkg bluetooth; CRC mismatch: header=%x computed=%x reassembled=%d bytes", got, sum, len(out)) + return nil, errors.New("crc mismatch") + } + return out, nil +} diff --git a/pkg/bluetooth/packet/packet_test.go b/pkg/bluetooth/packet/packet_test.go new file mode 100644 index 0000000..2c590f8 --- /dev/null +++ b/pkg/bluetooth/packet/packet_test.go @@ -0,0 +1,208 @@ +package packet + +import ( + "bytes" + "testing" +) + +// TestPacketRoundTrip exercises split→join across the size regimes that +// exercise different code paths: a tiny payload (single packet), a payload +// that just barely overflows a single packet (single + LastOptionalPlusOne), +// a multi-fragment payload that fits cleanly in last, and a large payload +// that needs the LastOptionalPlusOne tail. +func TestPacketRoundTrip(t *testing.T) { + cases := []struct { + name string + size int + }{ + {"tiny", 16}, + {"single-cap", firstPacketCapacityWithoutMiddle()}, + {"single+extra", firstPacketCapacityWithoutMiddle() + 5}, + {"two-frag", firstPacketCapacityWithMiddle() + 50}, + {"three-frag-clean", firstPacketCapacityWithMiddle() + middlePacketCapacity() + 50}, + {"three-frag-extra", firstPacketCapacityWithMiddle() + middlePacketCapacity() + lastPacketCapacity() + 17}, + {"sps2.1-sized", 666}, + {"sps2-sized", 920}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload := make([]byte, tc.size) + for i := range payload { + payload[i] = byte(i) + } + packets := Split(payload) + if len(packets) == 0 { + t.Fatal("splitPayload returned no packets") + } + for _, p := range packets { + if len(p) > MaxPayloadSize { + t.Errorf("packet larger than MTU: %d", len(p)) + } + } + + i := 0 + out, err := Join(packets[0], func() ([]byte, error) { + i++ + if i >= len(packets) { + t.Fatalf("joiner asked for fragment %d but only %d available", i, len(packets)) + } + return packets[i], nil + }) + if err != nil { + t.Fatalf("joinPackets: %v", err) + } + if !bytes.Equal(out, payload) { + t.Errorf("round-trip mismatch: got %d bytes, want %d", len(out), len(payload)) + } + }) + } +} + +// TestPacketCRCDetectsTamper confirms the joiner rejects a flipped byte. +func TestPacketCRCDetectsTamper(t *testing.T) { + payload := make([]byte, 500) + for i := range payload { + payload[i] = byte(i) + } + packets := Split(payload) + // Flip a byte in the middle of fragment 0's payload area (after the + // 2-byte header, before any structural byte the joiner reads). + packets[0][50] ^= 0xff + + i := 0 + _, err := Join(packets[0], func() ([]byte, error) { + i++ + return packets[i], nil + }) + if err == nil { + t.Fatal("expected CRC mismatch error after tamper") + } +} + +// TestJoinIndexMismatch flips a fragment's index byte and asserts the joiner +// rejects the reassembly rather than silently producing corrupt output. +func TestJoinIndexMismatch(t *testing.T) { + payload := make([]byte, 500) // forces 3 fragments + for i := range payload { + payload[i] = byte(i) + } + packets := Split(payload) + if len(packets) < 2 { + t.Fatalf("expected ≥2 packets, got %d", len(packets)) + } + // Corrupt the first non-first fragment's idx (was 1, claim 99). + packets[1][0] = 99 + + i := 0 + _, err := Join(packets[0], func() ([]byte, error) { + i++ + return packets[i], nil + }) + if err == nil { + t.Fatal("expected error from out-of-order fragment idx") + } +} + +// TestSplitBoundaryLastFitsExactly: payload size that lands the rest exactly +// at lastPacketCapacity. Confirms no LastOptionalPlusOnePacket is emitted +// at the boundary (off-by-one risk). +func TestSplitBoundaryLastFitsExactly(t *testing.T) { + // firstCap (242) + middleCap*1 (243) + lastCap (238) = 723. + size := firstPacketCapacityWithMiddle() + middlePacketCapacity() + lastPacketCapacity() + payload := make([]byte, size) + for i := range payload { + payload[i] = byte(i) + } + packets := Split(payload) + if len(packets) != 3 { + t.Fatalf("expected exactly 3 packets at boundary, got %d", len(packets)) + } + // rest byte in the last packet should equal lastPacketCapacity exactly, + // not size - lastPacketCapacity (which would be 0 and trigger oneExtra). + if int(packets[2][1]) != lastPacketCapacity() { + t.Errorf("last packet size byte = %d, want %d", packets[2][1], lastPacketCapacity()) + } + + i := 0 + out, err := Join(packets[0], func() ([]byte, error) { + i++ + return packets[i], nil + }) + if err != nil { + t.Fatalf("Join: %v", err) + } + if !bytes.Equal(out, payload) { + t.Errorf("round-trip mismatch at boundary") + } +} + +// TestJoinAcceptsShorterFragments: the joiner bounds reads by len(pkt), not +// by MaxPayloadSize, so a smaller-than-244 negotiated MTU still reassembles +// correctly. Hand-built fragments at a 30-byte fragment size simulate that. +func TestJoinAcceptsShorterFragments(t *testing.T) { + // Build a 100-byte payload split into 30-byte fragments by hand. + // Layout chosen to mirror what a sender at MTU=30 would emit: + // first capacity (with middle) = 28 + // middle capacity = 29 + // last capacity = 24 + // middleFragments = (100-28)/29 = 2, rest = 100 - 28 - 2*29 = 14. + // 14 ≤ lastCapacity, so no LastOptionalPlusOnePacket. + payload := make([]byte, 100) + for i := range payload { + payload[i] = byte(i + 1) + } + + first := []byte{0, 3} // idx=0, fullFragments=3 + first = append(first, payload[0:28]...) + + mid1 := []byte{1} + mid1 = append(mid1, payload[28:57]...) + + mid2 := []byte{2} + mid2 = append(mid2, payload[57:86]...) + + // crc of full payload, big-endian + last := []byte{3, 14} + crc := crc32IEEE(payload) + last = append(last, byte(crc>>24), byte(crc>>16), byte(crc>>8), byte(crc)) + last = append(last, payload[86:100]...) + + frags := [][]byte{first, mid1, mid2, last} + for i, f := range frags { + if len(f) > 30 { + t.Fatalf("fragment %d %d bytes — too big for simulated MTU", i, len(f)) + } + } + + i := 0 + out, err := Join(frags[0], func() ([]byte, error) { + i++ + return frags[i], nil + }) + if err != nil { + t.Fatalf("Join with short fragments: %v", err) + } + if !bytes.Equal(out, payload) { + t.Errorf("round-trip mismatch: got %d bytes %x, want %d bytes %x", len(out), out[:8], len(payload), payload[:8]) + } +} + +// crc32IEEE — local helper so the test doesn't reach into the production +// crc32 import already used by packet.go (keeps test imports minimal). +func crc32IEEE(b []byte) uint32 { + // Direct standard-lib polynomial 0xedb88320, big-endian Init=0xffffffff, + // XorOut=0xffffffff. Identical to hash/crc32.ChecksumIEEE. + const poly = 0xedb88320 + c := ^uint32(0) + for _, x := range b { + c ^= uint32(x) + for i := 0; i < 8; i++ { + if c&1 != 0 { + c = (c >> 1) ^ poly + } else { + c >>= 1 + } + } + } + return ^c +} diff --git a/pkg/message/length_test.go b/pkg/message/length_test.go new file mode 100644 index 0000000..b39fd90 --- /dev/null +++ b/pkg/message/length_test.go @@ -0,0 +1,62 @@ +package message + +import ( + "bytes" + "testing" +) + +// TestUnmarshalLargePayloadLength reproduces the SPS2.1-sized 11-bit length +// encoding bug: with a payload of 651 bytes, the TWi length-field bytes are +// 0x51 0x60. The original Unmarshal computed `data[6]<<3 | data[7]>>5` in +// uint8 space, which truncates 0x51<<3 to 0x88 and yields 139 instead of 651. +// This test pins the cast-to-uint16 fix. +func TestUnmarshalLargePayloadLength(t *testing.T) { + const wantLen = 651 + src := []byte{0xff, 0xff, 0xff, 0xfe} + dst := []byte{0x00, 0x29, 0x21, 0xf0} + + m := &Message{ + Type: MessageTypePairing, + Source: src, + Destination: dst, + Payload: bytes.Repeat([]byte{0xCC}, wantLen), + } + wire, err := m.Marshal() + if err != nil { + t.Fatal(err) + } + + got, err := Unmarshal(wire) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(got.Payload) != wantLen { + t.Errorf("payload len = %d, want %d (uint8 shift overflow regression?)", len(got.Payload), wantLen) + } + if !bytes.Equal(got.Payload, m.Payload) { + t.Error("payload bytes mismatch") + } +} + +// TestUnmarshalMaxPayloadLength tests the upper end of the 11-bit length +// field (2047 bytes). Marshal/Unmarshal must round-trip cleanly. +func TestUnmarshalMaxPayloadLength(t *testing.T) { + const wantLen = 2047 + m := &Message{ + Type: MessageTypePairing, + Source: []byte{1, 2, 3, 4}, + Destination: []byte{5, 6, 7, 8}, + Payload: bytes.Repeat([]byte{0xAB}, wantLen), + } + wire, err := m.Marshal() + if err != nil { + t.Fatal(err) + } + got, err := Unmarshal(wire) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(got.Payload) != wantLen { + t.Errorf("payload len = %d, want %d", len(got.Payload), wantLen) + } +} diff --git a/pkg/message/message.go b/pkg/message/message.go index 7dc4b0b..be1009b 100644 --- a/pkg/message/message.go +++ b/pkg/message/message.go @@ -14,7 +14,8 @@ const ( MessageTypeEncrypted MessageTypeSessionEstablishment MessageTypePairing - MagicPattern = "TW" + MessageTypeEncryptedSigned MessageType = 4 + MagicPattern = "TW" ) // Message is one CTwiPacket @@ -151,7 +152,11 @@ func Unmarshal(data []byte) (*Message, error) { } ret.SequenceNumber = data[4] ret.AckNumber = data[5] - var n = data[6]<<3 | data[7]>>5 + // 11-bit length: high 8 bits in data[6], low 3 bits in data[7][7:5]. + // Cast to uint16 BEFORE shifting — `data[6]<<3` is a uint8 op in Go and + // silently truncates the high bits, so any payload >255 (e.g. SPS2.1 at + // 651) decoded as a wraparound value (0x51<<3 → 0x88, not 0x288). + n := uint16(data[6])<<3 | uint16(data[7])>>5 if int(n) > len(data)-16 { spew.Dump(ret) return nil, fmt.Errorf("received length is too big in %x. Length:%d . remaining: %d", data, n, len(data)-16) @@ -159,10 +164,10 @@ func Unmarshal(data []byte) (*Message, error) { ret.Source = data[8:12] ret.Destination = data[12:16] if ret.Type == MessageTypeEncrypted { - ret.Payload = data[16 : 16+n+8] + ret.Payload = data[16 : 16+int(n)+8] ret.EncryptedPayload = true } else { - ret.Payload = data[16 : 16+n] + ret.Payload = data[16 : 16+int(n)] } return ret, nil } From b6587eca1c96cc71f96b8078f4634a921807921b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:32:19 +0000 Subject: [PATCH 04/18] bluetooth: rewrite BLE packet I/O and advertising for Omnipod 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer the Omnipod 5 BLE shape onto current main's bluetooth.go without regressing main's transport fixes from 79be48c. Three things change: 1. Advertising / GATT services (from five 9ea07e3): - Co-advertise ECF301E2-... alongside CE1F923D-... so OmnipodKit's scanner discovers the simulator. - Heartbeat service 7DED7A6C-... with notify characteristic 7DED7A6D-..., backed by a 10-second keep-alive goroutine that fires a one-byte notification once a central subscribes. - GATT data characteristic UUID corrected to canonical OmnipodKit form (1A7E2443-...). 2. Packet split/join I/O (from five a472229): the loop now dispatches to three channels — outgoing messages go through writeMessageData (using packet.Split), inbound data fragments go through readMessageData (using packet.Join driven by a closure that pulls subsequent fragments from b.ReadData()), and the legacy cmdInput path is retained for the existing fragmentation handshake. 3. cmdActivation channel scaffolding: the channel is declared and allocated, but ReadCmd still reads from cmdInput exactly like main. The HELLO-vs-RTS routing fix (76fd556) that feeds cmdActivation arrives in Commit 9; the channel exists now so that commit doesn't have to touch the struct definition. What was deliberately preserved from main (79be48c et al.): - writeMessage's integer arithmetic for large status responses (fullFragments / rest / index as int with byte() conversions) — five silently regressed this back to byte arithmetic. - StopMessageLoop call in CentralConnected (main's 97ce95d revert; five branched before that). Deviation forced by environment: AdvertiseNameServicesMfgData is not present in the vendored paypal/gatt on this branch (five carries a vendor patch we are not pulling in). Falls back to AdvertiseNameAndServices in both initial advertise and refresh paths; the manufacturing-data payload is therefore not advertised. Source: jwoglom/five commits 9ea07e3 (advertising/heartbeat), a472229 (packet split/join), plus partial 76fd556 (channel scaffolding only). --- pkg/bluetooth/bluetooth.go | 252 +++++++++++++++++++++++++++++-------- 1 file changed, 201 insertions(+), 51 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index bfda998..d527ffb 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -7,9 +7,11 @@ import ( "errors" "fmt" "hash/crc32" + "strings" "sync" "time" + "github.com/avereha/pod/pkg/bluetooth/packet" "github.com/avereha/pod/pkg/message" "github.com/davecgh/go-spew/spew" "github.com/paypal/gatt" @@ -29,10 +31,20 @@ var ( ) type Ble struct { - dataInput chan Packet - cmdInput chan Packet - dataOutput chan Packet - cmdOutput chan Packet + dataInput chan Packet + cmdInput chan Packet + // cmdActivation receives pairing-state command bytes (HELLO 0x06, + // PAIR_STATUS 0x08, INCORRECT 0x09 — anything that isn't the RTS/CTS/ + // SUCCESS/NACK fragmentation control set). ReadCmd() drains this so + // StartActivation gets first crack at the HELLO byte without racing + // the message loop's cmdInput consumer. + // + // NOTE (Commit 3b): the channel and the ReadCmd() reader are wired + // here, but the CMD-char write handler does NOT yet route into it. + // The dispatcher fix lands in a later commit (76fd556 / Commit 9). + cmdActivation chan Packet + dataOutput chan Packet + cmdOutput chan Packet messageInput chan *message.Message messageOutput chan *message.Message @@ -46,6 +58,9 @@ type Ble struct { dataNotifier gatt.Notifier dataNotifierMtx sync.Mutex + + heartbeatNotifier gatt.Notifier + heartbeatNotifierMtx sync.Mutex } var DefaultServerOptions = []gatt.Option{ @@ -67,6 +82,7 @@ func New(adapterID string, podId []byte) (*Ble, error) { b := &Ble{ dataInput: make(chan Packet, 5), cmdInput: make(chan Packet, 5), + cmdActivation: make(chan Packet, 5), dataOutput: make(chan Packet, 5), cmdOutput: make(chan Packet, 5), messageInput: make(chan *message.Message, 5), @@ -119,14 +135,43 @@ func New(adapterID string, podId []byte) (*Ble, error) { } }() + // Heartbeat emitter: once a phone subscribes to the heartbeat + // characteristic, push a one-byte notification every 10s. Real O5 pods + // use this as a connection keep-alive. + go func() { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for range t.C { + b.heartbeatNotifierMtx.Lock() + n := b.heartbeatNotifier + b.heartbeatNotifierMtx.Unlock() + if n == nil || n.Done() { + continue + } + if _, err := n.Write([]byte{0x00}); err != nil { + log.Tracef("pkg bluetooth; heartbeat write error: %s", err) + } + } + }() + // A mandatory handler for monitoring device state. onStateChanged := func(d gatt.Device, s gatt.State) { fmt.Printf("state: %s\n", s) switch s { case gatt.StatePoweredOn: - var serviceUUID = gatt.MustParseUUID("1a7e-4024-e3ed-4464-8b7e-751e03d0dc5f") - var cmdCharUUID = gatt.MustParseUUID("1a7e-2441-e3ed-4464-8b7e-751e03d0dc5f") - var dataCharUUID = gatt.MustParseUUID("1a7e-2442-e3ed-4464-8b7e-751e03d0dc5f") + // Main pod GATT service (Omnipod 5; same primary UUID as Dash). + // Source: OmnipodKit BluetoothServices.swift / BlePodProfile.swift. + var serviceUUID = gatt.MustParseUUID("1A7E4024-E3ED-4464-8B7E-751E03D0DC5F") + var cmdCharUUID = gatt.MustParseUUID("1A7E2441-E3ED-4464-8B7E-751E03D0DC5F") + var dataCharUUID = gatt.MustParseUUID("1A7E2443-E3ED-4464-8B7E-751E03D0DC5F") + + // Omnipod 5 heartbeat service used for keep-alive. + // The pod GATT service UUID is 7DED7A6C... and its single + // characteristic is 7DED7A6D... (notify). The ECF301E2... UUID + // below is what OmnipodKit scans for in the advertisement, not a + // GATT service exposed on the pod. + var heartbeatServiceUUID = gatt.MustParseUUID("7DED7A6C-CA72-46A7-A3A2-6061F6FDCAEB") + var heartbeatCharUUID = gatt.MustParseUUID("7DED7A6D-CA72-46A7-A3A2-6061F6FDCAEB") s := gatt.NewService(serviceUUID) @@ -136,6 +181,11 @@ func New(adapterID string, podId []byte) (*Ble, error) { log.Tracef("received CMD, %x", data) ret := make([]byte, len(data)) copy(ret, data) + // NOTE (Commit 3b): the HELLO-vs-RTS dispatcher (which + // routes activation bytes to cmdActivation) lands with + // Commit 9 (76fd556). For now every CMD write goes to + // cmdInput exactly like origin/main, preserving the + // existing pairing flow. b.cmdInput <- Packet(ret) return 0 }) @@ -167,34 +217,47 @@ func New(adapterID string, podId []byte) (*Ble, error) { return 0 }) - err = d.AddService(s) + h := gatt.NewService(heartbeatServiceUUID) + hbCharacteristic := h.AddCharacteristic(heartbeatCharUUID) + hbCharacteristic.HandleNotifyFunc( + func(r gatt.Request, n gatt.Notifier) { + b.heartbeatNotifierMtx.Lock() + b.heartbeatNotifier = n + b.heartbeatNotifierMtx.Unlock() + log.Infof("pkg bluetooth; handling heartbeat notifications on new connection from: %s", r.Central.ID()) + }) + + err = d.SetServices([]*gatt.Service{s, h}) if err != nil { log.Fatalf("pkg bluetooth; could not add service: %s", err) } - podIdServiceOne := gatt.UUID16(0xffff) - podIdServiceTwo := gatt.UUID16(0xfffe) - if podId != nil { - podIdServiceOne = gatt.UUID16(binary.BigEndian.Uint16(podId[0:2])) - podIdServiceTwo = gatt.UUID16(binary.BigEndian.Uint16(podId[2:4])) + podIdArray, err := hex.DecodeString("fffffffe") + if err != nil { + log.Fatalf("pkg bluetooth; could not parse default address: %s", err) } - // Advertise device name and service's UUIDs. - err = d.AdvertiseNameAndServices(" :: Fake POD ::", []gatt.UUID{ - gatt.UUID16(0x4024), - - gatt.UUID16(0x2470), - gatt.UUID16(0x000a), - - podIdServiceOne, - podIdServiceTwo, + if podId != nil { + podIdArray = podId + } - // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go - gatt.UUID16(0x0814), - gatt.UUID16(0x6DB1), - gatt.UUID16(0x0006), - gatt.UUID16(0xE451), - }) + // CE1F923D-C539-48EA-7300-0AFFFFFFFE00 unpaired, or + // CE1F923D-C539-48EA-7300-0A00 once paired. The + // ECF301E2... UUID is OmnipodKit's "advertisement" identifier + // for the O5 heartbeat service and is co-advertised so the + // scanner can find it during discovery (BluetoothServices.swift). + // + // NOTE: the vendored paypal/gatt in this tree does not expose + // AdvertiseNameServicesMfgData; the manufacturer-data payload + // (60030001000000) cannot be advertised without patching the + // vendor library. Fall back to AdvertiseNameAndServices for now. + err = d.AdvertiseNameAndServices( + "AP "+strings.ToUpper(hex.EncodeToString(podIdArray))+" 0A95B6110002761B", + []gatt.UUID{ + gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(podIdArray) + "00"), + gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), + }, + ) if err != nil { log.Fatalf("pkg bluetooth; could not advertise: %s", err) } @@ -211,25 +274,18 @@ func New(adapterID string, podId []byte) (*Ble, error) { func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, first 2 usually empty log.Debugf("RefreshAdvertisingWithSpecifiedId %x", id) // Looking at the paypal/gatt source code, we don't need to call StopAdvertising, - // but just call AdvertiseNameAndServices and it should update - - log.Tracef("podIdServiceOne %v", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) - log.Tracef("podIdServiceTwo %v", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) - err := (*b.device).AdvertiseNameAndServices(" :: Fake POD ::", []gatt.UUID{ - gatt.UUID16(0x4024), - - gatt.UUID16(0x2470), - gatt.UUID16(0x000a), - - gatt.UUID16(binary.BigEndian.Uint16(id[0:2])), - gatt.UUID16(binary.BigEndian.Uint16(id[2:4])), - - // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go - gatt.UUID16(0x0814), - gatt.UUID16(0x6DB1), - gatt.UUID16(0x0006), - gatt.UUID16(0xE451), - }) + // but just call AdvertiseNameAndServices and it should update. + // + // As with the initial advertise in New(), the vendored gatt does not + // support manufacturer-data advertising, so the 60030001000000 payload + // is omitted here as well. + err := (*b.device).AdvertiseNameAndServices( + "AP "+strings.ToUpper(hex.EncodeToString(id))+" 0A95B6110002761B", + []gatt.UUID{ + gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(id) + "00"), + gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), + }, + ) if err != nil { log.Infof("pkg bluetooth; could not re-advertise: %s", err) } @@ -254,6 +310,16 @@ func (b *Ble) writeDataBuffer(buf *bytes.Buffer) error { return b.WriteData(data) } +// ReadCmd blocks until the next pairing-state command byte arrives on the +// CMD characteristic — typically the OmnipodKit HELLO frame +// (06 01 04 + 4-byte controller ID). RTS/CTS/SUCCESS/NACK/FAIL signal bytes +// are not delivered here; they are consumed inline by the message loop's +// readMessage path. +// +// NOTE (Commit 3b): cmdActivation exists on the struct but its dispatcher +// is deferred to Commit 9 (76fd556). Until then, ReadCmd continues to read +// from cmdInput exactly like main, so the simulator's pairing path remains +// functional through Commits 4-8. func (b *Ble) ReadCmd() (Packet, error) { packet := <-b.cmdInput return packet, nil @@ -283,8 +349,15 @@ func (b *Ble) ReadMessageWithTimeout(d time.Duration) (*message.Message, bool) { } } +// ShutdownConnection drops the BLE link to the current central and stops +// the message loop so a subsequent StartAcceptingCommands can re-init the +// pipeline cleanly. Without the StopMessageLoop call here, restarting the +// loop later would fatal with "Messaging loop is already running". func (b *Ble) ShutdownConnection() { - (*b.central).Close() + if b.central != nil { + (*b.central).Close() + } + b.StopMessageLoop() } func (b *Ble) WriteMessage(message *message.Message) { @@ -297,13 +370,21 @@ func (b *Ble) loop(stop chan bool) { case <-stop: return case msg := <-b.messageOutput: - b.writeMessage(msg) + b.writeMessageData(msg) + case data := <-b.dataInput: + msg, err := b.readMessageData(data) + if err != nil { + log.Fatalf("pkg bluetooth; error reading message: %s", err) + } + b.messageInput <- msg case cmd := <-b.cmdInput: msg, err := b.readMessage(cmd) if err != nil { log.Fatalf("pkg bluetooth; error reading message: %s", err) } - b.messageInput <- msg + if msg != nil { + b.messageInput <- msg + } } } } @@ -331,6 +412,38 @@ func (b *Ble) expectCommand(expected Packet) { } } +// writeMessageData fragments msg through pkg/bluetooth/packet (OmnipodKit's +// authoritative split format) and pushes each fragment to the DATA +// characteristic. This is the path used by the live loop for all message +// writes — including the large get-status-type-1/3/5 responses preserved +// from main commit 79be48c. The packet subpackage's Split tests cover +// SPS2.1- and SPS2-sized payloads, so the large-message handling that +// 79be48c added to the old hand-rolled byte fragmentation in writeMessage +// is supplied here by the tested Split implementation. +func (b *Ble) writeMessageData(msg *message.Message) { + payload, err := msg.Marshal() + if err != nil { + log.Fatalf("pkg bluetooth; could not marshal the message %s", err) + } + log.Debugf("pkg bluetooth; sending message (%d bytes): %x", len(payload), payload) + + packets := packet.Split(payload) + log.Debugf("pkg bluetooth; split into %d BLE fragment(s)", len(packets)) + + for i, pkt := range packets { + log.Tracef("pkg bluetooth; writing fragment %d/%d (%d bytes)", i+1, len(packets), len(pkt)) + var buf bytes.Buffer + buf.Write(pkt) + b.writeDataBuffer(&buf) + } +} + +// writeMessage is the legacy Dash-shaped fragmenter retained from origin/main. +// It is no longer invoked by the message loop (writeMessageData is used +// instead), but is kept here for any future caller that wants the old +// RTS/CTS/SUCCESS handshake. The byte→int index conversion from commit +// 79be48c is preserved verbatim so the type 1/3/5 / large-message overflow +// fix still applies if anything calls this. func (b *Ble) writeMessage(msg *message.Message) { var buf bytes.Buffer var index = 0 @@ -345,7 +458,7 @@ func (b *Ble) writeMessage(msg *message.Message) { sum := crc32.ChecksumIEEE(bytes) if len(bytes) <= 18 { buf.WriteByte(byte(index)) - buf.WriteByte(0) // fragments + buf.WriteByte(0) // fragments buf.WriteByte(byte(sum >> 24)) buf.WriteByte(byte(sum >> 16)) @@ -412,6 +525,43 @@ func (b *Ble) writeMessage(msg *message.Message) { b.expectCommand(CmdSuccess) } +// readMessageData reassembles fragments arriving on the DATA characteristic +// using pkg/bluetooth/packet.Join. This replaces the old hand-rolled +// fragmentation reader; the OmnipodKit-aligned reassembler bounds each +// fragment read to its own declared length so smaller-than-244 MTUs still +// work, which subsumes the large-message read robustness work in 79be48c. +func (b *Ble) readMessageData(data Packet) (*message.Message, error) { + return b.parsePackets(data) +} + +func (b *Ble) parsePackets(first Packet) (*message.Message, error) { + log.Debugf("pkg bluetooth; reassembling, first fragment %d bytes: %x", len(first), first) + bytesOut, err := packet.Join(first, func() ([]byte, error) { + pkt, e := b.ReadData() + if e == nil { + log.Tracef("pkg bluetooth; got next fragment %d bytes: %x", len(pkt), []byte(pkt)) + } + return pkt, e + }) + if err != nil { + log.Warnf("pkg bluetooth; reassembly failed: %s", err) + b.WriteCmd(CmdFail) + return nil, err + } + log.Debugf("pkg bluetooth; reassembled %d-byte message", len(bytesOut)) + + b.WriteCmd(CmdSuccess) + + msg, mErr := message.Unmarshal(bytesOut) + log.Tracef("pkg bluetooth; received message: %s", spew.Sdump(msg)) + return msg, mErr +} + +// readMessage is the legacy RTS-driven read path retained verbatim from +// origin/main. With the new loop wiring (dataInput is consumed directly +// by readMessageData) this function only runs when an RTS arrives on the +// CMD characteristic. The graceful non-RTS fallback / HELLO handling +// added by five's 76fd556 is deferred to Commit 9. func (b *Ble) readMessage(cmd Packet) (*message.Message, error) { var buf bytes.Buffer var checksum []byte From 8097b768762181eceb5912e4f9cc51282664d888 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:42:28 +0000 Subject: [PATCH 05/18] pair: implement Omnipod 5 SPS2 pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the SPS2.1 + SPS2 cryptographic exchange used by Omnipod 5 pairing, plus the synthetic pod-side PKI that backs it. New files in pkg/pair: - podidentity.go: P-256 keypair + self-signed X.509 cert. NewPodIdentity generates fresh material; LoadPodIdentity rehydrates from persisted raw scalar + DER. Each simulator instance gets a stable identity on first activation. - spsnonce.go: 13-byte AES-CCM nonce builder with separate read/write counters keyed off the SPS exchange's nonce halves. - sps2.go: AES-CCM encrypt/decrypt for SPS2.1 (cert) and SPS2 (cert||sig), 171-byte channel-binding transcript builders for both PDM and pod directions, SHA-256+ECDSA sign/verify with raw 64-byte r||s pack/unpack, and a P-256 SPKI extractor that reads the public key out of a DER cert without doing chain validation. Also includes VerifyType4Signature, which lands here pre-wired but is unused until the Type-4 framing commit. - sps2_test.go, spsnonce_test.go: round-trips, transcript binding, P-256 extraction. pair.go hand-merge (preserve Dash bit-for-bit): - Pair struct gains nonceState, identity, pdmCertDER, sps21Data. - SetIdentity / IsO5 accessors. - computeMyData(): Dash unchanged (32-byte Curve25519 + zeroed nonce). O5 mints a real P-256 keypair via crypto/ecdh and a 16-byte random nonce. - computePairData(): Dash CMAC chain unchanged. O5 does P-256 ECDH (0x04||pdmPublic), feeds o5DeriveKeys with the now-matching 64/64/32 inputs, and initialises nonceState. - ParseSPS1 branches on Mode for the wire public-key length (32 vs 64). - ParseSPS2 / GenerateSPS2: O5 path decrypts/encrypts cert||sig, builds transcripts using pre-decrypt nonce state, signs with the identity key, and soft-fails PDM signature verification (warns but doesn't fatal — transcript layouts can shift across firmware). - ParseSPS21 / GenerateSPS21: new entry points; O5 decrypts/encrypts the intermediate-CA cert. Dash returns a zero-fill stand-in. pod.go: - ensurePodIdentity loads from state if persisted, else mints and saves. - StartActivation renames the local pair value to pairCtx (so calls like pairCtx.IsO5() and pairCtx.SetIdentity() don't fight the package identifier), calls SetIdentity for O5, and inserts the SPS2.1 send/receive between SPS1 and SPS2 — gated on IsO5(), so the Dash pairing sequence is unchanged. state.go: adds O5PrivateKey + O5CertDER (TOML keys o5_private_key / o5_cert_der) so the pod identity survives across runs. Source: jwoglom/five commit b459d39 (Step 3). The copy of sps2.go reflects five's HEAD (includes VerifyType4Signature), which the next commit will wire up. --- pkg/pair/mode.go | 4 + pkg/pair/pair.go | 227 +++++++++++++++++++++++++++++--- pkg/pair/podidentity.go | 90 +++++++++++++ pkg/pair/sps2.go | 264 ++++++++++++++++++++++++++++++++++++++ pkg/pair/sps2_test.go | 198 ++++++++++++++++++++++++++++ pkg/pair/spsnonce.go | 82 ++++++++++++ pkg/pair/spsnonce_test.go | 63 +++++++++ pkg/pod/pod.go | 60 +++++++-- pkg/pod/state.go | 8 ++ 9 files changed, 967 insertions(+), 29 deletions(-) create mode 100644 pkg/pair/podidentity.go create mode 100644 pkg/pair/sps2.go create mode 100644 pkg/pair/sps2_test.go create mode 100644 pkg/pair/spsnonce.go create mode 100644 pkg/pair/spsnonce_test.go diff --git a/pkg/pair/mode.go b/pkg/pair/mode.go index 846b006..f6e0d70 100644 --- a/pkg/pair/mode.go +++ b/pkg/pair/mode.go @@ -31,3 +31,7 @@ func ParseMode(s string) (Mode, error) { } return 0, fmt.Errorf("invalid pair mode %q (want dash or o5)", s) } + +// IsO5 returns true when this pairing context is operating in Omnipod 5 mode. +// Useful from packages where the local symbol `pair` shadows this package. +func (c *Pair) IsO5() bool { return c.Mode == ModeO5 } diff --git a/pkg/pair/pair.go b/pkg/pair/pair.go index ea17604..208c11d 100644 --- a/pkg/pair/pair.go +++ b/pkg/pair/pair.go @@ -2,6 +2,8 @@ package pair import ( "bytes" + "crypto/ecdh" + "crypto/rand" "errors" "fmt" @@ -19,9 +21,12 @@ const ( sp2 = ",SP2=" sps1 = "SPS1=" + sps21 = "SPS2.1=" sps2 = "SPS2=" sp0gp0 = "SP0,GP0" p0 = "P0=" + + defaultSPS21Size = 641 ) type Pair struct { @@ -37,6 +42,8 @@ type Pair struct { pdmNonce []byte pdmConf []byte + // In Dash this is the Curve25519 shared secret; in O5 it is the + // raw P-256 ECDH shared secret (32 bytes either way). curve25519LTK []byte pdmID []byte podID []byte @@ -44,8 +51,23 @@ type Pair struct { ltk []byte confKey []byte // key used to sign the "Conf" values; Dash only - // O5-only: 16-byte AES-CCM key for SPS2.1/SPS2. - conf []byte + // SPS2.1 inbound state shared by Dash and O5 paths. + sps21Len int + sps21Data []byte + + // O5-only: 16-byte AES-CCM key for SPS2.1/SPS2 plus the per-direction + // nonce state and the pod's signing identity. + conf []byte + nonceState *spsNonceState + identity *PodIdentity + pdmCertDER []byte +} + +// SetIdentity attaches a P-256 keypair + self-signed cert that the pod uses +// to sign the SPS2 channel-binding transcript. Required in O5 mode before +// SPS2.1/SPS2 are processed. +func (c *Pair) SetIdentity(id *PodIdentity) { + c.identity = id } func parseStringByte(expectedNames []string, data []byte) (map[string][]byte, error) { @@ -99,10 +121,21 @@ func (c *Pair) ParseSPS1(msg *message.Message) error { return err } log.Infof("Received SPS1 %x", sp[sps1]) - pdmPublic := sp[sps1][:32] - pdmNonce := sp[sps1][32:] - c.pdmPublic = make([]byte, 32) + // O5 carries a 64-byte raw P-256 public key (X||Y) followed by a 16-byte + // nonce; Dash carries a 32-byte Curve25519 public key followed by a + // 16-byte nonce. + pubLen := 32 + if c.Mode == ModeO5 { + pubLen = 64 + } + if len(sp[sps1]) < pubLen+16 { + return fmt.Errorf("SPS1 payload too short: got %d, want >=%d", len(sp[sps1]), pubLen+16) + } + pdmPublic := sp[sps1][:pubLen] + pdmNonce := sp[sps1][pubLen : pubLen+16] + + c.pdmPublic = make([]byte, pubLen) c.pdmNonce = make([]byte, 16) copy(c.pdmNonce, pdmNonce) copy(c.pdmPublic, pdmPublic) @@ -110,9 +143,11 @@ func (c *Pair) ParseSPS1(msg *message.Message) error { if err != nil { return err } - c.curve25519LTK, err = curve25519.X25519(c.podPrivate, c.pdmPublic) - if err != nil { - return err + if c.Mode != ModeO5 { + c.curve25519LTK, err = curve25519.X25519(c.podPrivate, c.pdmPublic) + if err != nil { + return err + } } return nil } @@ -149,6 +184,32 @@ func (c *Pair) ParseSPS2(msg *message.Message) error { return err } + if c.Mode == ModeO5 { + // Build the PDM transcript using the nonce state as it stands BEFORE + // decryption advances pdmNonce — this matches the controller's view + // at sign time (see O5KeyExchange.swift buildChannelBindingTranscript). + transcript := buildPDMTranscript(c.pdmPublic, c.podPublic, c.nonceState.pdm, c.nonceState.pod) + + certDER, sig, err := decryptSPS2(c.conf, c.nonceState, sp[sps2]) + if err != nil { + return fmt.Errorf("SPS2 decrypt: %w", err) + } + log.Infof("Decrypted PDM SPS2: %d-byte cert + 64-byte sig", len(certDER)) + c.pdmCertDER = certDER + + ok, vErr := verifyTranscript(certDER, transcript, sig) + if vErr != nil { + log.Warnf("PDM SPS2 signature verification setup failed: %v (continuing)", vErr) + return nil + } + if !ok { + log.Warnf("PDM SPS2 signature verification FAILED (continuing — transcript layout may differ across firmware)") + } else { + log.Infof("PDM SPS2 signature verified") + } + return nil + } + if !bytes.Equal(c.pdmConf, sp[sps2]) { return fmt.Errorf("Invalid conf value. Expected: %x. Got %x", c.pdmConf, sp[sps2]) } @@ -156,17 +217,102 @@ func (c *Pair) ParseSPS2(msg *message.Message) error { return nil } +func (c *Pair) ParseSPS21(msg *message.Message) error { + sp, err := parseStringByte([]string{sps21}, msg.Payload) + if err != nil { + log.Warnf("SPS2.1 Message :%s", spew.Sdump(msg)) + log.Warnf("SPS2.1 parse failed, continuing without validation: %v", err) + return nil + } + c.sps21Data = sp[sps21] + c.sps21Len = len(c.sps21Data) + if c.sps21Len == 0 { + log.Warn("SPS2.1 payload was empty; continuing without validation") + return nil + } + + if c.Mode == ModeO5 { + certDER, err := decryptSPS21(c.conf, c.nonceState, c.sps21Data) + if err != nil { + log.Warnf("PDM SPS2.1 decrypt failed: %v (continuing)", err) + return nil + } + log.Infof("Decrypted PDM SPS2.1: %d-byte intermediate-CA DER", len(certDER)) + c.sps21Data = certDER + return nil + } + + log.Infof("Received SPS2.1 payload (%d bytes)", c.sps21Len) + return nil +} + func (c *Pair) GenerateSPS2() (*message.Message, error) { - var err error sp := make(map[string][]byte) - sp[sps2] = c.podConf + + if c.Mode == ModeO5 { + if c.identity == nil { + return nil, errors.New("GenerateSPS2: pod identity not set in O5 mode (call SetIdentity)") + } + // At sign time, nonceState mirrors the moment we are about to send + // SPS2: pdmNonce already advanced by SPS2.1 receive AND SPS2 receive, + // podNonce already advanced by SPS2.1 send. That matches what the + // controller's verifier expects (see buildPodTranscript docs). + transcript := buildPodTranscript(c.pdmPublic, c.podPublic, c.nonceState.pdm, c.nonceState.pod) + sig, err := signTranscript(c.identity.PrivateKey, transcript) + if err != nil { + return nil, fmt.Errorf("SPS2 sign: %w", err) + } + ct, err := encryptSPS2(c.conf, c.nonceState, c.identity.CertDER, sig) + if err != nil { + return nil, fmt.Errorf("SPS2 encrypt: %w", err) + } + sp[sps2] = ct + } else { + sp[sps2] = c.podConf + } + + msg := message.NewMessage(message.MessageTypePairing, c.podID, c.pdmID) + payload, err := buildStringByte([]string{sps2}, sp) + if err != nil { + return nil, err + } + msg.Payload = payload + log.Debugf("Generated SPS2 (%d bytes)", len(sp[sps2])) + return msg, nil +} + +func (c *Pair) GenerateSPS21() (*message.Message, error) { + sp := make(map[string][]byte) + + if c.Mode == ModeO5 { + if c.identity == nil { + return nil, errors.New("GenerateSPS21: pod identity not set in O5 mode (call SetIdentity)") + } + ct, err := encryptSPS21(c.conf, c.nonceState, c.identity.CertDER) + if err != nil { + return nil, fmt.Errorf("SPS2.1 encrypt: %w", err) + } + sp[sps21] = ct + } else { + // Dash has no SPS2.1 in the activation flow; fall back to a zero-filled + // stand-in sized one byte shorter than the inbound (or the default if + // no inbound was observed). This branch is kept for completeness. + responseSize := c.sps21Len - 1 + if c.sps21Len == 0 { + responseSize = defaultSPS21Size + } else if responseSize < 0 { + responseSize = 0 + } + sp[sps21] = bytes.Repeat([]byte{0x00}, responseSize) + } msg := message.NewMessage(message.MessageTypePairing, c.podID, c.pdmID) - msg.Payload, err = buildStringByte([]string{sps2}, sp) + payload, err := buildStringByte([]string{sps21}, sp) if err != nil { return nil, err } - log.Debugf("Generated SPS2: %x", msg.Payload) + msg.Payload = payload + log.Debugf("Generated SPS2.1 (%d bytes)", len(sp[sps21])) return msg, nil } @@ -199,6 +345,29 @@ func (c *Pair) LTK() ([]byte, error) { func (c *Pair) computeMyData() error { var err error + if c.Mode == ModeO5 { + // Real P-256 keypair (raw 32-byte D scalar, 64-byte uncompressed X||Y + // public key without the 0x04 prefix). Nonce is freshly random — the + // per-direction counter lives in spsNonceState. + c.podNonce = make([]byte, 16) + if _, err = rand.Read(c.podNonce); err != nil { + return err + } + priv, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return err + } + c.podPrivate = priv.Bytes() + // PublicKey().Bytes() returns the 0x04-prefixed uncompressed point; + // strip the prefix to get the 64-byte raw X||Y form that o5DeriveKeys + // and the transcript helpers expect. + c.podPublic = priv.PublicKey().Bytes()[1:] + log.Infof("Pod Private %x :: %d", c.podPrivate, len(c.podPrivate)) + log.Infof("Pod Public %x :: %d", c.podPublic, len(c.podPublic)) + log.Infof("Pod Nonce %x :: %d", c.podNonce, len(c.podNonce)) + return nil + } + c.podPrivate = make([]byte, 32) c.podPublic = make([]byte, 32) c.podNonce = make([]byte, 16) // 0 for now TODO @@ -217,27 +386,45 @@ func (c *Pair) computeMyData() error { func (c *Pair) computePairData() error { var err error // fill in: lrtk, podConf, pdmConf, intermediarKey - c.curve25519LTK, err = curve25519.X25519(c.podPrivate, c.pdmPublic) - if err != nil { - return err - } - log.Infof("Shared secret %x :: %d", c.curve25519LTK, len(c.curve25519LTK)) - if c.Mode == ModeO5 { + // P-256 ECDH against the PDM's raw X||Y public key. + privateKey, err := ecdh.P256().NewPrivateKey(c.podPrivate) + if err != nil { + return err + } + publicKey, err := ecdh.P256().NewPublicKey(append([]byte{0x04}, c.pdmPublic...)) + if err != nil { + return err + } + c.curve25519LTK, err = privateKey.ECDH(publicKey) + if err != nil { + return err + } + log.Infof("Shared secret %x :: %d", c.curve25519LTK, len(c.curve25519LTK)) + conf, ltk, err := o5DeriveKeys(c.pdmPublic, c.podPublic, c.curve25519LTK) if err != nil { return err } c.conf = conf c.ltk = ltk + c.nonceState, err = newSPSNonceState(c.pdmNonce, c.podNonce) + if err != nil { + return err + } log.Infof("O5 conf key %x", c.conf) log.Infof("O5 LTK %x", c.ltk) // SPS2.1/SPS2 in O5 are AES-CCM payloads, not CMAC confirmation values; - // they are populated in dedicated handlers (Step 3). Skip the Dash - // CMAC chain. + // they are routed through dedicated handlers. Skip the Dash CMAC chain. return nil } + c.curve25519LTK, err = curve25519.X25519(c.podPrivate, c.pdmPublic) + if err != nil { + return err + } + log.Infof("Shared secret %x :: %d", c.curve25519LTK, len(c.curve25519LTK)) + //first_key = data.pod_public[-4:] + data.pdm_public[-4:] + data.pod_nonce[-4:] + data.pdm_nonce[-4:] firstKey := append(c.podPublic[28:], c.pdmPublic[28:]...) firstKey = append(firstKey, c.podNonce[12:]...) diff --git a/pkg/pair/podidentity.go b/pkg/pair/podidentity.go new file mode 100644 index 0000000..0da6340 --- /dev/null +++ b/pkg/pair/podidentity.go @@ -0,0 +1,90 @@ +package pair + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "math/big" + "time" +) + +// PodIdentity is the P-256 keypair used to sign SPS2 channel-binding +// transcripts (and Type-4 post-pairing messages). The DER cert wraps the +// matching public key in an X.509 SubjectPublicKeyInfo OmnipodKit knows how to +// parse via O5CertificateStore.extractP256PublicKey. +type PodIdentity struct { + PrivateKey *ecdsa.PrivateKey + CertDER []byte +} + +// NewPodIdentity mints a fresh P-256 keypair and a self-signed certificate +// that wraps it. We don't bother with a real chain (INS00PG1 etc.) — OmnipodKit +// only extracts the leaf public key and verifies the signature, it does not +// validate the chain. See O5CertificateStore.swift `o5validatePodSps2`. +func NewPodIdentity() (*PodIdentity, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("pod identity: generate key: %w", err) + } + + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "pod-five-simulator"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) + if err != nil { + return nil, fmt.Errorf("pod identity: self-sign: %w", err) + } + return &PodIdentity{PrivateKey: priv, CertDER: der}, nil +} + +// LoadPodIdentity reconstructs a PodIdentity from its serialised parts. +// privScalar is the raw 32-byte P-256 private key as `D.Bytes()` (left-padded +// if needed). certDER is the X.509 DER produced by NewPodIdentity. +func LoadPodIdentity(privScalar, certDER []byte) (*PodIdentity, error) { + if len(privScalar) == 0 || len(certDER) == 0 { + return nil, errors.New("pod identity: empty private key or cert") + } + d := new(big.Int).SetBytes(privScalar) + curve := elliptic.P256() + if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 { + return nil, errors.New("pod identity: scalar out of range") + } + priv := &ecdsa.PrivateKey{ + D: d, + PublicKey: ecdsa.PublicKey{ + Curve: curve, + }, + } + priv.X, priv.Y = curve.ScalarBaseMult(privScalar) + return &PodIdentity{PrivateKey: priv, CertDER: certDER}, nil +} + +// PrivateScalar returns the raw 32-byte P-256 private key. +func (p *PodIdentity) PrivateScalar() []byte { + d := p.PrivateKey.D.Bytes() + if len(d) == 32 { + return d + } + out := make([]byte, 32) + copy(out[32-len(d):], d) + return out +} + +// PublicKeyRaw returns the 64-byte uncompressed P-256 public key (X || Y) with +// no 0x04 prefix, matching OmnipodKit's representation. +func (p *PodIdentity) PublicKeyRaw() []byte { + x := p.PrivateKey.PublicKey.X.Bytes() + y := p.PrivateKey.PublicKey.Y.Bytes() + out := make([]byte, 64) + copy(out[32-len(x):32], x) + copy(out[64-len(y):64], y) + return out +} diff --git a/pkg/pair/sps2.go b/pkg/pair/sps2.go new file mode 100644 index 0000000..9371287 --- /dev/null +++ b/pkg/pair/sps2.go @@ -0,0 +1,264 @@ +package pair + +import ( + "bytes" + "crypto/aes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "math/big" + + aesccm "github.com/pschlump/AesCCM" + log "github.com/sirupsen/logrus" +) + +// sps2CCM returns an AES-CCM mode with key=conf, 13-byte nonce, 8-byte tag. +// SPS2.1 / SPS2 do not bind any associated data — see O5LTKExchanger.swift +// (no AAD argument is passed to CryptoSwift's CCM constructor). +func sps2CCM(conf, nonce []byte) (aesccm.CCM, error) { + block, err := aes.NewCipher(conf) + if err != nil { + return nil, err + } + return aesccm.NewCCM(block, 8, len(nonce)) +} + +// EncryptSPS21 produces the AES-CCM-encrypted pod SPS2.1 payload. +// +// Plaintext is the pod's intermediate-CA cert DER (any length the synthetic +// chain produces; pod-side just needs *a* DER cert wrapping its pubkey). +// Output is plaintext encrypted under conf with the read-direction SPS-nonce, +// followed by an 8-byte CCM authentication tag. +// +// Mirrors O5LTKExchanger.swift:347-369 (controller side) but emits the read +// direction since pod sends the response. +func encryptSPS21(conf []byte, nonceState *spsNonceState, certDER []byte) ([]byte, error) { + nonce := nonceState.build(SPSRead) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, fmt.Errorf("SPS2.1 encrypt: %w", err) + } + out := c.Seal(nil, nonce, certDER, nil) + nonceState.increment(SPSRead) + log.Debugf("SPS2.1 encrypted: %d plaintext -> %d ciphertext", len(certDER), len(out)) + return out, nil +} + +// DecryptSPS21 returns the plaintext PDM intermediate-CA cert DER. +// Mirrors O5LTKExchanger.swift:378-419 (controller validates pod direction; +// pod-side does the symmetric thing for the controller direction). +func decryptSPS21(conf []byte, nonceState *spsNonceState, ciphertext []byte) ([]byte, error) { + if len(ciphertext) <= 8 { + return nil, fmt.Errorf("SPS2.1 ciphertext too short: %d bytes", len(ciphertext)) + } + nonce := nonceState.build(SPSWrite) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, fmt.Errorf("SPS2.1 decrypt: %w", err) + } + pt, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("SPS2.1 decrypt: %w", err) + } + nonceState.increment(SPSWrite) + log.Debugf("SPS2.1 decrypted: %d ciphertext -> %d plaintext", len(ciphertext), len(pt)) + return pt, nil +} + +// EncryptSPS2 produces the AES-CCM-encrypted pod SPS2 payload, which is +// `cert_DER || ECDSA_signature(64 bytes raw r||s)` encrypted under conf with +// the read-direction SPS-nonce + 8-byte tag. +// +// Mirrors O5LTKExchanger.swift:431-469. +func encryptSPS2(conf []byte, nonceState *spsNonceState, certDER, signatureRaw []byte) ([]byte, error) { + if len(signatureRaw) != 64 { + return nil, fmt.Errorf("SPS2 encrypt: signature must be 64 bytes raw r||s, got %d", len(signatureRaw)) + } + plaintext := make([]byte, 0, len(certDER)+64) + plaintext = append(plaintext, certDER...) + plaintext = append(plaintext, signatureRaw...) + + nonce := nonceState.build(SPSRead) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, fmt.Errorf("SPS2 encrypt: %w", err) + } + out := c.Seal(nil, nonce, plaintext, nil) + nonceState.increment(SPSRead) + log.Debugf("SPS2 encrypted: %d plaintext (cert=%d + sig=64) -> %d ciphertext", len(plaintext), len(certDER), len(out)) + return out, nil +} + +// DecryptSPS2 returns the PDM cert DER and the 64-byte signature contained in +// the encrypted SPS2 payload. +// +// Mirrors O5LTKExchanger.swift:475-535. +func decryptSPS2(conf []byte, nonceState *spsNonceState, ciphertext []byte) (certDER, signatureRaw []byte, err error) { + if len(ciphertext) <= 8+64 { + return nil, nil, fmt.Errorf("SPS2 ciphertext too short: %d bytes", len(ciphertext)) + } + nonce := nonceState.build(SPSWrite) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, nil, fmt.Errorf("SPS2 decrypt: %w", err) + } + pt, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, nil, fmt.Errorf("SPS2 decrypt: %w", err) + } + nonceState.increment(SPSWrite) + + certLen := len(pt) - 64 + certDER = make([]byte, certLen) + signatureRaw = make([]byte, 64) + copy(certDER, pt[:certLen]) + copy(signatureRaw, pt[certLen:]) + return certDER, signatureRaw, nil +} + +// buildPDMTranscript builds the 171-byte channel-binding transcript that the +// PDM signed in SPS2. Mirrors O5KeyExchange.swift buildChannelBindingTranscript +// (lines 173-197). Pod uses this to verify the PDM's incoming signature. +// +// [0x01] || FIRMWARE_ID(6) || zeros(4) || pdmPublic(64) || podPublic(64) || pdmNonce(16) || podNonce(16) +func buildPDMTranscript(pdmPublic, podPublic, pdmNonce, podNonce []byte) []byte { + out := make([]byte, 0, 171) + out = append(out, 0x01) + out = append(out, FirmwareID...) + out = append(out, 0x00, 0x00, 0x00, 0x00) + out = append(out, pdmPublic...) + out = append(out, podPublic...) + out = append(out, pdmNonce...) + out = append(out, podNonce...) + return out +} + +// buildPodTranscript builds the 171-byte channel-binding transcript that the +// pod signs in SPS2. Mirrors O5KeyExchange.swift buildPodChannelBindingTranscript +// (lines 224-253). Note the swapped FIRMWARE_ID/zeros position relative to the +// PDM transcript and the keys/nonces grouped pod-first, pdm-second. +// +// [0x02] || zeros(4) || FIRMWARE_ID(6) || podPublic(64) || pdmPublic(64) || podNonce(16) || pdmNonce(16) +// +// The OmnipodKit verifier expects the pod to have signed its transcript with +// podNonce in the state it had AFTER incrementing for SPS2.1 only (i.e. +// before the pod incremented again for sending SPS2). On the pod side, that's +// exactly the value of podNonce at the moment we are about to encrypt SPS2 — +// so no manual decrement is needed here. The decrement quirk in the Swift +// verifier comes from the controller having advanced its `podNonce` mirror +// twice by the time it verifies. +func buildPodTranscript(pdmPublic, podPublic, pdmNonce, podNonce []byte) []byte { + out := make([]byte, 0, 171) + out = append(out, 0x02) + out = append(out, 0x00, 0x00, 0x00, 0x00) + out = append(out, FirmwareID...) + out = append(out, podPublic...) + out = append(out, pdmPublic...) + out = append(out, podNonce...) + out = append(out, pdmNonce...) + return out +} + +// signTranscript signs an already-built transcript with the pod's private key +// and returns the 64-byte raw r||s representation OmnipodKit expects. +func signTranscript(priv *ecdsa.PrivateKey, transcript []byte) ([]byte, error) { + digest := sha256.Sum256(transcript) + r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) + if err != nil { + return nil, err + } + return packRS(r, s), nil +} + +// VerifyType4Signature verifies the 64-byte raw r||s ECDSA signature on a +// Type-4 (ENCRYPTED_SIGNED) command. The signing input is exactly the 16-byte +// TWi header followed by the AES-CCM ciphertext (including the 8-byte tag); +// see OmnipodKit BleMessageTransport.swift getCmdMessage signing block. +// +// pubkeyRaw is the 64-byte raw P-256 public key (X||Y) extracted from the +// PDM's TLS leaf certificate at SPS2 time. +func VerifyType4Signature(pubkeyRaw, header, ciphertext, signatureRaw []byte) (bool, error) { + if len(pubkeyRaw) != 64 { + return false, fmt.Errorf("VerifyType4Signature: pubkey must be 64 bytes, got %d", len(pubkeyRaw)) + } + if len(signatureRaw) != 64 { + return false, fmt.Errorf("VerifyType4Signature: signature must be 64 bytes, got %d", len(signatureRaw)) + } + if len(header) != 16 { + return false, fmt.Errorf("VerifyType4Signature: header must be 16 bytes, got %d", len(header)) + } + pub := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(pubkeyRaw[:32]), + Y: new(big.Int).SetBytes(pubkeyRaw[32:]), + } + signingInput := make([]byte, 0, len(header)+len(ciphertext)) + signingInput = append(signingInput, header...) + signingInput = append(signingInput, ciphertext...) + digest := sha256.Sum256(signingInput) + r := new(big.Int).SetBytes(signatureRaw[:32]) + s := new(big.Int).SetBytes(signatureRaw[32:]) + return ecdsa.Verify(pub, digest[:], r, s), nil +} + +// verifyTranscript verifies a 64-byte raw r||s signature over a transcript +// using a public key extracted from a DER cert. +func verifyTranscript(certDER, transcript, signatureRaw []byte) (bool, error) { + if len(signatureRaw) != 64 { + return false, fmt.Errorf("signature must be 64 bytes, got %d", len(signatureRaw)) + } + pub, err := extractP256PublicKey(certDER) + if err != nil { + return false, err + } + r := new(big.Int).SetBytes(signatureRaw[:32]) + s := new(big.Int).SetBytes(signatureRaw[32:]) + digest := sha256.Sum256(transcript) + return ecdsa.Verify(pub, digest[:], r, s), nil +} + +func packRS(r, s *big.Int) []byte { + out := make([]byte, 64) + rb := r.Bytes() + sb := s.Bytes() + copy(out[32-len(rb):32], rb) + copy(out[64-len(sb):64], sb) + return out +} + +// p256SPKIHeader is the fixed 26-byte prefix of an X.509 SubjectPublicKeyInfo +// for an uncompressed P-256 public key. The next 64 bytes are the raw X||Y +// coordinates. Mirrors O5CertificateStore.swift p256SPKIHeader. +var p256SPKIHeader = []byte{ + 0x30, 0x59, + 0x30, 0x13, + 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, + 0x03, 0x42, + 0x00, + 0x04, +} + +// extractP256PublicKey scans a DER cert for the SubjectPublicKeyInfo prefix +// for a P-256 uncompressed key and returns it as an *ecdsa.PublicKey. +// Mirrors O5CertificateStore.swift extractP256PublicKey(fromDERCert:). +func extractP256PublicKey(certDER []byte) (*ecdsa.PublicKey, error) { + idx := bytes.Index(certDER, p256SPKIHeader) + if idx < 0 { + return nil, errors.New("extractP256PublicKey: SPKI header not found") + } + keyStart := idx + len(p256SPKIHeader) + if keyStart+64 > len(certDER) { + return nil, errors.New("extractP256PublicKey: cert truncated after SPKI header") + } + x := new(big.Int).SetBytes(certDER[keyStart : keyStart+32]) + y := new(big.Int).SetBytes(certDER[keyStart+32 : keyStart+64]) + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil +} + +// (kept for symmetry with binary.* helpers used elsewhere in the package) +var _ = binary.BigEndian diff --git a/pkg/pair/sps2_test.go b/pkg/pair/sps2_test.go new file mode 100644 index 0000000..c21c720 --- /dev/null +++ b/pkg/pair/sps2_test.go @@ -0,0 +1,198 @@ +package pair + +import ( + "bytes" + "crypto/rand" + "testing" +) + +func makeNonceState(t *testing.T) *spsNonceState { + t.Helper() + pdm := make([]byte, 16) + pod := make([]byte, 16) + rand.Read(pdm) + rand.Read(pod) + s, err := newSPSNonceState(pdm, pod) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestSPS21RoundTrip(t *testing.T) { + conf := bytes.Repeat([]byte{0x42}, 16) + plaintext := []byte("intermediate-CA-cert-DER-stand-in") + + // Two parallel state machines so write side advances independently of + // read side, mirroring the controller/pod relationship. + enc := makeNonceState(t) + dec, err := newSPSNonceState(enc.pdm, enc.pod) + if err != nil { + t.Fatal(err) + } + + // Encrypt as if pod->phone (read direction). + ct, err := encryptSPS21(conf, enc, plaintext) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if len(ct) != len(plaintext)+8 { + t.Fatalf("ciphertext should be plaintext+8B tag, got %d for %d-byte plaintext", len(ct), len(plaintext)) + } + + // Decrypt with a fresh state on the other side using the SPS *write* path + // — i.e., the receiver of pod->phone, in our verifier model. To keep the + // round-trip self-consistent we mirror by calling decrypt on a state that + // agrees on the pod-side nonce. Simpler: just decrypt with a freshly + // initialised state under the read path (matching directions). + pt, err := decryptAsRead(conf, dec, ct) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if !bytes.Equal(pt, plaintext) { + t.Fatalf("plaintext mismatch: got %x want %x", pt, plaintext) + } +} + +// decryptAsRead is a test helper that decrypts a ciphertext that was produced +// in the read direction by encryptSPS21 — useful for in-process round-trip +// tests where there is no separate writer/reader. +func decryptAsRead(conf []byte, ns *spsNonceState, ciphertext []byte) ([]byte, error) { + nonce := ns.build(SPSRead) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, err + } + pt, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + ns.increment(SPSRead) + return pt, nil +} + +// TestSPS2RoundTripWithSignature mints a pod identity, signs the pod +// transcript, encrypts SPS2, then decrypts and verifies the signature. +func TestSPS2RoundTripWithSignature(t *testing.T) { + conf := bytes.Repeat([]byte{0x77}, 16) + pdmPub := bytes.Repeat([]byte{0x11}, 64) + podPub := bytes.Repeat([]byte{0x22}, 64) + pdmNonce := bytes.Repeat([]byte{0x33}, 16) + podNonce := bytes.Repeat([]byte{0x44}, 16) + + id, err := NewPodIdentity() + if err != nil { + t.Fatal(err) + } + + transcript := buildPodTranscript(pdmPub, podPub, pdmNonce, podNonce) + if len(transcript) != 171 { + t.Fatalf("pod transcript expected 171 bytes, got %d", len(transcript)) + } + + sig, err := signTranscript(id.PrivateKey, transcript) + if err != nil { + t.Fatal(err) + } + if len(sig) != 64 { + t.Fatalf("sig should be 64 bytes, got %d", len(sig)) + } + + enc := makeNonceState(t) + dec, err := newSPSNonceState(enc.pdm, enc.pod) + if err != nil { + t.Fatal(err) + } + + ct, err := encryptSPS2(conf, enc, id.CertDER, sig) + if err != nil { + t.Fatal(err) + } + gotCert, gotSig, err := decryptSPS2AsRead(conf, dec, ct) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotCert, id.CertDER) { + t.Errorf("cert mismatch") + } + if !bytes.Equal(gotSig, sig) { + t.Errorf("signature mismatch") + } + + // Verify the signature using the cert (as OmnipodKit's verifier does). + ok, err := verifyTranscript(gotCert, transcript, gotSig) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("signature verification failed") + } +} + +func decryptSPS2AsRead(conf []byte, ns *spsNonceState, ciphertext []byte) ([]byte, []byte, error) { + nonce := ns.build(SPSRead) + c, err := sps2CCM(conf, nonce) + if err != nil { + return nil, nil, err + } + pt, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, nil, err + } + ns.increment(SPSRead) + certLen := len(pt) - 64 + return pt[:certLen], pt[certLen:], nil +} + +func TestPodIdentityScalarRoundTrip(t *testing.T) { + id, err := NewPodIdentity() + if err != nil { + t.Fatal(err) + } + scalar := id.PrivateScalar() + if len(scalar) != 32 { + t.Fatalf("scalar expected 32 bytes, got %d", len(scalar)) + } + loaded, err := LoadPodIdentity(scalar, id.CertDER) + if err != nil { + t.Fatal(err) + } + if loaded.PrivateKey.D.Cmp(id.PrivateKey.D) != 0 { + t.Fatal("scalar round-trip mismatch") + } + // Public key should match too. + if !bytes.Equal(loaded.PublicKeyRaw(), id.PublicKeyRaw()) { + t.Fatal("public key round-trip mismatch") + } +} + +func TestExtractP256PublicKey(t *testing.T) { + id, err := NewPodIdentity() + if err != nil { + t.Fatal(err) + } + pub, err := extractP256PublicKey(id.CertDER) + if err != nil { + t.Fatal(err) + } + if pub.X.Cmp(id.PrivateKey.PublicKey.X) != 0 || pub.Y.Cmp(id.PrivateKey.PublicKey.Y) != 0 { + t.Fatal("extracted public key does not match cert's actual public key") + } +} + +func TestPDMTranscriptShape(t *testing.T) { + pdmPub := bytes.Repeat([]byte{1}, 64) + podPub := bytes.Repeat([]byte{2}, 64) + pdmNonce := bytes.Repeat([]byte{3}, 16) + podNonce := bytes.Repeat([]byte{4}, 16) + tr := buildPDMTranscript(pdmPub, podPub, pdmNonce, podNonce) + if len(tr) != 171 { + t.Fatalf("PDM transcript expected 171 bytes, got %d", len(tr)) + } + if tr[0] != 0x01 { + t.Errorf("type byte should be 0x01 for PDM, got %x", tr[0]) + } + if !bytes.Equal(tr[1:7], FirmwareID) { + t.Errorf("PDM transcript should have FIRMWARE_ID at bytes 1-6") + } +} diff --git a/pkg/pair/spsnonce.go b/pkg/pair/spsnonce.go new file mode 100644 index 0000000..681172c --- /dev/null +++ b/pkg/pair/spsnonce.go @@ -0,0 +1,82 @@ +package pair + +import ( + "encoding/binary" + "fmt" +) + +// SPSDirection identifies which direction a pairing AES-CCM nonce is for. +type SPSDirection byte + +const ( + // SPSWrite is PDM->Pod direction; the leading byte is 0x01 and the + // nonce halves are pdmNonce[0:6] || podNonce[0:6]. + SPSWrite SPSDirection = 0x01 + // SPSRead is Pod->PDM direction; the leading byte is 0x02 and the + // nonce halves are podNonce[0:6] || pdmNonce[0:6]. + SPSRead SPSDirection = 0x02 +) + +// spsNonceState tracks the per-side counters that participate in the SPS-nonce +// builder. The first 8 bytes of the 16-byte pdm/pod nonces from SPS1 are +// treated as a little-endian uint64 counter that increments after every SPS +// message in the matching direction. +// +// Mirrors O5KeyExchange.swift:138-162 (incrementNonceInPlace / getSPSNonce). +type spsNonceState struct { + pdm []byte // 16 bytes + pod []byte // 16 bytes +} + +func newSPSNonceState(pdmNonce, podNonce []byte) (*spsNonceState, error) { + if len(pdmNonce) != 16 || len(podNonce) != 16 { + return nil, fmt.Errorf("spsNonce: pdmNonce and podNonce must be 16 bytes each (got %d, %d)", len(pdmNonce), len(podNonce)) + } + s := &spsNonceState{ + pdm: make([]byte, 16), + pod: make([]byte, 16), + } + copy(s.pdm, pdmNonce) + copy(s.pod, podNonce) + return s, nil +} + +// build returns a fresh 13-byte SPS-nonce for the given direction. +// +// write: [0x01] || pdmNonce[0:6] || podNonce[0:6] +// read: [0x02] || podNonce[0:6] || pdmNonce[0:6] +// +// Mirrors O5KeyExchange.swift:112-136. +func (s *spsNonceState) build(dir SPSDirection) []byte { + out := make([]byte, 0, 13) + out = append(out, byte(dir)) + switch dir { + case SPSWrite: + out = append(out, s.pdm[:6]...) + out = append(out, s.pod[:6]...) + case SPSRead: + out = append(out, s.pod[:6]...) + out = append(out, s.pdm[:6]...) + default: + panic(fmt.Sprintf("spsNonce: invalid direction %d", dir)) + } + return out +} + +// increment bumps the per-direction counter. The first 8 bytes of the matching +// nonce are interpreted as a little-endian uint64 and incremented by 1; the +// last 8 bytes are left unchanged. Mirrors O5KeyExchange.swift:152-162. +func (s *spsNonceState) increment(dir SPSDirection) { + var target []byte + switch dir { + case SPSWrite: + target = s.pdm + case SPSRead: + target = s.pod + default: + panic(fmt.Sprintf("spsNonce: invalid direction %d", dir)) + } + v := binary.LittleEndian.Uint64(target[:8]) + v++ + binary.LittleEndian.PutUint64(target[:8], v) +} diff --git a/pkg/pair/spsnonce_test.go b/pkg/pair/spsnonce_test.go new file mode 100644 index 0000000..60191bd --- /dev/null +++ b/pkg/pair/spsnonce_test.go @@ -0,0 +1,63 @@ +package pair + +import ( + "bytes" + "testing" +) + +func TestSPSNonceLayout(t *testing.T) { + pdm := bytes.Repeat([]byte{0xAA}, 16) + pod := bytes.Repeat([]byte{0xBB}, 16) + s, err := newSPSNonceState(pdm, pod) + if err != nil { + t.Fatal(err) + } + + w := s.build(SPSWrite) + if len(w) != 13 { + t.Fatalf("write nonce expected 13 bytes, got %d", len(w)) + } + if w[0] != 0x01 { + t.Errorf("write nonce[0] = %x, want 0x01", w[0]) + } + if !bytes.Equal(w[1:7], bytes.Repeat([]byte{0xAA}, 6)) { + t.Errorf("write nonce should start with pdm[0:6]: got %x", w[1:7]) + } + if !bytes.Equal(w[7:13], bytes.Repeat([]byte{0xBB}, 6)) { + t.Errorf("write nonce should end with pod[0:6]: got %x", w[7:13]) + } + + r := s.build(SPSRead) + if r[0] != 0x02 { + t.Errorf("read nonce[0] = %x, want 0x02", r[0]) + } + if !bytes.Equal(r[1:7], bytes.Repeat([]byte{0xBB}, 6)) { + t.Errorf("read nonce should start with pod[0:6]: got %x", r[1:7]) + } +} + +func TestSPSNonceIncrementsLittleEndianFirst8(t *testing.T) { + pdm := []byte{0xfe, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + pod := bytes.Repeat([]byte{0}, 16) + s, err := newSPSNonceState(pdm, pod) + if err != nil { + t.Fatal(err) + } + + // 0x000000000000fffe + 1 == 0x000000000000ffff + s.increment(SPSWrite) + if s.pdm[0] != 0xff || s.pdm[1] != 0xff { + t.Fatalf("after 1 increment expected first 2 bytes ff ff, got %x %x", s.pdm[0], s.pdm[1]) + } + // + 1 == 0x0000000000010000 (carry) + s.increment(SPSWrite) + if s.pdm[0] != 0x00 || s.pdm[1] != 0x00 || s.pdm[2] != 0x01 { + t.Fatalf("after carry expected 00 00 01, got %x %x %x", s.pdm[0], s.pdm[1], s.pdm[2]) + } + // last 8 bytes must be untouched + for i := 8; i < 16; i++ { + if s.pdm[i] != 0 { + t.Errorf("byte %d should be 0, got %x", i, s.pdm[i]) + } + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index b8ce737..7f13f35 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -89,6 +89,25 @@ func (p *Pod) notifyStateChange() { } } +// ensurePodIdentity returns the pod's stable P-256 keypair + self-signed cert, +// generating and persisting it on first use. Each pod simulator instance has +// a stable cryptographic identity once activated, mirroring real pods. +func (p *Pod) ensurePodIdentity() (*pair.PodIdentity, error) { + if len(p.state.O5PrivateKey) > 0 && len(p.state.O5CertDER) > 0 { + return pair.LoadPodIdentity(p.state.O5PrivateKey, p.state.O5CertDER) + } + id, err := pair.NewPodIdentity() + if err != nil { + return nil, err + } + p.state.O5PrivateKey = id.PrivateScalar() + p.state.O5CertDER = id.CertDER + if err := p.state.Save(); err != nil { + log.Warnf("pkg pod; could not persist pod identity: %s", err) + } + return id, nil +} + func (p *Pod) StartAcceptingCommands() { log.Infof("pkg pod; Listening for commands") firstCmd, _ := p.ble.ReadCmd() @@ -105,30 +124,53 @@ func (p *Pod) StartAcceptingCommands() { func (p *Pod) StartActivation() { - pair := &pair.Pair{Mode: p.pairMode} + pairCtx := &pair.Pair{Mode: p.pairMode} + + if pairCtx.IsO5() { + identity, err := p.ensurePodIdentity() + if err != nil { + log.Fatalf("pkg pod; could not load/create pod identity: %s", err) + } + pairCtx.SetIdentity(identity) + } + msg, _ := p.ble.ReadMessage() - if err := pair.ParseSP1SP2(msg); err != nil { + if err := pairCtx.ParseSP1SP2(msg); err != nil { log.Fatalf("pkg pod; error parsing SP1SP2 %s", err) } // read PDM public key and nonce msg, _ = p.ble.ReadMessage() - if err := pair.ParseSPS1(msg); err != nil { + if err := pairCtx.ParseSPS1(msg); err != nil { log.Fatalf("pkg pod; error parsing SPS1 %s", err) } - msg, err := pair.GenerateSPS1() + msg, err := pairCtx.GenerateSPS1() if err != nil { log.Fatal(err) } // send POD public key and nonce p.ble.WriteMessage(msg) + if pairCtx.IsO5() { + // O5 inserts SPS2.1 (intermediate-CA cert exchange) between SPS1 and SPS2. + msg, _ = p.ble.ReadMessage() + if err := pairCtx.ParseSPS21(msg); err != nil { + log.Fatalf("pkg pod; error parsing SPS2.1 %s", err) + } + + msg, err = pairCtx.GenerateSPS21() + if err != nil { + log.Fatal(err) + } + p.ble.WriteMessage(msg) + } + // read PDM conf value msg, _ = p.ble.ReadMessage() - pair.ParseSPS2(msg) + pairCtx.ParseSPS2(msg) // send POD conf value - msg, err = pair.GenerateSPS2() + msg, err = pairCtx.GenerateSPS2() if err != nil { log.Fatal(err) } @@ -136,19 +178,19 @@ func (p *Pod) StartActivation() { // receive SP0GP0 constant from PDM msg, _ = p.ble.ReadMessage() - err = pair.ParseSP0GP0(msg) + err = pairCtx.ParseSP0GP0(msg) if err != nil { log.Fatalf("pkg pod; could not parse SP0GP0: %s", err) } // send P0 constant - msg, err = pair.GenerateP0() + msg, err = pairCtx.GenerateP0() if err != nil { log.Fatal(err) } p.ble.WriteMessage(msg) - p.state.LTK, err = pair.LTK() + p.state.LTK, err = pairCtx.LTK() if err != nil { log.Fatalf("pkg pod; could not get LTK %s", err) } diff --git a/pkg/pod/state.go b/pkg/pod/state.go index 0467a3c..cc98b81 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -25,6 +25,14 @@ type PODState struct { NoncePrefix []byte `toml:"nonce_prefix"` CK []byte `toml:"ck"` + // O5 pairing identity: a P-256 ECDSA private key (raw 32-byte scalar) + // and a self-signed DER X.509 certificate wrapping the matching public + // key. Used to sign the SPS2 channel-binding transcript and to satisfy + // OmnipodKit's `extractP256PublicKey(fromDERCert:)`. Generated once on + // first activation and reused thereafter. + O5PrivateKey []byte `toml:"o5_private_key"` + O5CertDER []byte `toml:"o5_cert_der"` + PodProgress response.PodProgress ActivationTime time.Time `toml:"activation_time"` From e7ad48311d87db21a0bce8d1ab5a8b212a8bad54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:45:00 +0000 Subject: [PATCH 06/18] aid: handle Omnipod 5 setup commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the pkg/aid package: parses and responds to the nine Algorithm Integration Device (AID) Phase-1 setup commands that fire between AssignAddress and SetupPod during O5 pairing (UTC time, TDI, target BG profile, DIA, EGV, three batches of insulin history, status queries). AID commands are plain-ASCII payloads carried inside the same AES-CCM Type-1 encrypted transport as standard commands — they are NOT SLPE wrapped, so they require their own dispatcher. The aid.IsAIDPayload function distinguishes them by checking for a non-"0" feature prefix; aid.Parse decodes them length-anchored so the trailing ",G." suffix on SET+GET commands works binary-safely; cmd.BuildResponse emits canned Gen1 Status (28B), Unified Status (29B), echoes data on SET+GET, and returns "0" ack for Extended SET. pod.CommandLoop gets a routing branch after decryption: when aid.IsAIDPayload matches, hand off to handleAIDCommand and continue — the standard command.Unmarshal path is untouched. The branch is gated purely on payload shape, so Dash sessions (which never send AID commands) flow through exactly as before. handleAIDCommand encrypts the response via the existing encrypt.EncryptMessage path, sends it, and reads the controller's empty-payload ACK to keep nonces in sync, mirroring the standard post-response flow at the bottom of CommandLoop. Note: handleAIDCommand deliberately does NOT call notifyStateChange() while holding p.mtx — the CommandLoop's existing post-unlock call handles that. This matches the post-acbfbb2 layout (the deadlock fix that lands later in a runtime-fixes commit). e4bd7c3 already shipped the fixed form, so it's ported verbatim. Source: jwoglom/five commit e4bd7c3 (Step 4). --- pkg/aid/aid.go | 220 ++++++++++++++++++++++++++++++++++++++++++++ pkg/aid/aid_test.go | 150 ++++++++++++++++++++++++++++++ pkg/pod/pod.go | 60 ++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 pkg/aid/aid.go create mode 100644 pkg/aid/aid_test.go diff --git a/pkg/aid/aid.go b/pkg/aid/aid.go new file mode 100644 index 0000000..99fa5f8 --- /dev/null +++ b/pkg/aid/aid.go @@ -0,0 +1,220 @@ +// Package aid implements the Omnipod 5 "AID" (Algorithm Integration Device) +// setup-command exchange that runs between AssignAddress and SetupPod during +// pairing. +// +// On the wire, AID commands and responses are *plain ASCII* (no +// StringLengthPrefixEncoding wrapper) carried inside the same AES-CCM +// encrypted Type-1 transport that standard Omnipod commands use. The +// decrypted payload looks like one of: +// +// "S.=,G." // SET+GET +// "G." // GET only +// "SE.=" // Extended SET +// +// Where is either ASCII text (e.g. "8" for DIA) or raw binary bytes +// (e.g. for TDI / target BG profile). Responses use a matching prefix: +// +// SET+GET / GET response: ".=" +// Extended SET response: "ES.=0" +// +// Source: OmnipodKit O5AidCommands.swift and BleMessageTransport.swift +// (sendO5AidCommand). +package aid + +import ( + "bytes" + "errors" + "fmt" + "strings" +) + +// Kind is the structural form of an AID command on the wire. +type Kind int + +const ( + KindSetGet Kind = iota // S.=,G. + KindGet // G. + KindExtSet // SE.= +) + +// Command is a parsed AID command from the controller. +type Command struct { + Kind Kind + Feature string // e.g. "3", "255", "2" + Attribute string // e.g. "1", "2", "11", "12" + Data []byte // empty for KindGet +} + +// IsAIDPayload returns true if `payload` looks like an AID command rather than +// a standard SLPE-wrapped Omnipod command (which would start with "S0.0="). +// +// AID command first byte is always ASCII 'S' or 'G'. SLPE-wrapped Omnipod +// commands also start with 'S' (`S0.0=`), so we look at the feature byte: +// AID always uses non-zero feature numbers, while standard commands use +// feature "0". +func IsAIDPayload(payload []byte) bool { + if len(payload) < 4 { + return false + } + switch payload[0] { + case 'G': + // "G." — anything that looks like that is AID. + return payload[1] >= '0' && payload[1] <= '9' + case 'S': + // Standard SLPE Omnipod commands begin with literal "S0.0=" with a + // length prefix following. AID commands begin with "S." or + // "SE." where is something other than just "0". + if payload[1] == 'E' { + return true + } + // Distinguish "S0.0=" (standard) from "S.=" / "SE..." (AID). + // Read up to the first '.' and check whether the feature is "0". + dot := bytes.IndexByte(payload, '.') + if dot < 1 || dot > 5 { + return false + } + feature := string(payload[1:dot]) + return feature != "0" + } + return false +} + +// Parse decodes a decrypted AID payload. +// +// The returned Command's Data is a sub-slice of payload — copy it if the +// caller wants to retain it past the next read. +func Parse(payload []byte) (*Command, error) { + if len(payload) < 4 { + return nil, fmt.Errorf("aid: payload too short (%d bytes)", len(payload)) + } + + // Detect Extended SET first: "SE.=" + if bytes.HasPrefix(payload, []byte("SE")) { + eq := bytes.IndexByte(payload, '=') + if eq < 0 { + return nil, errors.New("aid: SE command missing '='") + } + f, a, err := splitFeatureAttr(string(payload[2:eq])) + if err != nil { + return nil, fmt.Errorf("aid: SE: %w", err) + } + return &Command{Kind: KindExtSet, Feature: f, Attribute: a, Data: payload[eq+1:]}, nil + } + + // SET+GET: "S.=,G." + if payload[0] == 'S' { + eq := bytes.IndexByte(payload, '=') + if eq < 0 { + return nil, errors.New("aid: S command missing '='") + } + f, a, err := splitFeatureAttr(string(payload[1:eq])) + if err != nil { + return nil, fmt.Errorf("aid: S: %w", err) + } + // Locate ",G." suffix — search for ',' and verify the rest + // matches the SET feature/attribute. Binary data may legally contain + // commas, so we anchor by length: the suffix is exactly + // ",G." and nothing follows it. + suffix := []byte(",G" + f + "." + a) + if !bytes.HasSuffix(payload, suffix) { + return nil, fmt.Errorf("aid: S command missing trailing %q", string(suffix)) + } + data := payload[eq+1 : len(payload)-len(suffix)] + return &Command{Kind: KindSetGet, Feature: f, Attribute: a, Data: data}, nil + } + + // GET: "G." + if payload[0] == 'G' { + f, a, err := splitFeatureAttr(string(payload[1:])) + if err != nil { + return nil, fmt.Errorf("aid: G: %w", err) + } + return &Command{Kind: KindGet, Feature: f, Attribute: a}, nil + } + + return nil, fmt.Errorf("aid: unrecognised payload prefix %q", string(payload[:1])) +} + +func splitFeatureAttr(s string) (string, string, error) { + dot := strings.IndexByte(s, '.') + if dot < 1 || dot == len(s)-1 { + return "", "", fmt.Errorf("malformed feature.attribute %q", s) + } + return s[:dot], s[dot+1:], nil +} + +// ResponsePrefix is the ASCII prefix the pod must emit in its response. +// Source: O5AidCommands.swift responsePrefix / extendedSetResponsePrefix. +func (c *Command) ResponsePrefix() string { + if c.Kind == KindExtSet { + return "ES" + c.Feature + "." + c.Attribute + "=" + } + return c.Feature + "." + c.Attribute + "=" +} + +// Encode renders the command back to wire bytes. Used in tests. +func (c *Command) Encode() []byte { + switch c.Kind { + case KindSetGet: + out := []byte("S" + c.Feature + "." + c.Attribute + "=") + out = append(out, c.Data...) + out = append(out, []byte(",G"+c.Feature+"."+c.Attribute)...) + return out + case KindGet: + return []byte("G" + c.Feature + "." + c.Attribute) + case KindExtSet: + out := []byte("SE" + c.Feature + "." + c.Attribute + "=") + out = append(out, c.Data...) + return out + } + panic(fmt.Sprintf("aid: unknown kind %d", c.Kind)) +} + +// BuildResponse generates the pod's response payload for a parsed command. +// +// For now we implement plausible canned responses sufficient to satisfy +// OmnipodKit's activation flow, without modeling the underlying state: +// +// - SE.=... -> "ES.=0" (ack) +// - SET+GET S.= -> ".=" + echoed data +// - GET G. -> ".=" + canned per-attribute payload +// +// The opaque byte payloads we return for GET commands are crafted to match the +// shapes captured in `Omnipod5APK/BTSNOOP/ios_snoop2/comm1.log`. Real-pod +// fidelity (returning state-derived values) is the job of Step 5/7. +func (c *Command) BuildResponse() []byte { + prefix := c.ResponsePrefix() + switch c.Kind { + case KindExtSet: + return []byte(prefix + "0") + case KindSetGet: + out := []byte(prefix) + out = append(out, c.Data...) + return out + case KindGet: + body := cannedGetResponse(c.Feature, c.Attribute) + out := []byte(prefix) + out = append(out, body...) + return out + } + return nil +} + +// cannedGetResponse returns a placeholder body for AID GET commands. Phase-1 +// activation only cares that the response *exists* and starts with the right +// prefix; OmnipodKit logs the raw bytes and continues. Sizes mirror what the +// captures show, so on-the-wire framing matches. +func cannedGetResponse(feature, attribute string) []byte { + switch feature + "." + attribute { + case "3.11": + // Gen1 AID Pod Status: 28-byte body preceded by 2-byte length 0x001c. + body := make([]byte, 30) + body[0] = 0x00 + body[1] = 0x1c + return body + case "3.12": + // Unified AID Pod Status: 29 bytes. + return make([]byte, 29) + } + return nil +} diff --git a/pkg/aid/aid_test.go b/pkg/aid/aid_test.go new file mode 100644 index 0000000..60c4c5a --- /dev/null +++ b/pkg/aid/aid_test.go @@ -0,0 +1,150 @@ +package aid + +import ( + "bytes" + "testing" +) + +func TestIsAIDPayload(t *testing.T) { + cases := []struct { + name string + data []byte + want bool + }{ + {"standard SLPE", []byte{'S', '0', '.', '0', '=', 0x00, 0x10}, false}, + {"AID SET+GET", []byte("S3.2=hello,G3.2"), true}, + {"AID GET", []byte("G3.11"), true}, + {"AID Extended SET", []byte("SE255.2=12345"), true}, + {"AID GET 3.12", []byte("G3.12"), true}, + {"too short", []byte{'S'}, false}, + {"random bytes", []byte{0x01, 0x02, 0x03, 0x04}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsAIDPayload(tc.data); got != tc.want { + t.Errorf("IsAIDPayload(%q) = %v, want %v", tc.data, got, tc.want) + } + }) + } +} + +func TestParseSetGet_ASCII(t *testing.T) { + c, err := Parse([]byte("S3.9=8,G3.9")) + if err != nil { + t.Fatal(err) + } + if c.Kind != KindSetGet || c.Feature != "3" || c.Attribute != "9" || string(c.Data) != "8" { + t.Fatalf("got %+v / %q", c, string(c.Data)) + } +} + +func TestParseSetGet_Binary(t *testing.T) { + bin := []byte{0x00, 0x03, 0x00, 0x0E, 0x00} + payload := []byte("S3.2=") + payload = append(payload, bin...) + payload = append(payload, []byte(",G3.2")...) + c, err := Parse(payload) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(c.Data, bin) { + t.Fatalf("data mismatch: %x vs %x", c.Data, bin) + } +} + +func TestParseSetGet_BinaryWithEmbeddedComma(t *testing.T) { + // Binary blob can contain commas; the trailing ",G3.2" suffix is + // length-anchored, not value-anchored. + bin := []byte{0x00, ',', 'G', 0x03, ',', 'X'} + payload := []byte("S3.2=") + payload = append(payload, bin...) + payload = append(payload, []byte(",G3.2")...) + c, err := Parse(payload) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(c.Data, bin) { + t.Fatalf("data mismatch: %x vs %x", c.Data, bin) + } +} + +func TestParseGet(t *testing.T) { + c, err := Parse([]byte("G3.11")) + if err != nil { + t.Fatal(err) + } + if c.Kind != KindGet || c.Feature != "3" || c.Attribute != "11" { + t.Fatalf("got %+v", c) + } + if len(c.Data) != 0 { + t.Errorf("GET should have empty Data") + } +} + +func TestParseExtSet(t *testing.T) { + c, err := Parse([]byte("SE255.2=1234567890")) + if err != nil { + t.Fatal(err) + } + if c.Kind != KindExtSet || c.Feature != "255" || c.Attribute != "2" { + t.Fatalf("got %+v", c) + } + if string(c.Data) != "1234567890" { + t.Errorf("data = %q", string(c.Data)) + } +} + +func TestEncodeRoundTrip(t *testing.T) { + for _, tc := range [][]byte{ + []byte("S3.9=8,G3.9"), + []byte("G3.12"), + []byte("SE255.2=1700000000"), + } { + c, err := Parse(tc) + if err != nil { + t.Fatal(err) + } + got := c.Encode() + if !bytes.Equal(got, tc) { + t.Errorf("round-trip mismatch:\n have: %q\n want: %q", got, tc) + } + } +} + +func TestBuildResponse(t *testing.T) { + cases := []struct { + in []byte + wantHead string + }{ + {[]byte("SE255.2=1700000000"), "ES255.2=0"}, + {[]byte("S3.9=8,G3.9"), "3.9=8"}, + {[]byte("G3.12"), "3.12="}, + } + for _, tc := range cases { + c, err := Parse(tc.in) + if err != nil { + t.Fatal(err) + } + resp := c.BuildResponse() + if !bytes.HasPrefix(resp, []byte(tc.wantHead)) { + t.Errorf("response %q should start with %q", resp, tc.wantHead) + } + } +} + +func TestBuildResponseSetGetEchoesBinary(t *testing.T) { + bin := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0x00} + payload := []byte("S3.2=") + payload = append(payload, bin...) + payload = append(payload, []byte(",G3.2")...) + c, err := Parse(payload) + if err != nil { + t.Fatal(err) + } + resp := c.BuildResponse() + want := []byte("3.2=") + want = append(want, bin...) + if !bytes.Equal(resp, want) { + t.Errorf("expected echo, got %x", resp) + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 7f13f35..e00d9ae 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -5,9 +5,11 @@ import ( "sync" "time" + "github.com/avereha/pod/pkg/aid" "github.com/avereha/pod/pkg/bluetooth" "github.com/avereha/pod/pkg/command" "github.com/avereha/pod/pkg/eap" + "github.com/avereha/pod/pkg/message" "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/encrypt" @@ -89,6 +91,54 @@ func (p *Pod) notifyStateChange() { } } +// handleAIDCommand parses a decrypted AID setup command, builds the matching +// ASCII response, encrypts it, and writes it back to the controller. It also +// handles the post-response ACK round-trip that the existing CommandLoop does +// inline for standard commands. +// +// Caller must hold p.mtx. This function does NOT release the mutex. +func (p *Pod) handleAIDCommand(reqMsg *message.Message, plaintext []byte) { + cmd, err := aid.Parse(plaintext) + if err != nil { + log.Errorf("pkg pod; AID parse failed: %s", err) + return + } + log.Infof("pkg pod; AID %d %s.%s data=%q", cmd.Kind, cmd.Feature, cmd.Attribute, string(cmd.Data)) + + respPayload := cmd.BuildResponse() + log.Infof("pkg pod; AID response: %q", string(respPayload)) + + p.state.MsgSeq++ + rsp := message.NewMessage(message.MessageTypeEncrypted, reqMsg.Destination, reqMsg.Source) + rsp.Payload = respPayload + rsp.SequenceNumber = p.state.MsgSeq + rsp.Ack = true + rsp.AckNumber = reqMsg.SequenceNumber + 1 + rsp.Eqos = 1 + + encrypted, err := encrypt.EncryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, rsp) + if err != nil { + log.Fatalf("pkg pod; AID encrypt response: %s", err) + } + p.state.NonceSeq++ + p.state.Save() + p.ble.WriteMessage(encrypted) + + // Read the controller's ACK and advance the nonce counter so subsequent + // messages stay in sync. + ackMsg, _ := p.ble.ReadMessage() + if ackMsg != nil { + ackPlain, err := encrypt.DecryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, ackMsg) + if err != nil { + log.Warnf("pkg pod; AID ACK decrypt failed: %s", err) + } else if len(ackPlain.Payload) != 0 { + log.Warnf("pkg pod; AID ACK had non-empty payload (%d bytes); ignoring", len(ackPlain.Payload)) + } + p.state.NonceSeq++ + } + p.state.Save() +} + // ensurePodIdentity returns the pod's stable P-256 keypair + self-signed cert, // generating and persisting it on first use. Each pod simulator instance has // a stable cryptographic identity once activated, mirroring real pods. @@ -284,6 +334,16 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { } p.state.NonceSeq++ + // O5 AID setup commands (UTC, TDI, BG profile, DIA, EGV, history, + // pod status) arrive in this same encrypted stream but use a plain + // ASCII key=value protocol instead of SLPE-wrapped Omnipod commands. + // They run between AssignAddress and SetupPod during pairing. + if aid.IsAIDPayload(decrypted.Payload) { + p.handleAIDCommand(msg, decrypted.Payload) + p.mtx.Unlock() + continue + } + cmd, err := command.Unmarshal(decrypted.Payload) if err != nil { log.Fatalf("pkg pod; could not unmarshal command: %s", err) From ff643ad202808c85534f1890935e136fa362bd34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:50:33 +0000 Subject: [PATCH 07/18] message: support Omnipod 5 encrypted+signed frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Type-4 (MessageTypeEncryptedSigned) wire format used by the Omnipod 5 controller for post-pairing commands. Layout: [16-byte TWi header][ciphertext (plaintext-len + 8-byte CCM tag)] [64-byte ECDSA r||s signature] The header's 11-bit length field reflects only the ciphertext, NOT the trailing signature. pkg/message/message.go: - Add Signature []byte to the Message struct. - Marshal: extend the EncryptedPayload fast-path that returns m.Raw to also cover Type-4 (m.Raw already includes the appended signature). - Unmarshal: new Type-4 branch slices ciphertext as data[16:16+n+8] and signature as the next 64 bytes; errors on truncation. - Widen the type-range guard to allow MessageTypeEncryptedSigned. - Fix flag.set: the old form was a no-op when val=false, leaving leftover bits set on reused *flag values. Without this, the type-4 bit pattern could mangle into type-5 when a writer reused a flag buffer. Now clears bits via *f &^= mask. pkg/pair/pair.go: cache the PDM's 64-byte raw P-256 public key (X||Y, left-zero-padded) at ParseSPS2 time by running extractP256PublicKey on the PDM cert. PDMPublicKey() exposes it. pkg/pod/state.go: PDMPublicKey []byte (TOML pdm_public_key) so the cached key survives across simulator runs. pkg/pod/pod.go: - StartActivation: after LTK is set, copy pairCtx.PDMPublicKey() into state and save. - CommandLoop: before decryption, if msg.Type == MessageTypeEncryptedSigned, call pair.VerifyType4Signature(pdmKey, msg.Raw[:16], msg.Payload, msg.Signature). Soft-fail: warn on missing pubkey / malformed sig / verify error / failed verification; log success otherwise. Never fatal, always continue with decryption — transcript layouts can drift across firmware revisions. Tests: TestUnmarshalType4 + TestUnmarshalType4Truncated for the wire layout, TestVerifyType4Signature_RoundTrip + bad-input rejection for the verification helper. Source: jwoglom/five commit 34fc6ad (Step 5). --- pkg/message/message.go | 29 ++++++++++++--- pkg/message/type4_test.go | 78 +++++++++++++++++++++++++++++++++++++++ pkg/pair/pair.go | 21 +++++++++++ pkg/pair/type4_test.go | 64 ++++++++++++++++++++++++++++++++ pkg/pod/pod.go | 25 +++++++++++++ pkg/pod/state.go | 5 +++ 6 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 pkg/message/type4_test.go create mode 100644 pkg/pair/type4_test.go diff --git a/pkg/message/message.go b/pkg/message/message.go index be1009b..5cc5b38 100644 --- a/pkg/message/message.go +++ b/pkg/message/message.go @@ -36,16 +36,25 @@ type Message struct { SequenceNumber uint8 AckNumber uint8 Version uint16 + + // Signature is the 64-byte raw r||s ECDSA signature appended after the + // AES-CCM ciphertext on MessageTypeEncryptedSigned (Type-4) frames. The + // signature is over the 16-byte TWi header concatenated with the + // ciphertext (including the 8-byte CCM tag) — see OmnipodKit + // BleMessageTransport.swift getCmdMessage. The header's `length` field + // reflects only the ciphertext, NOT the signature. + Signature []byte } type flag byte func (f *flag) set(index byte, val bool) { var mask flag = 1 << (7 - index) - if !val { - return + if val { + *f |= mask + } else { + *f &^= mask } - *f |= mask } func (f flag) get(index byte) byte { @@ -70,7 +79,9 @@ func NewMessage(t MessageType, source, destination []byte) *Message { func (m *Message) Marshal() ([]byte, error) { var buf bytes.Buffer - if m.Type == MessageTypeEncrypted && m.EncryptedPayload { // Already encrypted + if (m.Type == MessageTypeEncrypted || m.Type == MessageTypeEncryptedSigned) && m.EncryptedPayload { + // Already encrypted; for Type-4, m.Raw is expected to include the + // 64-byte ECDSA signature appended after the ciphertext. return m.Raw, nil } @@ -144,7 +155,7 @@ func Unmarshal(data []byte) (*Message, error) { ret.Gateway = f.get(3) == 1 ret.Type = MessageType(f.get(7) | f.get(6)<<1 | f.get(5)<<2 | f.get(4)<<3) - if ret.Type > MessageTypePairing { + if ret.Type > MessageTypeEncryptedSigned { return nil, fmt.Errorf("invalid message type found in %x", data) } if ret.Version != 0 { @@ -166,6 +177,14 @@ func Unmarshal(data []byte) (*Message, error) { if ret.Type == MessageTypeEncrypted { ret.Payload = data[16 : 16+int(n)+8] ret.EncryptedPayload = true + } else if ret.Type == MessageTypeEncryptedSigned { + end := 16 + int(n) + 8 + if end+64 > len(data) { + return nil, fmt.Errorf("Type-4 frame truncated: need %d bytes, have %d", end+64, len(data)) + } + ret.Payload = data[16:end] + ret.Signature = data[end : end+64] + ret.EncryptedPayload = true } else { ret.Payload = data[16 : 16+int(n)] } diff --git a/pkg/message/type4_test.go b/pkg/message/type4_test.go new file mode 100644 index 0000000..8a7511e --- /dev/null +++ b/pkg/message/type4_test.go @@ -0,0 +1,78 @@ +package message + +import ( + "bytes" + "testing" +) + +// TestUnmarshalType4 builds a synthetic Type-4 wire frame and asserts that +// Unmarshal correctly splits the AES-CCM ciphertext (length-from-header + 8B +// tag) from the trailing 64-byte ECDSA signature. +// +// Wire layout: +// +// [16-byte header (length field = plaintext length)] +// [plaintext-length bytes ciphertext] +// [8-byte CCM tag] +// [64-byte ECDSA signature] +func TestUnmarshalType4(t *testing.T) { + src := []byte{0x01, 0x02, 0x03, 0x04} + dst := []byte{0x05, 0x06, 0x07, 0x08} + plaintextLen := 16 + + // Use Marshal with a plaintext-only Payload so the header's length + // field gets set to plaintextLen. + m := &Message{ + Type: MessageTypeEncryptedSigned, + Source: src, + Destination: dst, + SequenceNumber: 7, + Eqos: 1, + Payload: bytes.Repeat([]byte{0xCC}, plaintextLen), + } + headerAndPlaintext, err := m.Marshal() + if err != nil { + t.Fatal(err) + } + + tag := bytes.Repeat([]byte{0x99}, 8) + signature := bytes.Repeat([]byte{0xAB}, 64) + wire := append([]byte{}, headerAndPlaintext...) + wire = append(wire, tag...) + wire = append(wire, signature...) + + got, err := Unmarshal(wire) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Type != MessageTypeEncryptedSigned { + t.Fatalf("type = %d, want %d (raw bytes %x)", got.Type, MessageTypeEncryptedSigned, wire[:8]) + } + wantPayload := append(append([]byte{}, m.Payload...), tag...) + if !bytes.Equal(got.Payload, wantPayload) { + t.Errorf("payload mismatch:\n got %x\n want %x", got.Payload, wantPayload) + } + if !bytes.Equal(got.Signature, signature) { + t.Errorf("signature mismatch:\n got %x\n want %x", got.Signature, signature) + } +} + +func TestUnmarshalType4Truncated(t *testing.T) { + m := &Message{ + Type: MessageTypeEncryptedSigned, + Source: []byte{1, 2, 3, 4}, + Destination: []byte{5, 6, 7, 8}, + SequenceNumber: 1, + Payload: bytes.Repeat([]byte{0xCC}, 16), + } + headerAndPlaintext, err := m.Marshal() + if err != nil { + t.Fatal(err) + } + wire := append([]byte{}, headerAndPlaintext...) + wire = append(wire, bytes.Repeat([]byte{0x99}, 8)...) // tag + wire = append(wire, bytes.Repeat([]byte{0xAB}, 32)...) // partial sig + if _, err := Unmarshal(wire); err == nil { + t.Fatal("expected error for truncated Type-4 frame") + } +} diff --git a/pkg/pair/pair.go b/pkg/pair/pair.go index 208c11d..08b76ca 100644 --- a/pkg/pair/pair.go +++ b/pkg/pair/pair.go @@ -61,8 +61,18 @@ type Pair struct { nonceState *spsNonceState identity *PodIdentity pdmCertDER []byte + + // O5-only: raw P-256 public key (64 bytes X||Y) extracted from the + // PDM's TLS certificate during SPS2 parsing. Used post-pairing to + // verify ECDSA signatures on Type-4 commands (programBolus / programBasal). + pdmPublicKey []byte } +// PDMPublicKey returns the 64-byte raw P-256 public key extracted from the +// PDM's TLS certificate during SPS2, or nil if pairing has not yet completed +// in O5 mode (or never reached SPS2 successfully). +func (c *Pair) PDMPublicKey() []byte { return c.pdmPublicKey } + // SetIdentity attaches a P-256 keypair + self-signed cert that the pod uses // to sign the SPS2 channel-binding transcript. Required in O5 mode before // SPS2.1/SPS2 are processed. @@ -197,6 +207,17 @@ func (c *Pair) ParseSPS2(msg *message.Message) error { log.Infof("Decrypted PDM SPS2: %d-byte cert + 64-byte sig", len(certDER)) c.pdmCertDER = certDER + // Cache the PDM TLS pubkey for post-pairing Type-4 verification. + if pub, perr := extractP256PublicKey(certDER); perr == nil { + c.pdmPublicKey = make([]byte, 64) + x := pub.X.Bytes() + y := pub.Y.Bytes() + copy(c.pdmPublicKey[32-len(x):32], x) + copy(c.pdmPublicKey[64-len(y):64], y) + } else { + log.Warnf("Could not extract PDM pubkey from SPS2 cert: %v", perr) + } + ok, vErr := verifyTranscript(certDER, transcript, sig) if vErr != nil { log.Warnf("PDM SPS2 signature verification setup failed: %v (continuing)", vErr) diff --git a/pkg/pair/type4_test.go b/pkg/pair/type4_test.go new file mode 100644 index 0000000..d5fafbb --- /dev/null +++ b/pkg/pair/type4_test.go @@ -0,0 +1,64 @@ +package pair + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "testing" +) + +// TestVerifyType4Signature_RoundTrip mirrors the controller-side signing +// behaviour from BleMessageTransport.swift: signing input is +// [16-byte header || ciphertext]. Pod-side verification must accept that +// exact input. +func TestVerifyType4Signature_RoundTrip(t *testing.T) { + id, err := NewPodIdentity() + if err != nil { + t.Fatal(err) + } + header := make([]byte, 16) + for i := range header { + header[i] = byte(i) + } + ciphertext := []byte("pretend-this-is-AES-CCM-ciphertext-plus-tag-bytes") + + signingInput := append([]byte{}, header...) + signingInput = append(signingInput, ciphertext...) + digest := sha256.Sum256(signingInput) + r, s, err := ecdsa.Sign(rand.Reader, id.PrivateKey, digest[:]) + if err != nil { + t.Fatal(err) + } + sig := packRS(r, s) + + ok, err := VerifyType4Signature(id.PublicKeyRaw(), header, ciphertext, sig) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("signature should verify") + } + + // Tampered ciphertext should fail. + ciphertext[0] ^= 0xff + ok, err = VerifyType4Signature(id.PublicKeyRaw(), header, ciphertext, sig) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("signature should not verify after ciphertext tamper") + } +} + +func TestVerifyType4Signature_BadInputs(t *testing.T) { + good := make([]byte, 64) + if _, err := VerifyType4Signature(make([]byte, 63), make([]byte, 16), nil, good); err == nil { + t.Error("short pubkey should error") + } + if _, err := VerifyType4Signature(good, make([]byte, 16), nil, make([]byte, 63)); err == nil { + t.Error("short signature should error") + } + if _, err := VerifyType4Signature(good, make([]byte, 8), nil, good); err == nil { + t.Error("short header should error") + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index e00d9ae..52f7b13 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -245,6 +245,10 @@ func (p *Pod) StartActivation() { log.Fatalf("pkg pod; could not get LTK %s", err) } log.Infof("pkg pod; LTK %x", p.state.LTK) + if pdmKey := pairCtx.PDMPublicKey(); pdmKey != nil { + p.state.PDMPublicKey = pdmKey + log.Infof("pkg pod; PDM TLS pubkey cached for Type-4 verification") + } p.state.EapAkaSeq = 1 p.state.Save() @@ -328,6 +332,27 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { // Lock mutex before we start using/modifying state p.mtx.Lock() + // Type-4 (ENCRYPTED_SIGNED): verify the controller's ECDSA + // signature over [16-byte header || ciphertext-with-tag] before + // decrypting. Soft-fail with a warning so transcript-layout drift + // doesn't block activation while we iterate. + if msg.Type == message.MessageTypeEncryptedSigned { + if len(p.state.PDMPublicKey) != 64 { + log.Warnf("pkg pod; received Type-4 command but no cached PDM pubkey; skipping signature check") + } else if len(msg.Signature) != 64 { + log.Warnf("pkg pod; Type-4 message has malformed signature length %d", len(msg.Signature)) + } else { + ok, vErr := pair.VerifyType4Signature(p.state.PDMPublicKey, msg.Raw[:16], msg.Payload, msg.Signature) + if vErr != nil { + log.Warnf("pkg pod; Type-4 signature verify error: %s", vErr) + } else if !ok { + log.Warnf("pkg pod; Type-4 signature verification FAILED (continuing)") + } else { + log.Infof("pkg pod; Type-4 signature verified") + } + } + } + decrypted, err := encrypt.DecryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, msg) if err != nil { log.Fatalf("pkg pod; could not decrypt message: %s", err) diff --git a/pkg/pod/state.go b/pkg/pod/state.go index cc98b81..35d6a20 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -33,6 +33,11 @@ type PODState struct { O5PrivateKey []byte `toml:"o5_private_key"` O5CertDER []byte `toml:"o5_cert_der"` + // PDMPublicKey is the 64-byte raw P-256 public key (X||Y) extracted from + // the PDM's TLS leaf cert during SPS2. Used to verify ECDSA signatures + // on inbound Type-4 commands (programBolus, programBasal). + PDMPublicKey []byte `toml:"pdm_public_key"` + PodProgress response.PodProgress ActivationTime time.Time `toml:"activation_time"` From 52753ec6e37cc9285f56a150e75ac5974765d292 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 18:56:16 +0000 Subject: [PATCH 08/18] response: add Omnipod 5 identity and version responses Land the plumbing for mode-aware SetUniqueID (0x03) and GetVersion (0x07) responses without changing any byte output today. When the real Omnipod 5 byte captures arrive from Joe, swapping the O5 constants will be a one-line change with the regression tests already in place to catch any accidental Dash drift. Plumbing: - pkg/pod/state.go: persist Mode (pair.Mode) in PODState (TOML key mode). Pod.New writes state.Mode = pairMode on every launch so the -mode flag is the source of truth for the current run. - pkg/command/command.go: optional ResponseForMode interface that any Command can implement to return mode-specific bytes. Existing Command interface is unchanged so the change ripples nowhere else. - pkg/pod/pod.go CommandLoop: when cmd.IsResponseHardcoded(), type-assert for ResponseForMode and prefer it; fall through to GetResponse() for every other hardcoded command. - pkg/command/{setuniqueid,getversion}.go: implement GetResponseForMode, threading the Mode through to the response struct. - pkg/response/{setuniqueidresponse,versionresponse}.go: add Mode field, split the hex constant into named dash*ResponseHex + o5*ResponseHex (today identical, TODO(joe) comment marks the swap point). Marshal selects on Mode; zero value (ModeDash) preserves legacy behaviour for any caller still constructing the struct with bare {}. Tests pin both Dash hex strings against the captured byte sequences on current main and verify the zero-value path is Dash, so any future change to the legacy constants must consciously update the regression. O5 tests mirror the Dash bytes today with parallel TODO(joe) markers so the assertion will be updated alongside the real-bytes swap. Dash bit-for-bit unchanged. --- pkg/command/command.go | 8 +++ pkg/command/getversion.go | 8 +++ pkg/command/setuniqueid.go | 8 +++ pkg/pod/pod.go | 18 ++++++- pkg/pod/state.go | 6 +++ pkg/response/setuniqueidresponse.go | 24 ++++++++- pkg/response/setuniqueidresponse_test.go | 66 ++++++++++++++++++++++++ pkg/response/versionresponse.go | 24 ++++++++- pkg/response/versionresponse_test.go | 56 ++++++++++++++++++++ 9 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 pkg/response/setuniqueidresponse_test.go create mode 100644 pkg/response/versionresponse_test.go diff --git a/pkg/command/command.go b/pkg/command/command.go index 995258a..a4ed35a 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -3,6 +3,7 @@ package command import ( "fmt" + "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/response" log "github.com/sirupsen/logrus" @@ -58,6 +59,13 @@ type Command interface { GetSeq() uint8 } +// ResponseForMode is an optional interface a Command may implement when it +// returns different response bytes depending on the pairing mode (Dash vs O5). +// Commands that don't implement this fall back to GetResponse(). +type ResponseForMode interface { + GetResponseForMode(mode pair.Mode) (response.Response, error) +} + type CommandReader struct { Data []byte // keep it simple for now } diff --git a/pkg/command/getversion.go b/pkg/command/getversion.go index ad3cf0e..ffdbc2c 100644 --- a/pkg/command/getversion.go +++ b/pkg/command/getversion.go @@ -3,6 +3,7 @@ package command import ( "fmt" + "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/response" log "github.com/sirupsen/logrus" ) @@ -41,6 +42,13 @@ func (g *GetVersion) GetResponse() (response.Response, error) { return &response.VersionResponse{}, nil } +// GetResponseForMode returns the GetVersion response stamped with the active +// pairing mode so the response layer can pick the right byte stream. Until +// Joe's O5 captures land, both modes yield the same bytes. +func (g *GetVersion) GetResponseForMode(mode pair.Mode) (response.Response, error) { + return &response.VersionResponse{Mode: mode}, nil +} + func (g *GetVersion) SetHeaderData(seq uint8, id []byte) error { g.ID = id g.Seq = seq diff --git a/pkg/command/setuniqueid.go b/pkg/command/setuniqueid.go index 2180120..3d8a233 100644 --- a/pkg/command/setuniqueid.go +++ b/pkg/command/setuniqueid.go @@ -1,6 +1,7 @@ package command import ( + "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/response" log "github.com/sirupsen/logrus" ) @@ -37,6 +38,13 @@ func (g *SetUniqueID) GetResponse() (response.Response, error) { return &response.SetUniqueID{}, nil } +// GetResponseForMode returns the SetUniqueID response stamped with the active +// pairing mode so the response layer can pick the right byte stream. Until +// Joe's O5 captures land, both modes yield the same bytes. +func (g *SetUniqueID) GetResponseForMode(mode pair.Mode) (response.Response, error) { + return &response.SetUniqueID{Mode: mode}, nil +} + func (g *SetUniqueID) SetHeaderData(seq uint8, id []byte) error { g.ID = id g.Seq = seq diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 52f7b13..1c0b1b3 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -57,6 +57,15 @@ func New(ble *bluetooth.Ble, stateFile string, freshState bool, pairMode pair.Mo } } + // Mirror the active pair mode onto persisted state so response-layer + // code (which doesn't see the Pod struct) can pick the right Dash/O5 + // byte stream. Always overwrite: the -mode flag is the source of + // truth for the current run. + state.Mode = pairMode + if err := state.Save(); err != nil { + log.Warnf("pkg pod; could not persist pair mode to state: %s", err) + } + ret := &Pod{ ble: ble, state: state, @@ -396,7 +405,14 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { var rsp response.Response if cmd.IsResponseHardcoded() { - rsp, err = cmd.GetResponse() + // Prefer the mode-aware variant when the command implements + // it (currently SetUniqueID and GetVersion). Other commands + // keep the original single-byte-stream GetResponse path. + if mr, ok := cmd.(command.ResponseForMode); ok { + rsp, err = mr.GetResponseForMode(p.state.Mode) + } else { + rsp, err = cmd.GetResponse() + } if err != nil { log.Fatalf("pkg pod; could not get command response: %s", err) } diff --git a/pkg/pod/state.go b/pkg/pod/state.go index 35d6a20..e93bae5 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -7,6 +7,7 @@ import ( toml "github.com/pelletier/go-toml" log "github.com/sirupsen/logrus" + "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/response" ) @@ -14,6 +15,11 @@ type PODState struct { LTK []byte `toml:"ltk"` EapAkaSeq uint64 `toml:"eap_aka_seq"` + // Mode is the active pairing mode (Dash or O5) that selects which + // response variants the simulator produces. Persisted so the bytes the + // pod returns to the controller stay consistent across restarts. + Mode pair.Mode `toml:"mode"` + Id []byte `toml:"id"` // 4 byte MsgSeq uint8 `toml:"msg_seq"` // TODO: is this the same as nonceSeq? diff --git a/pkg/response/setuniqueidresponse.go b/pkg/response/setuniqueidresponse.go index 694ee21..1185923 100644 --- a/pkg/response/setuniqueidresponse.go +++ b/pkg/response/setuniqueidresponse.go @@ -2,16 +2,38 @@ package response import ( "encoding/hex" + + "github.com/avereha/pod/pkg/pair" ) // This is the special case - sent with the 0x011B response to 0x03 message +// dashSetUniqueIDResponseHex is the captured Dash byte stream returned by a +// real Dash pod for the SetUniqueID (0x03) command. Locking it as a named +// constant lets the regression test pin the exact bytes. +const dashSetUniqueIDResponseHex = "011B13881008340A50040A00010300040308146DB10006E45100001091" + +// o5SetUniqueIDResponseHex is the Omnipod 5 byte stream for the same command. +// TODO(joe): replace with real O5 capture. Until then, we mirror the Dash bytes +// so behaviour is unchanged and the eventual swap is a one-line constant +// update. +const o5SetUniqueIDResponseHex = dashSetUniqueIDResponseHex + type SetUniqueID struct { Seq uint16 + + // Mode selects which captured byte stream Marshal returns. Zero value + // (pair.ModeDash) preserves the legacy behaviour for any caller that + // constructs this struct without setting it. + Mode pair.Mode } func (r *SetUniqueID) Marshal() ([]byte, error) { - response, _ := hex.DecodeString("011B13881008340A50040A00010300040308146DB10006E45100001091") + hexStr := dashSetUniqueIDResponseHex + if r.Mode == pair.ModeO5 { + hexStr = o5SetUniqueIDResponseHex + } + response, _ := hex.DecodeString(hexStr) return response, nil } diff --git a/pkg/response/setuniqueidresponse_test.go b/pkg/response/setuniqueidresponse_test.go new file mode 100644 index 0000000..f05e361 --- /dev/null +++ b/pkg/response/setuniqueidresponse_test.go @@ -0,0 +1,66 @@ +package response + +import ( + "encoding/hex" + "testing" + + "github.com/avereha/pod/pkg/pair" +) + +// TestSetUniqueIDMarshalDash pins the Dash mode byte stream so any unintended +// change to the response is caught by CI. This is the exact hex captured from +// a real Dash pod. +func TestSetUniqueIDMarshalDash(t *testing.T) { + const want = "011B13881008340A50040A00010300040308146DB10006E45100001091" + + r := &SetUniqueID{Mode: pair.ModeDash} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("Dash SetUniqueID bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} + +// TestSetUniqueIDMarshalZeroValueIsDash ensures legacy callers that construct +// &SetUniqueID{} without setting Mode keep getting the Dash bytes. +func TestSetUniqueIDMarshalZeroValueIsDash(t *testing.T) { + const want = "011B13881008340A50040A00010300040308146DB10006E45100001091" + + r := &SetUniqueID{} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("zero-value SetUniqueID bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} + +// TestSetUniqueIDMarshalO5 pins the Omnipod 5 mode byte stream. Today this +// equals the Dash bytes because Joe's real O5 capture hasn't landed yet. +// TODO(joe): once o5SetUniqueIDResponseHex diverges from the Dash constant, +// update `want` here to the real O5 hex so the regression guard tracks it. +func TestSetUniqueIDMarshalO5(t *testing.T) { + const want = "011B13881008340A50040A00010300040308146DB10006E45100001091" + + r := &SetUniqueID{Mode: pair.ModeO5} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("O5 SetUniqueID bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} + +// hexLower normalises a hex literal to lowercase so comparisons against +// encoding/hex output don't trip on case differences. +func hexLower(s string) string { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/pkg/response/versionresponse.go b/pkg/response/versionresponse.go index ff81dfe..93d5d4b 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -2,16 +2,38 @@ package response import ( "encoding/hex" + + "github.com/avereha/pod/pkg/pair" ) // This is the special case - sent with the 0x0115 response to 0x07 message +// dashVersionResponseHex is the captured Dash byte stream returned by a real +// Dash pod for the GetVersion (0x07) command. Locking it as a named constant +// lets the regression test pin the exact bytes. +const dashVersionResponseHex = "0115040A00010300040208146DB10006E45100FFFFFFFF" + +// o5VersionResponseHex is the Omnipod 5 byte stream for the same command. +// TODO(joe): replace with real O5 capture. Until then, we mirror the Dash +// bytes so behaviour is unchanged and the eventual swap is a one-line +// constant update. +const o5VersionResponseHex = dashVersionResponseHex + type VersionResponse struct { Seq uint16 + + // Mode selects which captured byte stream Marshal returns. Zero value + // (pair.ModeDash) preserves the legacy behaviour for any caller that + // constructs this struct without setting it. + Mode pair.Mode } func (r *VersionResponse) Marshal() ([]byte, error) { - response, _ := hex.DecodeString("0115040A00010300040208146DB10006E45100FFFFFFFF") + hexStr := dashVersionResponseHex + if r.Mode == pair.ModeO5 { + hexStr = o5VersionResponseHex + } + response, _ := hex.DecodeString(hexStr) return response, nil } diff --git a/pkg/response/versionresponse_test.go b/pkg/response/versionresponse_test.go new file mode 100644 index 0000000..91d9a21 --- /dev/null +++ b/pkg/response/versionresponse_test.go @@ -0,0 +1,56 @@ +package response + +import ( + "encoding/hex" + "testing" + + "github.com/avereha/pod/pkg/pair" +) + +// TestVersionResponseMarshalDash pins the Dash mode byte stream so any +// unintended change to the response is caught by CI. This is the exact hex +// captured from a real Dash pod. +func TestVersionResponseMarshalDash(t *testing.T) { + const want = "0115040A00010300040208146DB10006E45100FFFFFFFF" + + r := &VersionResponse{Mode: pair.ModeDash} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("Dash VersionResponse bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} + +// TestVersionResponseMarshalZeroValueIsDash ensures legacy callers that +// construct &VersionResponse{} without setting Mode keep getting Dash bytes. +func TestVersionResponseMarshalZeroValueIsDash(t *testing.T) { + const want = "0115040A00010300040208146DB10006E45100FFFFFFFF" + + r := &VersionResponse{} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("zero-value VersionResponse bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} + +// TestVersionResponseMarshalO5 pins the Omnipod 5 mode byte stream. Today this +// equals the Dash bytes because Joe's real O5 capture hasn't landed yet. +// TODO(joe): once o5VersionResponseHex diverges from the Dash constant, +// update `want` here to the real O5 hex so the regression guard tracks it. +func TestVersionResponseMarshalO5(t *testing.T) { + const want = "0115040A00010300040208146DB10006E45100FFFFFFFF" + + r := &VersionResponse{Mode: pair.ModeO5} + got, err := r.Marshal() + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if hex.EncodeToString(got) != hexLower(want) { + t.Fatalf("O5 VersionResponse bytes mismatch:\n got=%x\nwant=%s", got, hexLower(want)) + } +} From 179ec7a793f180c8d3461d33a2f0bc112ab5292f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 19:08:21 +0000 Subject: [PATCH 09/18] pod: fix O5 command loop and delivery edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle four small upstream runtime fixes needed for O5 sessions to survive past pairing. bluetooth.go (76fd556 — HELLO routing): - The CMD-char write handler now classifies every write: multi-byte payloads (e.g. the OmnipodKit HELLO frame 06 01 04 + 4-byte ID) and non-RTS single-byte signals go to cmdActivation; the four fragmentation-control signals (RTS / SUCCESS / NACK / FAIL) stay on cmdInput. - ReadCmd reads from cmdActivation again, completing the dispatcher wiring that Commit 3b scaffolded. - The legacy RTS path's first-byte check no longer fatals on an unexpected value — it warns and returns nil. Misclassified future signal bytes can't kill the loop. bluetooth.go (b674a06 — clean shutdown): - ShutdownConnection now calls StopMessageLoop() so the idle-timeout reconnect can re-init the pipeline without the "Messaging loop is already running" fatal. pod.go (acbfbb2 — AID deadlock): - The AID branch in CommandLoop now calls notifyStateChange() AFTER p.mtx.Unlock(). handleAIDCommand itself still must not call it while holding the mutex (documented). Without this notification, web clients watching state wouldn't see AID-phase progress at all. pkg/pod/delivery (4ce6046 — pulse-by-pulse math): - New package with PartialPulses(start, end, totalPulses, now) — pure interpolation math that's testable on macOS without the gatt dependency. delivery_test.go covers 2-second/pulse user boluses, 1-second/pulse setup boluses (prime/cannula), and the degenerate boundary cases. - Not wired into the live bolus accounting today: main's existing immediate-decrement model (BolusRemaining + Delivered) is preserved bit-for-bit. The package is available as a supplemental helper for future work (e.g. WS API exposure of pulse-level delivery progress). Prime pulse cadence (b674a06 in pod.go): already implemented by main's existing `if PodProgress >= PodProgressRunningAbove50U` branch selecting 1s/pulse during setup vs 2s/pulse during user boluses. No edit was needed — the b674a06 snapshot pattern expresses the same rule in the snapshot-bolus model that we didn't adopt. Source: jwoglom/five commits 76fd556, acbfbb2, b674a06, 4ce6046. --- pkg/bluetooth/bluetooth.go | 53 +++++++++++---------- pkg/pod/delivery/delivery.go | 36 ++++++++++++++ pkg/pod/delivery/delivery_test.go | 78 +++++++++++++++++++++++++++++++ pkg/pod/pod.go | 1 + 4 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 pkg/pod/delivery/delivery.go create mode 100644 pkg/pod/delivery/delivery_test.go diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index d527ffb..254983d 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -38,10 +38,6 @@ type Ble struct { // SUCCESS/NACK fragmentation control set). ReadCmd() drains this so // StartActivation gets first crack at the HELLO byte without racing // the message loop's cmdInput consumer. - // - // NOTE (Commit 3b): the channel and the ReadCmd() reader are wired - // here, but the CMD-char write handler does NOT yet route into it. - // The dispatcher fix lands in a later commit (76fd556 / Commit 9). cmdActivation chan Packet dataOutput chan Packet cmdOutput chan Packet @@ -181,12 +177,18 @@ func New(adapterID string, podId []byte) (*Ble, error) { log.Tracef("received CMD, %x", data) ret := make([]byte, len(data)) copy(ret, data) - // NOTE (Commit 3b): the HELLO-vs-RTS dispatcher (which - // routes activation bytes to cmdActivation) lands with - // Commit 9 (76fd556). For now every CMD write goes to - // cmdInput exactly like origin/main, preserving the - // existing pairing flow. - b.cmdInput <- Packet(ret) + // Multi-byte writes (e.g. HELLO = 06 01 04 + 4-byte + // controller ID) and any non-RTS/SUCCESS single-byte + // signal (HELLO, PAIR_STATUS, INCORRECT, …) belong on + // the activation queue. RTS / SUCCESS / NACK / FAIL + // stay on cmdInput where the message loop drains them + // for fragmentation handshake and ack handling. + if len(ret) > 1 || (ret[0] != CmdRTS[0] && ret[0] != CmdSuccess[0] && + ret[0] != CmdNACK[0] && ret[0] != CmdFail[0]) { + b.cmdActivation <- Packet(ret) + } else { + b.cmdInput <- Packet(ret) + } return 0 }) @@ -315,13 +317,8 @@ func (b *Ble) writeDataBuffer(buf *bytes.Buffer) error { // (06 01 04 + 4-byte controller ID). RTS/CTS/SUCCESS/NACK/FAIL signal bytes // are not delivered here; they are consumed inline by the message loop's // readMessage path. -// -// NOTE (Commit 3b): cmdActivation exists on the struct but its dispatcher -// is deferred to Commit 9 (76fd556). Until then, ReadCmd continues to read -// from cmdInput exactly like main, so the simulator's pairing path remains -// functional through Commits 4-8. func (b *Ble) ReadCmd() (Packet, error) { - packet := <-b.cmdInput + packet := <-b.cmdActivation return packet, nil } @@ -557,20 +554,28 @@ func (b *Ble) parsePackets(first Packet) (*message.Message, error) { return msg, mErr } -// readMessage is the legacy RTS-driven read path retained verbatim from -// origin/main. With the new loop wiring (dataInput is consumed directly -// by readMessageData) this function only runs when an RTS arrives on the -// CMD characteristic. The graceful non-RTS fallback / HELLO handling -// added by five's 76fd556 is deferred to Commit 9. +// readMessage is the legacy RTS-driven read path retained from origin/main. +// With the new loop wiring (dataInput is consumed directly by +// readMessageData) this function only runs when an RTS arrives on the CMD +// characteristic. HELLO / PAIR_STATUS / INCORRECT and other multi-byte +// activation bytes are routed to cmdActivation by the CMD-char write +// handler, so they should never reach here; if one does (e.g. a +// misclassified future OmnipodKit signal), warn and ignore rather than +// fatal so the loop survives. func (b *Ble) readMessage(cmd Packet) (*message.Message, error) { var buf bytes.Buffer var checksum []byte - log.Trace("pkg bluetooth; Reading RTS") if !bytes.Equal(CmdRTS[:1], cmd[:1]) { - log.Fatalf("pkg bluetooth; expected command: %x. received command: %x", CmdRTS, cmd) + // HELLO / PAIR_STATUS / etc. are routed to cmdActivation by the + // CMD char write handler, so seeing one here means it slipped + // through the dispatcher (e.g. a multi-byte signal misclassified). + // Log and ignore rather than fatal — the activation-path consumer + // (StartActivation's ReadCmd) will pick it up if relevant. + log.Warnf("pkg bluetooth; readMessage saw unexpected cmd byte 0x%02x; ignoring", cmd[0]) + return nil, nil } - log.Trace("pkg bluetooth; Sending CTS") + log.Trace("pkg bluetooth; Reading RTS, sending CTS") b.WriteCmd(CmdCTS) diff --git a/pkg/pod/delivery/delivery.go b/pkg/pod/delivery/delivery.go new file mode 100644 index 0000000..9c75ecc --- /dev/null +++ b/pkg/pod/delivery/delivery.go @@ -0,0 +1,36 @@ +// Package delivery contains pure helpers for computing in-flight bolus +// progress. It deliberately has no external imports beyond `time` so it can +// be unit-tested on any platform (the parent `pod` package imports BLE +// libraries that are Linux-only). +package delivery + +import "time" + +// PartialPulses returns the number of pulses delivered between `start` and +// `now`, capped at `total`. The bolus duration is given by `end - start`, +// which lets the caller choose the pulse cadence: prime and cannula-insert +// boluses run at 1 second per pulse on a real Omnipod 5, while user-issued +// boluses run at 2 seconds per pulse. +// +// Behaviour at the boundaries: +// - start.IsZero() || total == 0 || !end.After(start) → 0 +// - now ≤ start → 0 +// - now ≥ end → total +func PartialPulses(total uint16, start, end, now time.Time) uint16 { + if start.IsZero() || total == 0 || !end.After(start) { + return 0 + } + if !now.After(start) { + return 0 + } + if !now.Before(end) { + return total + } + elapsed := now.Sub(start) + duration := end.Sub(start) + delivered := uint64(total) * uint64(elapsed) / uint64(duration) + if delivered >= uint64(total) { + return total + } + return uint16(delivered) +} diff --git a/pkg/pod/delivery/delivery_test.go b/pkg/pod/delivery/delivery_test.go new file mode 100644 index 0000000..dfdbb98 --- /dev/null +++ b/pkg/pod/delivery/delivery_test.go @@ -0,0 +1,78 @@ +package delivery + +import ( + "testing" + "time" +) + +// TestPartialPulses_TwoSecond covers a 10-pulse user bolus running at 2 sec/ +// pulse (regular Omnipod 5 bolus cadence). end = start + 20s. +func TestPartialPulses_TwoSecond(t *testing.T) { + start := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) + end := start.Add(20 * time.Second) + + cases := []struct { + name string + now time.Time + want uint16 + }{ + {"before start", start.Add(-time.Second), 0}, + {"at start", start, 0}, + {"one pulse in", start.Add(2 * time.Second), 1}, + {"five pulses in", start.Add(10 * time.Second), 5}, + {"exactly done", end, 10}, + {"past end clamps", start.Add(time.Hour), 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := PartialPulses(10, start, end, tc.now); got != tc.want { + t.Errorf("PartialPulses(10, …, %v) = %d, want %d", tc.now.Sub(start), got, tc.want) + } + }) + } +} + +// TestPartialPulses_OneSecond covers prime/cannula at 1 sec/pulse. +// 52 pulses (prime) at 1s/pulse → end = start + 52s. +func TestPartialPulses_OneSecond(t *testing.T) { + start := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) + end := start.Add(52 * time.Second) + + cases := []struct { + name string + now time.Time + want uint16 + }{ + {"at start", start, 0}, + {"halfway (26s in)", start.Add(26 * time.Second), 26}, + {"fully done", end, 52}, + {"past end", start.Add(2 * time.Minute), 52}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := PartialPulses(52, start, end, tc.now); got != tc.want { + t.Errorf("PartialPulses(52, …, %v) = %d, want %d", tc.now.Sub(start), got, tc.want) + } + }) + } +} + +// TestPartialPulses_Degenerate exercises the early-out paths. +func TestPartialPulses_Degenerate(t *testing.T) { + start := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) + end := start.Add(20 * time.Second) + now := start.Add(5 * time.Second) + + if got := PartialPulses(0, start, end, now); got != 0 { + t.Errorf("zero total -> %d, want 0", got) + } + if got := PartialPulses(10, time.Time{}, end, now); got != 0 { + t.Errorf("zero start -> %d, want 0", got) + } + if got := PartialPulses(10, start, start, now); got != 0 { + t.Errorf("end == start -> %d, want 0", got) + } + if got := PartialPulses(10, start, start.Add(-time.Second), now); got != 0 { + t.Errorf("end < start -> %d, want 0", got) + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 1c0b1b3..c80e9df 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -375,6 +375,7 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { if aid.IsAIDPayload(decrypted.Payload) { p.handleAIDCommand(msg, decrypted.Payload) p.mtx.Unlock() + p.notifyStateChange() continue } From ad1293cf4a0a5f2ac2d392f1de03d65a20435a0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 14:29:59 +0000 Subject: [PATCH 10/18] response: wire in real Omnipod 5 SetUniqueID and Version captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder o5*ResponseHex constants (which mirrored Dash) with the actual byte streams captured from a real Omnipod 5 pod (Pod Type ID 05, firmware 9.0.4, BLE firmware 5.0.2, Lot 261724721, TID 491153). Both files now carry the upstream byte-field layout comment so the structure of each frame is self-documenting. SetUniqueID (0x011B response to 0x03): 01 1B 1388 10 08 34 0A 50 09 00 04 05 00 02 05 03 0F999A31 00077E91 00000000 = 01 LL VVVV BR PR PP CP PL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT IIIIIIII VersionResponse (0x0115 response to 0x07): 01 15 09 00 04 05 00 02 05 02 0F999A31 00077E91 05 FFFFFFFF = 01 LL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT GS IIIIIIII The Dash regression tests are unchanged and still pin the legacy bytes verbatim; the O5 tests now assert the real captured hex. Mode-aware plumbing (the optional ResponseForMode interface, the Mode field on PODState, the type-assertion dispatch in CommandLoop) all landed in the previous response commit — this is purely the constant + test swap that commit was scaffolded for. --- pkg/response/setuniqueidresponse.go | 16 +++++++++++----- pkg/response/setuniqueidresponse_test.go | 9 ++++----- pkg/response/versionresponse.go | 17 ++++++++++++----- pkg/response/versionresponse_test.go | 11 ++++++----- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/pkg/response/setuniqueidresponse.go b/pkg/response/setuniqueidresponse.go index 1185923..0e6e726 100644 --- a/pkg/response/setuniqueidresponse.go +++ b/pkg/response/setuniqueidresponse.go @@ -13,11 +13,17 @@ import ( // constant lets the regression test pin the exact bytes. const dashSetUniqueIDResponseHex = "011B13881008340A50040A00010300040308146DB10006E45100001091" -// o5SetUniqueIDResponseHex is the Omnipod 5 byte stream for the same command. -// TODO(joe): replace with real O5 capture. Until then, we mirror the Dash bytes -// so behaviour is unchanged and the eventual swap is a one-line constant -// update. -const o5SetUniqueIDResponseHex = dashSetUniqueIDResponseHex +// o5SetUniqueIDResponseHex is the Omnipod 5 byte stream for the same command, +// captured from a real Omnipod 5 pod (Pod Type ID 05, firmware 9.0.4, BLE +// firmware 5.0.2, Lot 261724721, TID 491153). Layout: +// +// 01 LL VVVV BR PR PP CP PL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT IIIIIIII +// 01 1B 1388 10 08 34 0A 50 09 00 04 05 00 02 05 03 0F999A31 00077E91 00000000 +// +// where MXMYMZ is the pod firmware version, IXIYIZ is the BLE-stack firmware, +// ID is the pod type identifier (0x05 = Omnipod 5), LLLLLLLL is the lot +// number, and TTTTTTTT is the per-pod TID. +const o5SetUniqueIDResponseHex = "011B13881008340A5009000405000205030F999A3100077E9100000000" type SetUniqueID struct { Seq uint16 diff --git a/pkg/response/setuniqueidresponse_test.go b/pkg/response/setuniqueidresponse_test.go index f05e361..31cfc6a 100644 --- a/pkg/response/setuniqueidresponse_test.go +++ b/pkg/response/setuniqueidresponse_test.go @@ -38,12 +38,11 @@ func TestSetUniqueIDMarshalZeroValueIsDash(t *testing.T) { } } -// TestSetUniqueIDMarshalO5 pins the Omnipod 5 mode byte stream. Today this -// equals the Dash bytes because Joe's real O5 capture hasn't landed yet. -// TODO(joe): once o5SetUniqueIDResponseHex diverges from the Dash constant, -// update `want` here to the real O5 hex so the regression guard tracks it. +// TestSetUniqueIDMarshalO5 pins the Omnipod 5 mode byte stream as captured +// from a real Omnipod 5 pod (Pod Type ID 05, firmware 9.0.4, BLE firmware +// 5.0.2, Lot 261724721, TID 491153). func TestSetUniqueIDMarshalO5(t *testing.T) { - const want = "011B13881008340A50040A00010300040308146DB10006E45100001091" + const want = "011B13881008340A5009000405000205030F999A3100077E9100000000" r := &SetUniqueID{Mode: pair.ModeO5} got, err := r.Marshal() diff --git a/pkg/response/versionresponse.go b/pkg/response/versionresponse.go index 93d5d4b..dc78f42 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -13,11 +13,18 @@ import ( // lets the regression test pin the exact bytes. const dashVersionResponseHex = "0115040A00010300040208146DB10006E45100FFFFFFFF" -// o5VersionResponseHex is the Omnipod 5 byte stream for the same command. -// TODO(joe): replace with real O5 capture. Until then, we mirror the Dash -// bytes so behaviour is unchanged and the eventual swap is a one-line -// constant update. -const o5VersionResponseHex = dashVersionResponseHex +// o5VersionResponseHex is the Omnipod 5 byte stream for the same command, +// captured from a real Omnipod 5 pod (Pod Type ID 05, firmware 9.0.4, BLE +// firmware 5.0.2, Lot 261724721, TID 491153). Layout: +// +// 01 LL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT GS IIIIIIII +// 01 15 09 00 04 05 00 02 05 02 0F999A31 00077E91 05 FFFFFFFF +// +// where MXMYMZ is the pod firmware version, IXIYIZ is the BLE-stack firmware, +// ID is the pod type identifier (0x05 = Omnipod 5), LLLLLLLL is the lot +// number, TTTTTTTT is the per-pod TID, and GS is a gain/status-style byte +// not present in the Dash variant. +const o5VersionResponseHex = "011509000405000205020F999A3100077E9105FFFFFFFF" type VersionResponse struct { Seq uint16 diff --git a/pkg/response/versionresponse_test.go b/pkg/response/versionresponse_test.go index 91d9a21..4953b6f 100644 --- a/pkg/response/versionresponse_test.go +++ b/pkg/response/versionresponse_test.go @@ -38,12 +38,13 @@ func TestVersionResponseMarshalZeroValueIsDash(t *testing.T) { } } -// TestVersionResponseMarshalO5 pins the Omnipod 5 mode byte stream. Today this -// equals the Dash bytes because Joe's real O5 capture hasn't landed yet. -// TODO(joe): once o5VersionResponseHex diverges from the Dash constant, -// update `want` here to the real O5 hex so the regression guard tracks it. +// TestVersionResponseMarshalO5 pins the Omnipod 5 mode byte stream as +// captured from a real Omnipod 5 pod (Pod Type ID 05, firmware 9.0.4, BLE +// firmware 5.0.2, Lot 261724721, TID 491153). Note this is 23 bytes vs the +// Dash response's 23 — same length, different field layout (no PR/PP/CP/PL +// preamble, but adds a 1-byte GS field before the trailing 0xFFFFFFFF). func TestVersionResponseMarshalO5(t *testing.T) { - const want = "0115040A00010300040208146DB10006E45100FFFFFFFF" + const want = "011509000405000205020F999A3100077E9105FFFFFFFF" r := &VersionResponse{Mode: pair.ModeO5} got, err := r.Marshal() From d95e1d3d80522760a70ced0b4d507d93611689b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:02:16 +0000 Subject: [PATCH 11/18] bluetooth: plumb pair.Mode into New (no behavior change) PR review revealed that every BLE-related change in Commit 3b was applied unconditionally to both Dash and Omnipod 5 sessions because the selected pair.Mode never reached pkg/bluetooth. As a result Dash users (default mode) saw the simulator advertise as an O5 pod, expose the wrong data characteristic UUID, and frame messages using O5's 244-byte packet split instead of Dash's 20-byte RTS/CTS transport. Marion's Raspberry Pi sees "No pods found" for both modes as a consequence. This commit is pure infrastructure: thread the mode through New so subsequent commits can branch advertise / GATT / transport / cmd routing on it. No wire bytes change yet; the simulator behaves exactly as it did before, just with the mode stored on the Ble struct and an IsO5() accessor available. - pkg/bluetooth/bluetooth.go: add mode pair.Mode field on Ble, accept it as the third parameter to New, log the active profile at startup, add IsO5() helper used by the follow-up mode-branched code. - main.go: pass pairMode through to bluetooth.New. This is the first of a focused repair series (A-H) that restores Dash bit-for-bit against origin/main while keeping the Omnipod 5 work intact. --- main.go | 2 +- pkg/bluetooth/bluetooth.go | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index cf6c43a..cdd27e0 100644 --- a/main.go +++ b/main.go @@ -55,7 +55,7 @@ func main() { log.Tracef("podId %x", state.Id) - ble, err := bluetooth.New("hci0", state.Id) + ble, err := bluetooth.New("hci0", state.Id, pairMode) //defer ble.Close() if err != nil { log.Fatalf("Could not start BLE: %s", err) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 254983d..02a1055 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -13,6 +13,7 @@ import ( "github.com/avereha/pod/pkg/bluetooth/packet" "github.com/avereha/pod/pkg/message" + "github.com/avereha/pod/pkg/pair" "github.com/davecgh/go-spew/spew" "github.com/paypal/gatt" "github.com/paypal/gatt/linux/cmd" @@ -31,6 +32,11 @@ var ( ) type Ble struct { + // mode selects which BLE profile (Dash or Omnipod 5) the simulator + // exposes: advertisement bytes, GATT service shape, transport framing, + // and command-channel routing all differ between the two. + mode pair.Mode + dataInput chan Packet cmdInput chan Packet // cmdActivation receives pairing-state command bytes (HELLO 0x06, @@ -59,6 +65,12 @@ type Ble struct { heartbeatNotifierMtx sync.Mutex } +// IsO5 reports whether this BLE instance is exposing the Omnipod 5 profile. +// Used internally to gate advertisement, GATT shape, transport framing, and +// command-channel routing decisions. Dash profile is the default and must +// match origin/main bit-for-bit. +func (b *Ble) IsO5() bool { return b.mode == pair.ModeO5 } + var DefaultServerOptions = []gatt.Option{ gatt.LnxMaxConnections(1), gatt.LnxDeviceID(-1, true), @@ -69,13 +81,14 @@ var DefaultServerOptions = []gatt.Option{ }), } -func New(adapterID string, podId []byte) (*Ble, error) { +func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { d, err := gatt.NewDevice(DefaultServerOptions...) if err != nil { log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) } b := &Ble{ + mode: mode, dataInput: make(chan Packet, 5), cmdInput: make(chan Packet, 5), cmdActivation: make(chan Packet, 5), @@ -86,6 +99,8 @@ func New(adapterID string, podId []byte) (*Ble, error) { device: &d, } + log.Infof("pkg bluetooth; profile=%s adapter=%s podId=%x", mode, adapterID, podId) + d.Handle( gatt.CentralConnected(func(c gatt.Central) { fmt.Println("pkg bluetooth; ** New connection from: ", c.ID()) From 788576165d2ed2789b0f0243c4b135eac8933cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:07:27 +0000 Subject: [PATCH 12/18] bluetooth: restore Dash advertisement byte-for-byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Omnipod 5 port replaced the Dash advertise call with the O5 form globally — same code path for both modes. Result: a Dash phone scanning the simulator saw an "AP 0A95B6110002761B" device announcing CE1F923D... and ECF301E2... UUIDs instead of " :: Fake POD ::" and the 9-element 16-bit UUID list it expects, so the Dash app reported "No pods found". Split the advertise paths into four mode-specific helpers and branch on b.IsO5() at each site: - advertiseDash / refreshDash carry main's exact bytes: name " :: Fake POD ::", UUIDs 0x4024, 0x2470, 0x000a, the two pod-address UUID16 slots, and the version-response identifiers 0x0814, 0x6DB1, 0x0006, 0xE451. refreshDash preserves main's tracing form verbatim (including the two log.Tracef calls that vet would flag — they are inherited from main and changing them would break byte parity). - advertiseO5 / refreshO5 keep the current HEAD behavior unchanged: "AP 0A95B6110002761B" name, CE1F923D-...0A00 and ECF301E2-... UUIDs. The manufacturer-data field (60030001000000) that real O5 pods co-advertise is still missing in this commit; Commit F will land that once the vendored paypal/gatt gains the helper it needs. onStateChanged and RefreshAdvertisingWithSpecifiedId both dispatch on b.IsO5(). The startup log now reports the actual advertised name and UUID count so a btmon capture is straightforward to correlate. GATT shape (data char UUID, heartbeat service, registration form), transport framing, and command-channel routing are still O5-shaped for both modes — they are addressed in Commits C, D, and E. --- pkg/bluetooth/bluetooth.go | 180 ++++++++++++++++++++++++++++--------- 1 file changed, 140 insertions(+), 40 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 02a1055..ae87750 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -81,6 +81,110 @@ var DefaultServerOptions = []gatt.Option{ }), } +// dashAdvertiseName is the device name the Dash simulator advertises. It is +// the literal string origin/main has shipped with since the project's first +// commit; OmnipodKit's Dash scanner does not rely on the name (it matches by +// the 0x4024 service UUID), but the byte-for-byte form is preserved so +// btmon captures continue to compare cleanly with main. +const dashAdvertiseName = " :: Fake POD ::" + +// advertiseDash advertises the Dash profile: the " :: Fake POD ::" name and +// the 9-element UUID16 list from origin/main. This must remain byte-for-byte +// identical to main so existing Dash users (and the test fixtures captured +// against main) keep working. podId is the 4-byte pod address; when nil the +// default {ff,ff,ff,fe} mapping from main is used. +func (b *Ble) advertiseDash(d gatt.Device, podId []byte) error { + podIdServiceOne := gatt.UUID16(0xffff) + podIdServiceTwo := gatt.UUID16(0xfffe) + if podId != nil { + podIdServiceOne = gatt.UUID16(binary.BigEndian.Uint16(podId[0:2])) + podIdServiceTwo = gatt.UUID16(binary.BigEndian.Uint16(podId[2:4])) + } + + // Advertise device name and service's UUIDs. + return d.AdvertiseNameAndServices(dashAdvertiseName, []gatt.UUID{ + gatt.UUID16(0x4024), + + gatt.UUID16(0x2470), + gatt.UUID16(0x000a), + + podIdServiceOne, + podIdServiceTwo, + + // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go + gatt.UUID16(0x0814), + gatt.UUID16(0x6DB1), + gatt.UUID16(0x0006), + gatt.UUID16(0xE451), + }) +} + +// advertiseO5 advertises the Omnipod 5 profile: an "AP 0A95B6110002761B" +// name and two 128-bit UUIDs (CE1F923D-...0A00 plus the ECF301E2... +// scanner identifier). When podId is nil the default {ff,ff,ff,fe} mapping +// is used. NOTE: the manufacturer-data field (60030001000000) that the real +// pod co-advertises is NOT emitted here — the vendored paypal/gatt in this +// tree does not expose AdvertiseNameServicesMfgData. Commit F is expected +// to add that helper. +func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { + podIdArray, err := hex.DecodeString("fffffffe") + if err != nil { + return fmt.Errorf("could not parse default address: %w", err) + } + if podId != nil { + podIdArray = podId + } + + // CE1F923D-C539-48EA-7300-0AFFFFFFFE00 unpaired, or + // CE1F923D-C539-48EA-7300-0A00 once paired. The + // ECF301E2... UUID is OmnipodKit's "advertisement" identifier + // for the O5 heartbeat service and is co-advertised so the + // scanner can find it during discovery (BluetoothServices.swift). + return d.AdvertiseNameAndServices( + "AP "+strings.ToUpper(hex.EncodeToString(podIdArray))+" 0A95B6110002761B", + []gatt.UUID{ + gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(podIdArray) + "00"), + gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), + }, + ) +} + +// refreshDash is the Dash-profile re-advertise path used after SetUniqueID +// updates the pod address. Verbatim form from origin/main (same UUID list as +// advertiseDash but constructed inline so the trace logging matches main's +// shape too). +func (b *Ble) refreshDash(id []byte) error { + log.Tracef("podIdServiceOne", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) + log.Tracef("podIdServiceTwo", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) + return (*b.device).AdvertiseNameAndServices(dashAdvertiseName, []gatt.UUID{ + gatt.UUID16(0x4024), + + gatt.UUID16(0x2470), + gatt.UUID16(0x000a), + + gatt.UUID16(binary.BigEndian.Uint16(id[0:2])), + gatt.UUID16(binary.BigEndian.Uint16(id[2:4])), + + // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go + gatt.UUID16(0x0814), + gatt.UUID16(0x6DB1), + gatt.UUID16(0x0006), + gatt.UUID16(0xE451), + }) +} + +// refreshO5 is the Omnipod 5 re-advertise path used after SetUniqueID. Same +// shape as advertiseO5; manufacturer-data is still deferred to Commit F. +func (b *Ble) refreshO5(id []byte) error { + return (*b.device).AdvertiseNameAndServices( + "AP "+strings.ToUpper(hex.EncodeToString(id))+" 0A95B6110002761B", + []gatt.UUID{ + gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(id) + "00"), + gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), + }, + ) +} + func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { d, err := gatt.NewDevice(DefaultServerOptions...) if err != nil { @@ -249,35 +353,34 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { log.Fatalf("pkg bluetooth; could not add service: %s", err) } - podIdArray, err := hex.DecodeString("fffffffe") - if err != nil { - log.Fatalf("pkg bluetooth; could not parse default address: %s", err) + // Mode-branch the advertisement so DASH stays byte-for-byte + // identical to origin/main (which is what existing Dash scanners + // match against) while O5 keeps the OmnipodKit-shaped name and + // 128-bit UUID list. The manufacturer-data payload that real O5 + // pods co-advertise is still missing and will be added in Commit + // F when the vendored paypal/gatt gains a helper for it. + var advertiseErr error + var advertisedName string + var advertisedUUIDCount int + if b.IsO5() { + advertiseErr = b.advertiseO5(d, podId) + // Mirror the name/uuid-count computation from advertiseO5 so + // the startup log matches what was actually advertised. + idHex := "FFFFFFFE" + if podId != nil { + idHex = strings.ToUpper(hex.EncodeToString(podId)) + } + advertisedName = "AP " + idHex + " 0A95B6110002761B" + advertisedUUIDCount = 2 + } else { + advertiseErr = b.advertiseDash(d, podId) + advertisedName = dashAdvertiseName + advertisedUUIDCount = 9 } - - if podId != nil { - podIdArray = podId - } - - // CE1F923D-C539-48EA-7300-0AFFFFFFFE00 unpaired, or - // CE1F923D-C539-48EA-7300-0A00 once paired. The - // ECF301E2... UUID is OmnipodKit's "advertisement" identifier - // for the O5 heartbeat service and is co-advertised so the - // scanner can find it during discovery (BluetoothServices.swift). - // - // NOTE: the vendored paypal/gatt in this tree does not expose - // AdvertiseNameServicesMfgData; the manufacturer-data payload - // (60030001000000) cannot be advertised without patching the - // vendor library. Fall back to AdvertiseNameAndServices for now. - err = d.AdvertiseNameAndServices( - "AP "+strings.ToUpper(hex.EncodeToString(podIdArray))+" 0A95B6110002761B", - []gatt.UUID{ - gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(podIdArray) + "00"), - gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), - }, - ) - if err != nil { - log.Fatalf("pkg bluetooth; could not advertise: %s", err) + if advertiseErr != nil { + log.Fatalf("pkg bluetooth; advertise: %s", advertiseErr) } + log.Infof("pkg bluetooth; advertising name=%q uuid_count=%d", advertisedName, advertisedUUIDCount) default: } } @@ -290,19 +393,16 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, first 2 usually empty log.Debugf("RefreshAdvertisingWithSpecifiedId %x", id) - // Looking at the paypal/gatt source code, we don't need to call StopAdvertising, - // but just call AdvertiseNameAndServices and it should update. - // - // As with the initial advertise in New(), the vendored gatt does not - // support manufacturer-data advertising, so the 60030001000000 payload - // is omitted here as well. - err := (*b.device).AdvertiseNameAndServices( - "AP "+strings.ToUpper(hex.EncodeToString(id))+" 0A95B6110002761B", - []gatt.UUID{ - gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(id) + "00"), - gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), - }, - ) + // Looking at the paypal/gatt source code, we don't need to call + // StopAdvertising, but just call AdvertiseNameAndServices and it should + // update. Mode-branch the actual payload so DASH refreshes match + // origin/main byte-for-byte while O5 keeps the OmnipodKit-shaped form. + var err error + if b.IsO5() { + err = b.refreshO5(id) + } else { + err = b.refreshDash(id) + } if err != nil { log.Infof("pkg bluetooth; could not re-advertise: %s", err) } From de6774c49913772e06b50bb3cb4feb30183eaf1d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:09:51 +0000 Subject: [PATCH 13/18] bluetooth: restore Dash GATT shape byte-for-byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Omnipod 5 port changed the data-characteristic UUID from 1A7E2442-... (Dash) to 1A7E2443-... (O5) globally, and registered an O5 heartbeat service unconditionally via d.SetServices. Dash clients subscribe to and write on 2442 — when the simulator only exposed 2443, Dash discovery succeeded but every subsequent data exchange failed silently. Combined with the advertise regression fixed in Commit B, this was the second half of Marion's "No pods found / pods found but won't pair" symptoms. Mode-branch the GATT registration inside onStateChanged: - Dash: single service with cmd char 1A7E2441-..., data char 1A7E2442-..., registered via d.AddService(s) — exact origin/main shape, no heartbeat service. - O5: same service + cmd char, data char 1A7E2443-..., heartbeat service 7DED7A6C-... with notify characteristic 7DED7A6D-..., registered via d.SetServices([]*gatt.Service{s, h}) — current HEAD behavior preserved. The cmd-characteristic Write/Notify callbacks and the data-char Notify/Write callbacks are unchanged for both modes (Commit E will mode-branch the cmd dispatcher; the data callback is identical because the wire bytes differ in framing, not in queueing). The heartbeat goroutine still spawns unconditionally in New, but its existing nil-check on b.heartbeatNotifier makes it a no-op for Dash since the heartbeat service is never registered and the notifier stays nil. Documented inline so a future reader can confirm the invariant. --- pkg/bluetooth/bluetooth.go | 61 ++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index ae87750..fe50455 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -252,7 +252,11 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { // Heartbeat emitter: once a phone subscribes to the heartbeat // characteristic, push a one-byte notification every 10s. Real O5 pods - // use this as a connection keep-alive. + // use this as a connection keep-alive. The ticker spawns unconditionally + // for both modes — in Dash mode the heartbeat service is never + // registered (see onStateChanged below) so b.heartbeatNotifier stays + // nil and the nil-check below makes the ticker a no-op. No Notify call + // is ever fired for Dash. go func() { t := time.NewTicker(10 * time.Second) defer t.Stop() @@ -274,11 +278,16 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { fmt.Printf("state: %s\n", s) switch s { case gatt.StatePoweredOn: - // Main pod GATT service (Omnipod 5; same primary UUID as Dash). + // Main pod GATT service (same primary UUID for Dash and Omnipod 5). // Source: OmnipodKit BluetoothServices.swift / BlePodProfile.swift. + // Dash and O5 share the service + cmd char UUIDs; only the data + // char UUID differs (Dash uses 2442 verbatim from origin/main, O5 + // uses 2443 per OmnipodKit). The O5-only heartbeat service is + // registered alongside the main service for O5 mode. var serviceUUID = gatt.MustParseUUID("1A7E4024-E3ED-4464-8B7E-751E03D0DC5F") var cmdCharUUID = gatt.MustParseUUID("1A7E2441-E3ED-4464-8B7E-751E03D0DC5F") - var dataCharUUID = gatt.MustParseUUID("1A7E2443-E3ED-4464-8B7E-751E03D0DC5F") + var dashDataCharUUID = gatt.MustParseUUID("1A7E2442-E3ED-4464-8B7E-751E03D0DC5F") + var o5DataCharUUID = gatt.MustParseUUID("1A7E2443-E3ED-4464-8B7E-751E03D0DC5F") // Omnipod 5 heartbeat service used for keep-alive. // The pod GATT service UUID is 7DED7A6C... and its single @@ -319,6 +328,15 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { log.Infof("pkg bluetooth; handling CMD notifications on new connection from: %s", r.Central.ID()) }) + // Mode-branch the data characteristic UUID: Dash keeps the 2442 + // value from origin/main, O5 uses 2443 per OmnipodKit. The + // notify/write callbacks are identical for both modes — the + // transport (Commit D) and command dispatcher (Commit E) will + // branch on b.IsO5() where they need to. + dataCharUUID := dashDataCharUUID + if b.IsO5() { + dataCharUUID = o5DataCharUUID + } dataCharacteristic := s.AddCharacteristic(dataCharUUID) dataCharacteristic.HandleNotifyFunc( func(r gatt.Request, n gatt.Notifier) { @@ -338,19 +356,30 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { return 0 }) - h := gatt.NewService(heartbeatServiceUUID) - hbCharacteristic := h.AddCharacteristic(heartbeatCharUUID) - hbCharacteristic.HandleNotifyFunc( - func(r gatt.Request, n gatt.Notifier) { - b.heartbeatNotifierMtx.Lock() - b.heartbeatNotifier = n - b.heartbeatNotifierMtx.Unlock() - log.Infof("pkg bluetooth; handling heartbeat notifications on new connection from: %s", r.Central.ID()) - }) - - err = d.SetServices([]*gatt.Service{s, h}) - if err != nil { - log.Fatalf("pkg bluetooth; could not add service: %s", err) + // Mode-branch the GATT registration call. Dash uses the singular + // AddService form from origin/main and does NOT expose the + // heartbeat service. O5 builds the heartbeat service and + // registers both via SetServices, matching the OmnipodKit shape. + if b.IsO5() { + h := gatt.NewService(heartbeatServiceUUID) + hbCharacteristic := h.AddCharacteristic(heartbeatCharUUID) + hbCharacteristic.HandleNotifyFunc( + func(r gatt.Request, n gatt.Notifier) { + b.heartbeatNotifierMtx.Lock() + b.heartbeatNotifier = n + b.heartbeatNotifierMtx.Unlock() + log.Infof("pkg bluetooth; handling heartbeat notifications on new connection from: %s", r.Central.ID()) + }) + + err = d.SetServices([]*gatt.Service{s, h}) + if err != nil { + log.Fatalf("pkg bluetooth; could not add service: %s", err) + } + } else { + err = d.AddService(s) + if err != nil { + log.Fatalf("pkg bluetooth; could not add service: %s", err) + } } // Mode-branch the advertisement so DASH stays byte-for-byte From 055097cc691abfb68b583906488f923db7513049 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:13:13 +0000 Subject: [PATCH 14/18] bluetooth: restore Dash packet transport (legacy 20-byte RTS/CTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Omnipod 5 port routed all message I/O through packet.Split/Join (244-byte fragments) unconditionally, so Dash phones — which frame at 20 bytes via the RTS/CTS/SUCCESS handshake — either rejected the oversized outbound fragments or failed to reassemble inbound responses sized differently from what packet.Join expected. The legacy writeMessage and readMessage paths still existed in the file but were no longer invoked by loop(). Split the message loop into two mode-specific siblings and dispatch on b.IsO5() once at entry: - loopDash: messageOutput -> writeMessage (legacy hand-rolled 20-byte fragments with RTS/CTS/SUCCESS handshake), cmdInput -> readMessage (consumes data fragments inline via b.ReadData() during the handshake). No data-input case — Dash never reads data outside of readMessage's RTS path, so a select case on dataInput would race readMessage's own reads. - loopO5: messageOutput -> writeMessageData (packet.Split, up to 244B per fragment), dataInput -> readMessageData -> parsePackets (packet.Join with closure-driven fragment fetch), and cmdInput -> readMessage retained as a harmless fallback (warns and returns nil for non-RTS bytes — O5 phones are not expected to send RTS at all). The packet subpackage's MaxPayloadSize = 244 stays; only the O5 path reaches it. message.go (TWi length fix, Type-4) and the cmd-char dispatcher are unchanged in this commit — Commit E will mode-branch the cmd routing. Also pulled in main's c797a81 fix to the two refreshDash Tracef calls (they need %v format directives or go test -vet fails CI). --- pkg/bluetooth/bluetooth.go | 52 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index fe50455..2974fad 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -154,8 +154,8 @@ func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { // advertiseDash but constructed inline so the trace logging matches main's // shape too). func (b *Ble) refreshDash(id []byte) error { - log.Tracef("podIdServiceOne", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) - log.Tracef("podIdServiceTwo", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) + log.Tracef("podIdServiceOne %v", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) + log.Tracef("podIdServiceTwo %v", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) return (*b.device).AdvertiseNameAndServices(dashAdvertiseName, []gatt.UUID{ gatt.UUID16(0x4024), @@ -505,7 +505,55 @@ func (b *Ble) WriteMessage(message *message.Message) { b.messageOutput <- message } +// loop dispatches to a mode-specific transport reader/writer. +// +// Dash and Omnipod 5 use fundamentally different fragmentation framings on +// the wire, so the message loop has to be branched at the top: Dash uses the +// legacy hand-rolled 20-byte RTS/CTS/SUCCESS handshake from origin/main, +// while O5 uses the packet.Split/Join framing aligned with OmnipodKit. +// Branching once here keeps each transport's select self-contained and +// avoids a hot-path b.IsO5() check per case. func (b *Ble) loop(stop chan bool) { + if b.IsO5() { + b.loopO5(stop) + } else { + b.loopDash(stop) + } +} + +// loopDash is the legacy origin/main message loop: outgoing messages go +// through writeMessage (20-byte RTS/CTS fragments) and incoming messages are +// reassembled by readMessage (which consumes data fragments from b.dataInput +// inline via b.ReadData() during the RTS handshake). There is intentionally +// NO `case data := <-b.dataInput` here — on Dash, the data characteristic's +// fragments are pulled by readMessage, not by the loop's select. +func (b *Ble) loopDash(stop chan bool) { + for { + select { + case <-stop: + return + case msg := <-b.messageOutput: + b.writeMessage(msg) + case cmd := <-b.cmdInput: + msg, err := b.readMessage(cmd) + if err != nil { + log.Fatalf("pkg bluetooth; error reading message: %s", err) + } + if msg != nil { + b.messageInput <- msg + } + } + } +} + +// loopO5 is the Omnipod 5 message loop: outgoing messages go through +// writeMessageData (packet.Split, up to 244-byte fragments) and incoming +// data fragments arrive on b.dataInput where readMessageData / packet.Join +// reassembles them. The cmdInput case is kept as a fallback for any RTS-like +// signal byte that slips through; readMessage warns and returns nil for +// non-RTS bytes (added in Commit 9), so this never fatals on stray cmd +// bytes — and on O5 the phone is not expected to send RTS at all. +func (b *Ble) loopO5(stop chan bool) { for { select { case <-stop: From 2a247f4dba438d2cc371a8dee67f7e8b3fa78ee6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:15:24 +0000 Subject: [PATCH 15/18] bluetooth: mode-gate command activation routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Omnipod 5 port introduced a dispatcher on the CMD characteristic that splits multi-byte / non-control writes onto a new cmdActivation channel, and pointed ReadCmd at cmdActivation. The intent was correct for O5 (HELLO arrives as a 7-byte frame and must reach pod.StartAcceptingCommands ahead of the message loop's RTS handshake), but the new routing was applied to Dash too. origin/main funnels every CMD write to cmdInput unconditionally and consumes it from there in both readMessage and ReadCmd. With the O5 dispatcher in place, a Dash phone's single-byte RTS still landed on cmdInput (matching the new control-byte fast-path), but multi-byte Dash writes drained to cmdActivation while readMessage was waiting on cmdInput — and ReadCmd was waiting on cmdActivation. Both sides starved. Mode-branch the dispatcher and ReadCmd: - HandleWriteFunc: in O5 mode, keep the current routing (multi-byte or non-RTS/SUCCESS/NACK/FAIL → cmdActivation; the four control bytes → cmdInput). In Dash mode, push every byte to cmdInput exactly like origin/main. - ReadCmd: in O5 mode, read from cmdActivation. In Dash mode, read from cmdInput. Both branches are documented inline so the asymmetry is explicit. With Commit D's loopDash dispatching cmdInput → readMessage and Commit C's Dash GATT only registering the 1A7E2441 cmd characteristic, the full Dash CMD path is now: phone write → cmdInput → loopDash readMessage, matching origin/main exactly. --- pkg/bluetooth/bluetooth.go | 44 ++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 2974fad..8798a1a 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -305,16 +305,26 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { log.Tracef("received CMD, %x", data) ret := make([]byte, len(data)) copy(ret, data) - // Multi-byte writes (e.g. HELLO = 06 01 04 + 4-byte - // controller ID) and any non-RTS/SUCCESS single-byte - // signal (HELLO, PAIR_STATUS, INCORRECT, …) belong on - // the activation queue. RTS / SUCCESS / NACK / FAIL - // stay on cmdInput where the message loop drains them - // for fragmentation handshake and ack handling. - if len(ret) > 1 || (ret[0] != CmdRTS[0] && ret[0] != CmdSuccess[0] && - ret[0] != CmdNACK[0] && ret[0] != CmdFail[0]) { - b.cmdActivation <- Packet(ret) + if b.IsO5() { + // O5: Multi-byte writes (e.g. HELLO = 06 01 04 + + // 4-byte controller ID) and any non-RTS/SUCCESS + // single-byte signal (HELLO, PAIR_STATUS, + // INCORRECT, …) belong on the activation queue. + // RTS / SUCCESS / NACK / FAIL stay on cmdInput + // where the message loop drains them for + // fragmentation handshake and ack handling. + if len(ret) > 1 || (ret[0] != CmdRTS[0] && ret[0] != CmdSuccess[0] && + ret[0] != CmdNACK[0] && ret[0] != CmdFail[0]) { + b.cmdActivation <- Packet(ret) + } else { + b.cmdInput <- Packet(ret) + } } else { + // Dash: origin/main routes every CMD write to + // cmdInput unconditionally. The message loop's + // readMessage path consumes RTS bytes there, and + // StartAcceptingCommands' ReadCmd() also reads + // from cmdInput on Dash. b.cmdInput <- Packet(ret) } return 0 @@ -457,12 +467,18 @@ func (b *Ble) writeDataBuffer(buf *bytes.Buffer) error { } // ReadCmd blocks until the next pairing-state command byte arrives on the -// CMD characteristic — typically the OmnipodKit HELLO frame -// (06 01 04 + 4-byte controller ID). RTS/CTS/SUCCESS/NACK/FAIL signal bytes -// are not delivered here; they are consumed inline by the message loop's -// readMessage path. +// CMD characteristic. On Dash this is origin/main's behavior: every CMD +// write is funnelled through cmdInput, including the single-byte RTS that +// StartAcceptingCommands waits for. On O5 the dispatcher splits multi-byte +// activation frames (HELLO = 06 01 04 + 4-byte controller ID, PAIR_STATUS, +// INCORRECT, …) onto cmdActivation while RTS/CTS/SUCCESS/NACK/FAIL stay on +// cmdInput for the message loop's fragmentation handshake. func (b *Ble) ReadCmd() (Packet, error) { - packet := <-b.cmdActivation + if b.IsO5() { + packet := <-b.cmdActivation + return packet, nil + } + packet := <-b.cmdInput return packet, nil } From a76aa8d0b68d882767919792d2ba3529a682fe9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:19:53 +0000 Subject: [PATCH 16/18] vendor,bluetooth: emit Omnipod 5 manufacturer-data on advertise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real Omnipod 5 pods include a 7-byte manufacturer-data field (60 03 00 01 00 00 00) in their BLE advertisement; OmnipodKit's scanner keys off that payload to recognise a pod, so an advertisement that only carries name + service UUIDs is invisible to the controller. The Dash port left this as a known gap because the vendored paypal/gatt fork did not expose a helper for emitting manufacturer-data alongside name and services. Cherry-pick the AdvertiseNameServicesMfgData helper from jwoglom/five's paypal/gatt fork: - vendor/github.com/paypal/gatt/device.go: add the method declaration to the public Device interface. - vendor/github.com/paypal/gatt/device_linux.go: full Linux impl — builds an AdvPacket with general-discoverable flags, fits the UUIDs, appends typeManufacturerData, and spills the name to scan-response only when it doesn't fit in the primary packet. - vendor/github.com/paypal/gatt/device_darwin.go: shim that forwards name + UUIDs (the upstream macOS XPC path does not surface a mfg-data slot; matches five's behavior). Pi/Linux is the only target where this matters for real-pod parity. vendor/github.com/paypal/gatt/adv.go is deliberately NOT touched — the local upstream-revert hack that bypasses the UUID-fit check stays exactly as on the current branch. Wire it up in pkg/bluetooth/bluetooth.go: - advertiseO5 and refreshO5 now call AdvertiseNameServicesMfgData with a package-level o5MfgData = []byte{0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00} so the wire bytes match OmnipodKit captures. - advertiseDash and refreshDash are untouched — they keep AdvertiseNameAndServices exactly like origin/main, so Dash btmon captures still compare cleanly with main. - The startup log gains a mfg_data= field so a Pi btmon trace can correlate against the in-process record. Dash logs an empty value. --- pkg/bluetooth/bluetooth.go | 43 +++++++++++++------ vendor/github.com/paypal/gatt/device.go | 2 + .../github.com/paypal/gatt/device_darwin.go | 12 ++++++ vendor/github.com/paypal/gatt/device_linux.go | 21 +++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 8798a1a..65b82a2 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -88,6 +88,12 @@ var DefaultServerOptions = []gatt.Option{ // btmon captures continue to compare cleanly with main. const dashAdvertiseName = " :: Fake POD ::" +// o5MfgData is the 7-byte manufacturer-data payload that real Omnipod 5 pods +// co-advertise. Source: OmnipodKit BLE advertisement bytes captured from +// production pod scans; the OmnipodKit scanner inspects this field during +// discovery, so we emit the exact same sequence for O5 advertisements. +var o5MfgData = []byte{0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00} + // advertiseDash advertises the Dash profile: the " :: Fake POD ::" name and // the 9-element UUID16 list from origin/main. This must remain byte-for-byte // identical to main so existing Dash users (and the test fixtures captured @@ -120,12 +126,13 @@ func (b *Ble) advertiseDash(d gatt.Device, podId []byte) error { } // advertiseO5 advertises the Omnipod 5 profile: an "AP 0A95B6110002761B" -// name and two 128-bit UUIDs (CE1F923D-...0A00 plus the ECF301E2... -// scanner identifier). When podId is nil the default {ff,ff,ff,fe} mapping -// is used. NOTE: the manufacturer-data field (60030001000000) that the real -// pod co-advertises is NOT emitted here — the vendored paypal/gatt in this -// tree does not expose AdvertiseNameServicesMfgData. Commit F is expected -// to add that helper. +// name, two 128-bit UUIDs (CE1F923D-...0A00 plus the ECF301E2... +// scanner identifier), and the 7-byte manufacturer-data payload that real +// Omnipod 5 pods co-advertise. When podId is nil the default {ff,ff,ff,fe} +// mapping is used. The mfg-data bytes (60 03 00 01 00 00 00) come straight +// from OmnipodKit's observed BLE advertisement frames — OmnipodKit's scanner +// keys off this field, so the vendored paypal/gatt fork now exposes +// AdvertiseNameServicesMfgData to let us emit it. func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { podIdArray, err := hex.DecodeString("fffffffe") if err != nil { @@ -140,12 +147,13 @@ func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { // ECF301E2... UUID is OmnipodKit's "advertisement" identifier // for the O5 heartbeat service and is co-advertised so the // scanner can find it during discovery (BluetoothServices.swift). - return d.AdvertiseNameAndServices( + return d.AdvertiseNameServicesMfgData( "AP "+strings.ToUpper(hex.EncodeToString(podIdArray))+" 0A95B6110002761B", []gatt.UUID{ gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(podIdArray) + "00"), gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), }, + o5MfgData, ) } @@ -174,14 +182,16 @@ func (b *Ble) refreshDash(id []byte) error { } // refreshO5 is the Omnipod 5 re-advertise path used after SetUniqueID. Same -// shape as advertiseO5; manufacturer-data is still deferred to Commit F. +// shape as advertiseO5, including the OmnipodKit-observed manufacturer-data +// payload so post-pair re-advertise frames still match real-pod BLE captures. func (b *Ble) refreshO5(id []byte) error { - return (*b.device).AdvertiseNameAndServices( + return (*b.device).AdvertiseNameServicesMfgData( "AP "+strings.ToUpper(hex.EncodeToString(id))+" 0A95B6110002761B", []gatt.UUID{ gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(id) + "00"), gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), }, + o5MfgData, ) } @@ -394,13 +404,13 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { // Mode-branch the advertisement so DASH stays byte-for-byte // identical to origin/main (which is what existing Dash scanners - // match against) while O5 keeps the OmnipodKit-shaped name and - // 128-bit UUID list. The manufacturer-data payload that real O5 - // pods co-advertise is still missing and will be added in Commit - // F when the vendored paypal/gatt gains a helper for it. + // match against) while O5 keeps the OmnipodKit-shaped name, + // 128-bit UUID list, and the manufacturer-data payload that real + // pods co-advertise. var advertiseErr error var advertisedName string var advertisedUUIDCount int + var advertisedMfg []byte if b.IsO5() { advertiseErr = b.advertiseO5(d, podId) // Mirror the name/uuid-count computation from advertiseO5 so @@ -411,15 +421,20 @@ func New(adapterID string, podId []byte, mode pair.Mode) (*Ble, error) { } advertisedName = "AP " + idHex + " 0A95B6110002761B" advertisedUUIDCount = 2 + advertisedMfg = o5MfgData } else { advertiseErr = b.advertiseDash(d, podId) advertisedName = dashAdvertiseName advertisedUUIDCount = 9 + advertisedMfg = nil } if advertiseErr != nil { log.Fatalf("pkg bluetooth; advertise: %s", advertiseErr) } - log.Infof("pkg bluetooth; advertising name=%q uuid_count=%d", advertisedName, advertisedUUIDCount) + // mfg_data is logged hex-encoded so a Pi btmon capture can be + // correlated against the bytes we asked paypal/gatt to emit. + // Dash leaves it empty; O5 prints the 7-byte payload. + log.Infof("pkg bluetooth; advertising name=%q uuid_count=%d mfg_data=%s", advertisedName, advertisedUUIDCount, hex.EncodeToString(advertisedMfg)) default: } } diff --git a/vendor/github.com/paypal/gatt/device.go b/vendor/github.com/paypal/gatt/device.go index 93d423e..7d33f06 100644 --- a/vendor/github.com/paypal/gatt/device.go +++ b/vendor/github.com/paypal/gatt/device.go @@ -44,6 +44,8 @@ type Device interface { // If name doesn't fit in the advertising packet, it will be put in scan response. AdvertiseNameAndServices(name string, ss []UUID) error + AdvertiseNameServicesMfgData(name string, ss []UUID, mfg []byte) error + // AdvertiseIBeaconData advertise iBeacon with given manufacturer data. AdvertiseIBeaconData(b []byte) error diff --git a/vendor/github.com/paypal/gatt/device_darwin.go b/vendor/github.com/paypal/gatt/device_darwin.go index 9504b4b..d9b339b 100644 --- a/vendor/github.com/paypal/gatt/device_darwin.go +++ b/vendor/github.com/paypal/gatt/device_darwin.go @@ -103,6 +103,18 @@ func (d *device) AdvertiseNameAndServices(name string, ss []UUID) error { return nil } +func (d *device) AdvertiseNameServicesMfgData(name string, ss []UUID, mfg []byte) error { + us := uuidSlice(ss) + rsp := d.sendReq(8, xpc.Dict{ + "kCBAdvDataLocalName": name, + "kCBAdvDataServiceUUIDs": us}, + ) + if res := rsp.MustGetInt("kCBMsgArgResult"); res != 0 { + return errors.New("FIXME: Advertise error") + } + return nil +} + func (d *device) AdvertiseIBeaconData(data []byte) error { var utsname xpc.Utsname xpc.Uname(&utsname) diff --git a/vendor/github.com/paypal/gatt/device_linux.go b/vendor/github.com/paypal/gatt/device_linux.go index f6f9fb1..dbe9eac 100644 --- a/vendor/github.com/paypal/gatt/device_linux.go +++ b/vendor/github.com/paypal/gatt/device_linux.go @@ -166,6 +166,27 @@ func (d *device) AdvertiseNameAndServices(name string, uu []UUID) error { return d.Advertise(a) } +func (d *device) AdvertiseNameServicesMfgData(name string, uu []UUID, mfg []byte) error { + a := &AdvPacket{} + a.AppendFlags(flagGeneralDiscoverable | flagLEOnly) + a.AppendUUIDFit(uu) + a.AppendField(typeManufacturerData, mfg) + + if len(a.b)+len(name)+2 < MaxEIRPacketLength { + a.AppendName(name) + d.scanResp = nil + } else { + a := &AdvPacket{} + a.AppendName(name) + d.scanResp = &cmd.LESetScanResponseData{ + ScanResponseDataLength: uint8(a.Len()), + ScanResponseData: a.Bytes(), + } + } + + return d.Advertise(a) +} + func (d *device) AdvertiseIBeaconData(b []byte) error { a := &AdvPacket{} a.AppendFlags(flagGeneralDiscoverable | flagLEOnly) From 9c4538a925b5ac5b478e5ac7d7a3266d1d0970fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:24:02 +0000 Subject: [PATCH 17/18] pod: fix mode persistence; don't overwrite state.Mode on restart Commit 7 of the original O5 port made the response layer mode-aware by persisting state.Mode and reading it on every command. Commit 7 also unconditionally rewrote state.Mode = pairMode on every pod.New call, which combined with main.go's -mode flag defaulting to "dash" produced silent state corruption: 1. ./pod -fresh -mode o5 state.toml: mode = "o5" 2. ./pod flag = "dash" (default) pod.New: state.Mode = ModeDash state.toml rewritten to "dash" BLE now advertises Dash; the pod starts returning Dash response bytes for SetUniqueID/GetVersion against an O5 controller. Introduce pod.ResolveMode(state, flag, fresh) -> (resolved, conflict) and call it from main.go before bluetooth.New so the BLE profile is chosen from the reconciled mode, not the raw flag. Precedence: - fresh start: the flag wins (caller persists it). - restart, state.Mode matches flag: no warning, no write. - restart, state.Mode differs from flag: use persisted value, warn loudly, do NOT rewrite state.toml. The operator can pass -fresh to force a reset. - restart, legacy state file with no mode field: TOML decodes Mode as the zero value (ModeDash); matches the default flag, no warning. pod.New now only persists state.Mode on freshState. On a restart it trusts the resolved pairMode for in-memory routing and leaves the persisted value alone. A defensive "argument != persisted" check remains as belt-and-suspenders: it logs a loud warning if some future caller forgets to ResolveMode first, but still never silently overwrites the file. Tests in pkg/pod/state_test.go cover all seven ResolveMode permutations and round-trip pod.New through an O5 state.toml with the default Dash flag, asserting the file is preserved verbatim. This is the last functional commit in the BLE repair series (A-G). Commit H will add the BLE-profile regression tests. --- main.go | 11 +++ pkg/pod/pod.go | 22 ++++-- pkg/pod/state.go | 24 ++++++ pkg/pod/state_test.go | 175 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 pkg/pod/state_test.go diff --git a/main.go b/main.go index cdd27e0..cc337e5 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,17 @@ func main() { log.Tracef("podId %x", state.Id) + // Reconcile the CLI -mode flag against any persisted mode so a + // restart without -mode doesn't silently rewrite an O5 state to + // Dash (the flag's default). On a fresh start the flag wins; on a + // restart the persisted value wins and we warn on mismatch. + resolvedMode, modeConflict := pod.ResolveMode(state, pairMode, *freshState) + if modeConflict { + log.Warnf("persisted mode %q differs from -mode flag %q; using persisted value (pass -fresh to override)", + state.Mode, pairMode) + } + pairMode = resolvedMode + ble, err := bluetooth.New("hci0", state.Id, pairMode) //defer ble.Close() if err != nil { diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index c80e9df..b355988 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -59,11 +59,23 @@ func New(ble *bluetooth.Ble, stateFile string, freshState bool, pairMode pair.Mo // Mirror the active pair mode onto persisted state so response-layer // code (which doesn't see the Pod struct) can pick the right Dash/O5 - // byte stream. Always overwrite: the -mode flag is the source of - // truth for the current run. - state.Mode = pairMode - if err := state.Save(); err != nil { - log.Warnf("pkg pod; could not persist pair mode to state: %s", err) + // byte stream. On a fresh start the CLI flag is authoritative and we + // persist it. On a restart we leave state.Mode alone: the caller is + // expected to have already reconciled the CLI flag against state.Mode + // via ResolveMode and to pass the resolved value in here, so silently + // overwriting would corrupt the persisted mode whenever the operator + // forgot the -mode flag. See pkg/pod/state.go ResolveMode. + if freshState { + state.Mode = pairMode + if err := state.Save(); err != nil { + log.Warnf("pkg pod; could not persist pair mode to state: %s", err) + } + } else if state.Mode != pairMode { + // Defensive: caller forgot to reconcile. Trust the resolved + // pairMode argument for in-memory routing without rewriting + // the persisted value, and log loudly so the bug is visible. + log.Warnf("pkg pod; persisted mode %q != resolved mode %q passed to pod.New; in-memory routing will use the argument but state.toml will NOT be overwritten", + state.Mode, pairMode) } ret := &Pod{ diff --git a/pkg/pod/state.go b/pkg/pod/state.go index e93bae5..ffca25f 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -89,6 +89,30 @@ func (p *PODState) Save() error { return ioutil.WriteFile(p.Filename, data, 0777) } +// ResolveMode picks the effective pairing mode for this process, honouring +// persisted state across restarts so the operator doesn't silently rewrite +// an O5 state.toml to Dash by forgetting the -mode flag. +// +// Precedence: +// +// - freshState=true: the CLI flag wins; caller is expected to persist it. +// - freshState=false: state.Mode wins. If it differs from the CLI flag, +// conflict=true is reported so the caller can warn the operator. The +// resolved value is still the persisted one (use -fresh to override). +// +// Because pair.ModeDash is the zero value, a legacy state file written +// before the mode field existed deserializes as ModeDash, which lines up +// with the default CLI flag and produces no conflict. +func ResolveMode(state *PODState, flagMode pair.Mode, freshState bool) (resolved pair.Mode, conflict bool) { + if freshState || state == nil { + return flagMode, false + } + if state.Mode != flagMode { + return state.Mode, true + } + return state.Mode, false +} + func (p *PODState) MinutesActive() uint16 { return uint16(time.Now().Sub(p.ActivationTime).Round(time.Minute).Minutes()) } diff --git a/pkg/pod/state_test.go b/pkg/pod/state_test.go new file mode 100644 index 0000000..7d93334 --- /dev/null +++ b/pkg/pod/state_test.go @@ -0,0 +1,175 @@ +package pod + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + toml "github.com/pelletier/go-toml" + + "github.com/avereha/pod/pkg/pair" +) + +func TestResolveMode(t *testing.T) { + tests := []struct { + name string + state *PODState + flagMode pair.Mode + fresh bool + wantResolved pair.Mode + wantConflict bool + }{ + { + name: "fresh start with o5 flag", + state: &PODState{Mode: pair.ModeO5}, + flagMode: pair.ModeO5, + fresh: true, + wantResolved: pair.ModeO5, + wantConflict: false, + }, + { + name: "fresh start overrides persisted dash with o5 flag", + state: &PODState{Mode: pair.ModeDash}, + flagMode: pair.ModeO5, + fresh: true, + wantResolved: pair.ModeO5, + wantConflict: false, + }, + { + name: "restart o5 with default dash flag picks o5 and flags conflict", + state: &PODState{Mode: pair.ModeO5}, + flagMode: pair.ModeDash, + fresh: false, + wantResolved: pair.ModeO5, + wantConflict: true, + }, + { + name: "restart matching dash", + state: &PODState{Mode: pair.ModeDash}, + flagMode: pair.ModeDash, + fresh: false, + wantResolved: pair.ModeDash, + wantConflict: false, + }, + { + name: "restart matching o5", + state: &PODState{Mode: pair.ModeO5}, + flagMode: pair.ModeO5, + fresh: false, + wantResolved: pair.ModeO5, + wantConflict: false, + }, + { + name: "legacy state (no mode field) with default dash flag", + state: &PODState{}, // Mode is zero == ModeDash + flagMode: pair.ModeDash, + fresh: false, + wantResolved: pair.ModeDash, + wantConflict: false, + }, + { + name: "nil state defers to flag", + state: nil, + flagMode: pair.ModeO5, + fresh: false, + wantResolved: pair.ModeO5, + wantConflict: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, conflict := ResolveMode(tc.state, tc.flagMode, tc.fresh) + if got != tc.wantResolved { + t.Errorf("resolved mode = %v, want %v", got, tc.wantResolved) + } + if conflict != tc.wantConflict { + t.Errorf("conflict = %v, want %v", conflict, tc.wantConflict) + } + }) + } +} + +// TestNewDoesNotOverwriteO5StateWithDashFlag exercises the original PR audit +// bug: ./pod (with default -mode dash) against an O5 state.toml must not +// rewrite the file to mode = "dash". +func TestNewDoesNotOverwriteO5StateWithDashFlag(t *testing.T) { + dir, err := ioutil.TempDir("", "podstate-") + if err != nil { + t.Fatalf("tempdir: %v", err) + } + defer os.RemoveAll(dir) + + statePath := filepath.Join(dir, "state.toml") + + // Seed an O5 state on disk. + seed := &PODState{Filename: statePath, Mode: pair.ModeO5} + if err := seed.Save(); err != nil { + t.Fatalf("seed save: %v", err) + } + + // Simulate the buggy invocation: user reruns ./pod with the default + // -mode dash flag. main.go first calls ResolveMode to reconcile. + loaded, err := NewState(statePath) + if err != nil { + t.Fatalf("NewState: %v", err) + } + resolved, conflict := ResolveMode(loaded, pair.ModeDash, false) + if !conflict { + t.Fatalf("expected conflict to be reported, got false") + } + if resolved != pair.ModeO5 { + t.Fatalf("expected resolved=O5, got %v", resolved) + } + + // pod.New gets called with the resolved value (O5) and freshState=false. + // We don't need a real bluetooth.Ble for the path under test, but + // pod.New stores the value and calls state.Save() only on fresh starts. + p := New(nil, statePath, false, resolved) + if p == nil { + t.Fatalf("New returned nil") + } + + // Re-read state.toml from disk and confirm mode is still O5. + raw, err := ioutil.ReadFile(statePath) + if err != nil { + t.Fatalf("readback: %v", err) + } + var got PODState + if err := toml.Unmarshal(raw, &got); err != nil { + t.Fatalf("toml unmarshal: %v", err) + } + if got.Mode != pair.ModeO5 { + t.Errorf("persisted mode after restart = %v, want %v (O5)", got.Mode, pair.ModeO5) + } +} + +// TestNewFreshStartPersistsFlag confirms that on -fresh the flag value wins +// and is persisted to disk. +func TestNewFreshStartPersistsFlag(t *testing.T) { + dir, err := ioutil.TempDir("", "podstate-") + if err != nil { + t.Fatalf("tempdir: %v", err) + } + defer os.RemoveAll(dir) + + statePath := filepath.Join(dir, "state.toml") + + p := New(nil, statePath, true, pair.ModeO5) + if p == nil { + t.Fatalf("New returned nil") + } + + raw, err := ioutil.ReadFile(statePath) + if err != nil { + t.Fatalf("readback: %v", err) + } + var got PODState + if err := toml.Unmarshal(raw, &got); err != nil { + t.Fatalf("toml unmarshal: %v", err) + } + if got.Mode != pair.ModeO5 { + t.Errorf("persisted mode after fresh start = %v, want %v (O5)", got.Mode, pair.ModeO5) + } +} From ae587a870f8e16f7c33fb4fac1bbd3e3b27485a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:29:02 +0000 Subject: [PATCH 18/18] bluetooth,aid: Dash regression + O5 discovery tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add programmatic guards so a future O5-side change can't silently reintroduce the Dash advertising / GATT / mfg-data regressions the previous commit series fixed. pkg/bluetooth/bluetooth.go (structural refactor only, no behavior change): introduce a narrow `advertiser` interface that pulls AdvertiseNameAndServices and AdvertiseNameServicesMfgData out of the gatt.Device shape. The helpers advertiseDash / advertiseO5 / refreshDash / refreshO5 now take this interface; gatt.Device satisfies it implicitly, so the production call sites pass the device unchanged. This makes the helpers testable without standing up a real BLE stack. pkg/bluetooth/profile_test.go (new): six table-driven tests assert the exact wire bytes each mode produces. - TestAdvertiseDashBytes / TestAdvertiseDashDefaultPodId: name is " :: Fake POD ::", 9 UUIDs in main's exact order (0x4024, 0x2470, 0x000a, podIdOne, podIdTwo, 0x0814, 0x6DB1, 0x0006, 0xE451), AdvertiseNameAndServices is the method called (not the mfg form), default podId mapping is 0xffff / 0xfffe. - TestAdvertiseO5Bytes / TestAdvertiseO5DefaultPodId: name is "AP 0A95B6110002761B", exactly 2 UUIDs (CE1F923D-...-0A00 and ECF301E2-674B-4474-94D0-364F3AA653E6), AdvertiseNameServicesMfgData is the method called, mfg payload is {0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00}, default podId is FFFFFFFE. - TestRefreshDashBytes / TestRefreshO5Bytes: same shape for the post-SetUniqueID refresh path. pkg/aid/aid_test.go: extend the existing TestIsAIDPayload table with four Dash SLPE prefixes (SET_UNIQUE_ID, GET_VERSION, GET_STATUS, PROGRAM_INSULIN — all starting with "S0.0=") and assert IsAIDPayload returns false. Guards against any future aid.go change that would misclassify a Dash command into the AID branch. End of the BLE repair series. --- pkg/aid/aid_test.go | 69 ++++++++++ pkg/bluetooth/bluetooth.go | 32 +++-- pkg/bluetooth/profile_test.go | 241 ++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 pkg/bluetooth/profile_test.go diff --git a/pkg/aid/aid_test.go b/pkg/aid/aid_test.go index 60c4c5a..44345f8 100644 --- a/pkg/aid/aid_test.go +++ b/pkg/aid/aid_test.go @@ -18,6 +18,75 @@ func TestIsAIDPayload(t *testing.T) { {"AID GET 3.12", []byte("G3.12"), true}, {"too short", []byte{'S'}, false}, {"random bytes", []byte{0x01, 0x02, 0x03, 0x04}, false}, + // Regression cases: real-world Dash SLPE-wrapped command payloads + // must NOT be classified as AID — they all begin with the literal + // "S0.0=" envelope (feature "0"), and an O5-side AID parse would + // silently corrupt the Dash command path otherwise. The body bytes + // after the 2-byte length prefix include the command type + // (data[6] in command.Unmarshal): 0x03 SET_UNIQUE_ID, 0x07 + // GET_VERSION, 0x0e GET_STATUS, 0x1a PROGRAM_INSULIN. + { + name: "Dash SET_UNIQUE_ID (S0.0= prefix)", + // "S0.0=" + 2-byte length + 4-byte id + 2-byte lsf + 0x03 type + // + body bytes + 2-byte crc + ",G0.0". + data: append( + append([]byte("S0.0="), 0x00, 0x15), + append([]byte{ + 0xff, 0xff, 0xff, 0xfe, // id + 0x00, 0x13, // lsf (seq=0,len=0x13) + 0x03, // SET_UNIQUE_ID + // SetUniqueID body is large; just stub plausible bytes. + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, // 17 body bytes (length=0x13 = 19 -> body+crc=19) + 0xab, 0xcd, // crc + }, []byte(",G0.0")...)..., + ), + want: false, + }, + { + name: "Dash GET_VERSION (S0.0= prefix)", + // Empty-body GET_VERSION still has the S0.0= envelope. + data: append( + append([]byte("S0.0="), 0x00, 0x0a), + append([]byte{ + 0xff, 0xff, 0xff, 0xfe, // id + 0x00, 0x02, // lsf (len=2) + 0x07, // GET_VERSION + 0x00, // body + 0xab, 0xcd, // crc + }, []byte(",G0.0")...)..., + ), + want: false, + }, + { + name: "Dash GET_STATUS (S0.0= prefix)", + data: append( + append([]byte("S0.0="), 0x00, 0x0a), + append([]byte{ + 0xff, 0xff, 0xff, 0xfe, // id + 0x00, 0x02, // lsf + 0x0e, // GET_STATUS + 0x00, // body + 0xab, 0xcd, // crc + }, []byte(",G0.0")...)..., + ), + want: false, + }, + { + name: "Dash PROGRAM_INSULIN (S0.0= prefix)", + data: append( + append([]byte("S0.0="), 0x00, 0x10), + append([]byte{ + 0xff, 0xff, 0xff, 0xfe, // id + 0x00, 0x08, // lsf + 0x1a, // PROGRAM_INSULIN + 0x13, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, // body + 0xab, 0xcd, // crc + }, []byte(",G0.0")...)..., + ), + want: false, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 65b82a2..f3d9c4f 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -81,6 +81,17 @@ var DefaultServerOptions = []gatt.Option{ }), } +// advertiser is the narrow subset of gatt.Device the advertise/refresh +// helpers need. Pulling these two calls behind an interface lets the +// regression tests in profile_test.go capture the exact name/UUID/mfg-data +// arguments each mode produces without having to stand up a real gatt.Device. +// gatt.Device satisfies this interface implicitly, so the production call +// sites pass the device unchanged. +type advertiser interface { + AdvertiseNameAndServices(name string, ss []gatt.UUID) error + AdvertiseNameServicesMfgData(name string, ss []gatt.UUID, mfg []byte) error +} + // dashAdvertiseName is the device name the Dash simulator advertises. It is // the literal string origin/main has shipped with since the project's first // commit; OmnipodKit's Dash scanner does not rely on the name (it matches by @@ -99,7 +110,7 @@ var o5MfgData = []byte{0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00} // identical to main so existing Dash users (and the test fixtures captured // against main) keep working. podId is the 4-byte pod address; when nil the // default {ff,ff,ff,fe} mapping from main is used. -func (b *Ble) advertiseDash(d gatt.Device, podId []byte) error { +func (b *Ble) advertiseDash(d advertiser, podId []byte) error { podIdServiceOne := gatt.UUID16(0xffff) podIdServiceTwo := gatt.UUID16(0xfffe) if podId != nil { @@ -133,7 +144,7 @@ func (b *Ble) advertiseDash(d gatt.Device, podId []byte) error { // from OmnipodKit's observed BLE advertisement frames — OmnipodKit's scanner // keys off this field, so the vendored paypal/gatt fork now exposes // AdvertiseNameServicesMfgData to let us emit it. -func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { +func (b *Ble) advertiseO5(d advertiser, podId []byte) error { podIdArray, err := hex.DecodeString("fffffffe") if err != nil { return fmt.Errorf("could not parse default address: %w", err) @@ -160,11 +171,12 @@ func (b *Ble) advertiseO5(d gatt.Device, podId []byte) error { // refreshDash is the Dash-profile re-advertise path used after SetUniqueID // updates the pod address. Verbatim form from origin/main (same UUID list as // advertiseDash but constructed inline so the trace logging matches main's -// shape too). -func (b *Ble) refreshDash(id []byte) error { +// shape too). The advertiser parameter is the same gatt.Device the live +// caller uses in production; tests pass a mock to capture the exact call. +func (b *Ble) refreshDash(d advertiser, id []byte) error { log.Tracef("podIdServiceOne %v", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) log.Tracef("podIdServiceTwo %v", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) - return (*b.device).AdvertiseNameAndServices(dashAdvertiseName, []gatt.UUID{ + return d.AdvertiseNameAndServices(dashAdvertiseName, []gatt.UUID{ gatt.UUID16(0x4024), gatt.UUID16(0x2470), @@ -184,8 +196,10 @@ func (b *Ble) refreshDash(id []byte) error { // refreshO5 is the Omnipod 5 re-advertise path used after SetUniqueID. Same // shape as advertiseO5, including the OmnipodKit-observed manufacturer-data // payload so post-pair re-advertise frames still match real-pod BLE captures. -func (b *Ble) refreshO5(id []byte) error { - return (*b.device).AdvertiseNameServicesMfgData( +// The advertiser parameter is the same gatt.Device the live caller uses in +// production; tests pass a mock to capture the exact call. +func (b *Ble) refreshO5(d advertiser, id []byte) error { + return d.AdvertiseNameServicesMfgData( "AP "+strings.ToUpper(hex.EncodeToString(id))+" 0A95B6110002761B", []gatt.UUID{ gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0A" + hex.EncodeToString(id) + "00"), @@ -453,9 +467,9 @@ func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, f // origin/main byte-for-byte while O5 keeps the OmnipodKit-shaped form. var err error if b.IsO5() { - err = b.refreshO5(id) + err = b.refreshO5(*b.device, id) } else { - err = b.refreshDash(id) + err = b.refreshDash(*b.device, id) } if err != nil { log.Infof("pkg bluetooth; could not re-advertise: %s", err) diff --git a/pkg/bluetooth/profile_test.go b/pkg/bluetooth/profile_test.go new file mode 100644 index 0000000..0c93dc9 --- /dev/null +++ b/pkg/bluetooth/profile_test.go @@ -0,0 +1,241 @@ +package bluetooth + +// profile_test.go is the Commit H regression guard for the Dash vs Omnipod 5 +// advertisement payloads. The four helpers (advertiseDash, advertiseO5, +// refreshDash, refreshO5) take a narrow `advertiser` interface (defined in +// bluetooth.go) so these tests can capture the exact name / UUID list / +// manufacturer-data each mode emits without instantiating a real +// paypal/gatt device. A future change to the O5 advertisement shape that +// inadvertently mutates the Dash form will trip the byte-for-byte checks +// here and fail CI before silently breaking existing Dash users. + +import ( + "bytes" + "testing" + + "github.com/avereha/pod/pkg/pair" + "github.com/paypal/gatt" +) + +// advCall records a single AdvertiseNameAndServices call. +type advCall struct { + name string + uuids []gatt.UUID +} + +// mfgCall records a single AdvertiseNameServicesMfgData call. +type mfgCall struct { + name string + uuids []gatt.UUID + mfg []byte +} + +// fakeAdvertiser implements the advertiser interface and records every call. +// Only the two advertise variants are exercised; the rest of gatt.Device is +// not used by the helpers under test, so no further surface is mocked. +type fakeAdvertiser struct { + adv []advCall + mfg []mfgCall +} + +func (f *fakeAdvertiser) AdvertiseNameAndServices(name string, ss []gatt.UUID) error { + // Defensive copy so test mutations of the underlying slice can't poison + // what the test asserts against. + cp := make([]gatt.UUID, len(ss)) + copy(cp, ss) + f.adv = append(f.adv, advCall{name: name, uuids: cp}) + return nil +} + +func (f *fakeAdvertiser) AdvertiseNameServicesMfgData(name string, ss []gatt.UUID, mfg []byte) error { + cp := make([]gatt.UUID, len(ss)) + copy(cp, ss) + mfgCp := make([]byte, len(mfg)) + copy(mfgCp, mfg) + f.mfg = append(f.mfg, mfgCall{name: name, uuids: cp, mfg: mfgCp}) + return nil +} + +// uuidEqual is shorthand for asserting a UUID matches one constructed inline. +func uuidEqual(t *testing.T, idx int, got, want gatt.UUID) { + t.Helper() + if !got.Equal(want) { + t.Errorf("uuid[%d] = %s, want %s", idx, got.String(), want.String()) + } +} + +func TestAdvertiseDashBytes(t *testing.T) { + b := &Ble{mode: pair.ModeDash} + fa := &fakeAdvertiser{} + + if err := b.advertiseDash(fa, []byte{0xaa, 0xbb, 0xcc, 0xdd}); err != nil { + t.Fatalf("advertiseDash returned error: %v", err) + } + + if len(fa.adv) != 1 { + t.Fatalf("expected exactly one AdvertiseNameAndServices call, got %d", len(fa.adv)) + } + if len(fa.mfg) != 0 { + t.Fatalf("Dash advertisement must NOT use mfg-data form; got %d mfg calls", len(fa.mfg)) + } + + got := fa.adv[0] + if got.name != " :: Fake POD ::" { + t.Errorf("name = %q, want %q", got.name, " :: Fake POD ::") + } + if len(got.uuids) != 9 { + t.Fatalf("expected 9 UUIDs, got %d", len(got.uuids)) + } + // Verbatim from origin/main: 0x4024, 0x2470, 0x000a, podIdOne, podIdTwo, + // 0x0814, 0x6DB1, 0x0006, 0xE451. + want := []gatt.UUID{ + gatt.UUID16(0x4024), + gatt.UUID16(0x2470), + gatt.UUID16(0x000a), + gatt.UUID16(0xaabb), + gatt.UUID16(0xccdd), + gatt.UUID16(0x0814), + gatt.UUID16(0x6DB1), + gatt.UUID16(0x0006), + gatt.UUID16(0xE451), + } + for i := range want { + uuidEqual(t, i, got.uuids[i], want[i]) + } +} + +func TestAdvertiseDashDefaultPodId(t *testing.T) { + b := &Ble{mode: pair.ModeDash} + fa := &fakeAdvertiser{} + + if err := b.advertiseDash(fa, nil); err != nil { + t.Fatalf("advertiseDash returned error: %v", err) + } + if len(fa.adv) != 1 { + t.Fatalf("expected one adv call, got %d", len(fa.adv)) + } + // Default mapping from origin/main: 0xffff / 0xfffe. + uuidEqual(t, 3, fa.adv[0].uuids[3], gatt.UUID16(0xffff)) + uuidEqual(t, 4, fa.adv[0].uuids[4], gatt.UUID16(0xfffe)) +} + +func TestAdvertiseO5Bytes(t *testing.T) { + b := &Ble{mode: pair.ModeO5} + fa := &fakeAdvertiser{} + + if err := b.advertiseO5(fa, []byte{0xaa, 0xbb, 0xcc, 0xdd}); err != nil { + t.Fatalf("advertiseO5 returned error: %v", err) + } + + if len(fa.mfg) != 1 { + t.Fatalf("expected exactly one AdvertiseNameServicesMfgData call, got %d", len(fa.mfg)) + } + if len(fa.adv) != 0 { + t.Fatalf("O5 advertisement must NOT use plain form; got %d plain adv calls", len(fa.adv)) + } + + got := fa.mfg[0] + wantName := "AP AABBCCDD 0A95B6110002761B" + if got.name != wantName { + t.Errorf("name = %q, want %q", got.name, wantName) + } + if len(got.uuids) != 2 { + t.Fatalf("expected 2 UUIDs, got %d", len(got.uuids)) + } + uuidEqual(t, 0, got.uuids[0], gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0AAABBCCDD00")) + uuidEqual(t, 1, got.uuids[1], gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6")) + + // Manufacturer data is the OmnipodKit-observed 7-byte payload. + wantMfg := []byte{0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00} + if !bytes.Equal(got.mfg, wantMfg) { + t.Errorf("mfg = %x, want %x", got.mfg, wantMfg) + } +} + +func TestAdvertiseO5DefaultPodId(t *testing.T) { + b := &Ble{mode: pair.ModeO5} + fa := &fakeAdvertiser{} + + if err := b.advertiseO5(fa, nil); err != nil { + t.Fatalf("advertiseO5 returned error: %v", err) + } + if len(fa.mfg) != 1 { + t.Fatalf("expected one mfg call, got %d", len(fa.mfg)) + } + got := fa.mfg[0] + wantName := "AP FFFFFFFE 0A95B6110002761B" + if got.name != wantName { + t.Errorf("default-id name = %q, want %q", got.name, wantName) + } + uuidEqual(t, 0, got.uuids[0], gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0AFFFFFFFE00")) +} + +func TestRefreshDashBytes(t *testing.T) { + b := &Ble{mode: pair.ModeDash} + fa := &fakeAdvertiser{} + + if err := b.refreshDash(fa, []byte{0xaa, 0xbb, 0xcc, 0xdd}); err != nil { + t.Fatalf("refreshDash returned error: %v", err) + } + + if len(fa.adv) != 1 { + t.Fatalf("expected exactly one AdvertiseNameAndServices call, got %d", len(fa.adv)) + } + if len(fa.mfg) != 0 { + t.Fatalf("Dash refresh must NOT use mfg-data form; got %d mfg calls", len(fa.mfg)) + } + + got := fa.adv[0] + if got.name != " :: Fake POD ::" { + t.Errorf("name = %q, want %q", got.name, " :: Fake POD ::") + } + if len(got.uuids) != 9 { + t.Fatalf("expected 9 UUIDs, got %d", len(got.uuids)) + } + want := []gatt.UUID{ + gatt.UUID16(0x4024), + gatt.UUID16(0x2470), + gatt.UUID16(0x000a), + gatt.UUID16(0xaabb), + gatt.UUID16(0xccdd), + gatt.UUID16(0x0814), + gatt.UUID16(0x6DB1), + gatt.UUID16(0x0006), + gatt.UUID16(0xE451), + } + for i := range want { + uuidEqual(t, i, got.uuids[i], want[i]) + } +} + +func TestRefreshO5Bytes(t *testing.T) { + b := &Ble{mode: pair.ModeO5} + fa := &fakeAdvertiser{} + + if err := b.refreshO5(fa, []byte{0xaa, 0xbb, 0xcc, 0xdd}); err != nil { + t.Fatalf("refreshO5 returned error: %v", err) + } + + if len(fa.mfg) != 1 { + t.Fatalf("expected exactly one AdvertiseNameServicesMfgData call, got %d", len(fa.mfg)) + } + if len(fa.adv) != 0 { + t.Fatalf("O5 refresh must NOT use plain form; got %d plain adv calls", len(fa.adv)) + } + + got := fa.mfg[0] + wantName := "AP AABBCCDD 0A95B6110002761B" + if got.name != wantName { + t.Errorf("name = %q, want %q", got.name, wantName) + } + if len(got.uuids) != 2 { + t.Fatalf("expected 2 UUIDs, got %d", len(got.uuids)) + } + uuidEqual(t, 0, got.uuids[0], gatt.MustParseUUID("CE1F923D-C539-48EA-7300-0AAABBCCDD00")) + uuidEqual(t, 1, got.uuids[1], gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6")) + + wantMfg := []byte{0x60, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00} + if !bytes.Equal(got.mfg, wantMfg) { + t.Errorf("mfg = %x, want %x", got.mfg, wantMfg) + } +}