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/main.go b/main.go index 12a6bb5..cc337e5 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 { @@ -49,13 +55,25 @@ func main() { log.Tracef("podId %x", state.Id) - ble, err := bluetooth.New("hci0", 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 { 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/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..44345f8 --- /dev/null +++ b/pkg/aid/aid_test.go @@ -0,0 +1,219 @@ +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}, + // 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) { + 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/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index bfda998..f3d9c4f 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -7,10 +7,13 @@ import ( "errors" "fmt" "hash/crc32" + "strings" "sync" "time" + "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" @@ -29,10 +32,21 @@ var ( ) type Ble struct { - dataInput chan Packet - cmdInput chan Packet - dataOutput chan Packet - cmdOutput chan Packet + // 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, + // 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. + cmdActivation chan Packet + dataOutput chan Packet + cmdOutput chan Packet messageInput chan *message.Message messageOutput chan *message.Message @@ -46,8 +60,17 @@ type Ble struct { dataNotifier gatt.Notifier dataNotifierMtx sync.Mutex + + heartbeatNotifier gatt.Notifier + 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), @@ -58,15 +81,145 @@ var DefaultServerOptions = []gatt.Option{ }), } -func New(adapterID string, podId []byte) (*Ble, error) { +// 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 +// 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 ::" + +// 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 +// 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 advertiser, 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, 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 advertiser, 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.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, + ) +} + +// 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). 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 d.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, including the OmnipodKit-observed manufacturer-data +// payload so post-pair re-advertise frames still match real-pod BLE captures. +// 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"), + gatt.MustParseUUID("ECF301E2-674B-4474-94D0-364F3AA653E6"), + }, + o5MfgData, + ) +} + +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), dataOutput: make(chan Packet, 5), cmdOutput: make(chan Packet, 5), messageInput: make(chan *message.Message, 5), @@ -74,6 +227,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()) @@ -119,14 +274,52 @@ 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. 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() + 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 (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 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 + // 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,7 +329,28 @@ func New(adapterID string, podId []byte) (*Ble, error) { log.Tracef("received CMD, %x", data) ret := make([]byte, len(data)) copy(ret, data) - b.cmdInput <- 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 }) @@ -148,6 +362,15 @@ func New(adapterID string, podId []byte) (*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) { @@ -167,37 +390,65 @@ func New(adapterID string, podId []byte) (*Ble, error) { return 0 }) - err = d.AddService(s) - 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) + } } - 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])) + // 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, + // 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 + // 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 + advertisedMfg = o5MfgData + } else { + advertiseErr = b.advertiseDash(d, podId) + advertisedName = dashAdvertiseName + advertisedUUIDCount = 9 + advertisedMfg = nil } - - // 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, - - // 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), - }) - if err != nil { - log.Fatalf("pkg bluetooth; could not advertise: %s", err) + if advertiseErr != nil { + log.Fatalf("pkg bluetooth; advertise: %s", advertiseErr) } + // 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: } } @@ -210,26 +461,16 @@ 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), - }) + // 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(*b.device, id) + } else { + err = b.refreshDash(*b.device, id) + } if err != nil { log.Infof("pkg bluetooth; could not re-advertise: %s", err) } @@ -254,7 +495,18 @@ 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. 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) { + if b.IsO5() { + packet := <-b.cmdActivation + return packet, nil + } packet := <-b.cmdInput return packet, nil } @@ -283,15 +535,44 @@ 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) { 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: @@ -303,7 +584,41 @@ func (b *Ble) loop(stop chan bool) { 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: + return + case msg := <-b.messageOutput: + 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) + } + if msg != nil { + b.messageInput <- msg + } } } } @@ -331,6 +646,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 +692,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,15 +759,60 @@ 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 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/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/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) + } +} 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/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..5cc5b38 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 @@ -35,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 { @@ -69,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 } @@ -143,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 { @@ -151,7 +163,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 +175,18 @@ 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 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+n] + ret.Payload = data[16 : 16+int(n)] } return ret, nil } 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/mode.go b/pkg/pair/mode.go new file mode 100644 index 0000000..f6e0d70 --- /dev/null +++ b/pkg/pair/mode.go @@ -0,0 +1,37 @@ +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) +} + +// 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/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..08b76ca 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,12 +21,18 @@ const ( sp2 = ",SP2=" sps1 = "SPS1=" + sps21 = "SPS2.1=" sps2 = "SPS2=" sp0gp0 = "SP0,GP0" p0 = "P0=" + + defaultSPS21Size = 641 ) type Pair struct { + // Mode selects Dash (CMAC chain) or O5 (SHA-256/FIRMWARE_ID) key derivation. + Mode Mode + podPublic []byte podPrivate []byte podNonce []byte @@ -34,12 +42,42 @@ 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 ltk []byte - confKey []byte // key used to sign the "Conf" values + confKey []byte // key used to sign the "Conf" values; Dash only + + // 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 + + // 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. +func (c *Pair) SetIdentity(id *PodIdentity) { + c.identity = id } func parseStringByte(expectedNames []string, data []byte) (map[string][]byte, error) { @@ -93,10 +131,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) @@ -104,9 +153,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 } @@ -143,6 +194,43 @@ 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 + + // 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) + 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]) } @@ -150,17 +238,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) - msg.Payload, err = buildStringByte([]string{sps2}, sp) + payload, err := buildStringByte([]string{sps2}, sp) if err != nil { return nil, err } - log.Debugf("Generated SPS2: %x", msg.Payload) + 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) + payload, err := buildStringByte([]string{sps21}, sp) + if err != nil { + return nil, err + } + msg.Payload = payload + log.Debugf("Generated SPS2.1 (%d bytes)", len(sp[sps21])) return msg, nil } @@ -193,6 +366,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 @@ -211,11 +407,45 @@ func (c *Pair) computeMyData() error { func (c *Pair) computePairData() error { var err error // fill in: lrtk, podConf, pdmConf, intermediarKey + 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 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.Debugf("Donna LTK: %x", c.curve25519LTK) + 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/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/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 3949478..b355988 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" @@ -31,6 +33,7 @@ type PodMsgBody struct { type Pod struct { ble *bluetooth.Ble state *PODState + pairMode pair.Mode mtx sync.Mutex webMessageHook func([]byte) } @@ -39,7 +42,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{ @@ -54,9 +57,31 @@ func New(ble *bluetooth.Ble, stateFile string, freshState bool) *Pod { } } + // 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. 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{ - ble: ble, - state: state, + ble: ble, + state: state, + pairMode: pairMode, } return ret @@ -87,6 +112,73 @@ 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. +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() @@ -103,30 +195,53 @@ func (p *Pod) StartAcceptingCommands() { func (p *Pod) StartActivation() { - pair := &pair.Pair{} + 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) } @@ -134,23 +249,27 @@ 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) } 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() @@ -234,12 +353,44 @@ 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) } 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() + p.notifyStateChange() + continue + } + cmd, err := command.Unmarshal(decrypted.Payload) if err != nil { log.Fatalf("pkg pod; could not unmarshal command: %s", err) @@ -267,7 +418,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 0467a3c..ffca25f 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? @@ -25,6 +31,19 @@ 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"` + + // 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"` @@ -70,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) + } +} diff --git a/pkg/response/setuniqueidresponse.go b/pkg/response/setuniqueidresponse.go index 694ee21..0e6e726 100644 --- a/pkg/response/setuniqueidresponse.go +++ b/pkg/response/setuniqueidresponse.go @@ -2,16 +2,44 @@ 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, +// 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 + + // 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..31cfc6a --- /dev/null +++ b/pkg/response/setuniqueidresponse_test.go @@ -0,0 +1,65 @@ +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 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 = "011B13881008340A5009000405000205030F999A3100077E9100000000" + + 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..dc78f42 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -2,16 +2,45 @@ 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, +// 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 + + // 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..4953b6f --- /dev/null +++ b/pkg/response/versionresponse_test.go @@ -0,0 +1,57 @@ +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 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 = "011509000405000205020F999A3100077E9105FFFFFFFF" + + 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)) + } +} 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 0000000..f405180 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/SPS1_src__dst__1590.bin differ 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 0000000..9eccbf2 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session1_SPS2.1_phone_to_pod.bin differ 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 0000000..31d11b7 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session1_SPS2.1_pod_to_phone.bin differ diff --git a/pkg/testfixtures/btsnoop1/session1_SPS2_phone_to_pod.bin b/pkg/testfixtures/btsnoop1/session1_SPS2_phone_to_pod.bin new file mode 100644 index 0000000..c5b980d Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session1_SPS2_phone_to_pod.bin differ 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 0000000..0ef1902 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session2_SPS2.1_phone_to_pod.bin differ diff --git a/pkg/testfixtures/btsnoop1/session2_SPS2.1_pod_to_phone.bin b/pkg/testfixtures/btsnoop1/session2_SPS2.1_pod_to_phone.bin new file mode 100644 index 0000000..8848bb2 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session2_SPS2.1_pod_to_phone.bin differ 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 0000000..58ba57e Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session2_SPS2_phone_to_pod.bin differ 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 0000000..4fb2879 Binary files /dev/null and b/pkg/testfixtures/btsnoop1/session2_SPS2_pod_to_phone.bin differ 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 0000000..fb8c93a Binary files /dev/null and b/pkg/testfixtures/btsnoop2/SPS2.1_phone_to_pod.bin differ diff --git a/pkg/testfixtures/btsnoop2/SPS2.1_pod_to_phone.bin b/pkg/testfixtures/btsnoop2/SPS2.1_pod_to_phone.bin new file mode 100644 index 0000000..5e7c6b0 Binary files /dev/null and b/pkg/testfixtures/btsnoop2/SPS2.1_pod_to_phone.bin differ 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 0000000..8951fc9 Binary files /dev/null and b/pkg/testfixtures/btsnoop2/SPS2_phone_to_pod.bin differ 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 0000000..e1c8760 Binary files /dev/null and b/pkg/testfixtures/btsnoop2/SPS2_pod_to_phone.bin differ diff --git a/pkg/testfixtures/btsnoop_test.go b/pkg/testfixtures/btsnoop_test.go new file mode 100644 index 0000000..1fa9dde --- /dev/null +++ b/pkg/testfixtures/btsnoop_test.go @@ -0,0 +1,29 @@ +package testfixtures + +import "testing" + +func TestCapturesLoad(t *testing.T) { + caps := Captures() + if len(caps) != 3 { + t.Fatalf("expected 3 captures, got %d", len(caps)) + } + for _, c := range caps { + if c.PDMSPS1 == nil || c.PodSPS1 == nil { + t.Fatalf("%s: missing SPS1", c.Name) + } + if len(c.PDMSPS1.Public) != 64 || len(c.PDMSPS1.Nonce) != 16 { + t.Fatalf("%s: PDM SPS1 sizes wrong", c.Name) + } + if len(c.PodSPS1.Public) != 64 || len(c.PodSPS1.Nonce) != 16 { + t.Fatalf("%s: pod SPS1 sizes wrong", c.Name) + } + // SPS2.1 phone->pod 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/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) 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