From 3520ce9c559c2679cf57ab17809ccd7cce72e83f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 16:56:58 -0500 Subject: [PATCH 1/8] Simulate an Omnipod 5 as well as a DASH -mode o5 serves an Omnipod 5 instead of a DASH, as OmnipodKit's O5 path expects one. The mode is kept in the state file so restarting does not change the pod the phone paired with. The link layer gains a packet layout, so the framing OmnipodKit's PayloadSplitter and PayloadJoiner describe is written once and used at both packet sizes rather than only for a DASH's 20-byte packets. An Omnipod 5 uses 244-byte packets, skips the RTS/CTS handshake, serves the data characteristic 1A7E2443, and advertises one 128-bit service UUID holding its controller's id, with the trailing byte it flips when it wants attention. Pairing follows O5LTKExchanger: SPS0, then P-256 ECDH and a SHA-256 key derivation in SPS1, then two certificate rounds under AES-CCM in which each side shows an X.509 certificate and signs a transcript of what both have said. The pod makes itself a self-signed certificate; as in OmnipodKit, neither side validates a chain, so each reads the public key out of the other's certificate to check its signature. The exchange ends in the long-term key and the EAP-AKA session a DASH already used. A pod then answers the setup commands the phone sends before SetupPod, reports product id 5 and a lot number that decodes as an Omnipod 5, and refuses an insulin, cancel or deactivate command that did not arrive signed by its controller with error 0x2A, as a real pod does. Along the way: - the message length field is read as the 11 bits it is, not a byte, which wrapped on a pairing message over 255 bytes - CRC16 is computed rather than left as zero, since an Omnipod 5's phone checks it; verified against OmnipodKit's test vector and four captured pod messages - the control panel shows the pod type, the controller, and any command refused for want of a signature - cmd/o5keypair writes the registration data the app needs, which it cannot download in the iOS Simulator -mode o5 needs -transport impossible: the Linux stack here advertises only 16-bit UUIDs and does not negotiate the MTU an Omnipod 5 needs, so it now says so instead of advertising something no phone will answer. --- README.md | 72 ++++ cmd/o5keypair/main.go | 182 ++++++++++ cmd/o5keypair/main_test.go | 118 ++++++ main.go | 25 +- pkg/aid/aid.go | 156 ++++++++ pkg/aid/aid_test.go | 143 ++++++++ pkg/api/web.go | 13 +- pkg/bluetooth/bluetooth.go | 265 +++++++------- pkg/bluetooth/bluetooth_test.go | 4 +- pkg/bluetooth/gatt_linux.go | 9 +- pkg/bluetooth/impossible.go | 42 ++- pkg/bluetooth/impossible_test.go | 66 +++- pkg/bluetooth/o5_link_test.go | 224 ++++++++++++ pkg/bluetooth/packet.go | 192 ++++++++++ pkg/bluetooth/packet_test.go | 198 +++++++++++ pkg/bluetooth/profile.go | 137 +++++++ pkg/crc/crc.go | 57 ++- pkg/crc/crc_test.go | 43 +++ pkg/message/message.go | 73 +++- pkg/message/message_test.go | 129 +++++++ pkg/pair/o5.go | 532 ++++++++++++++++++++++++++++ pkg/pair/o5_test.go | 335 ++++++++++++++++++ pkg/pod/o5.go | 164 +++++++++ pkg/pod/o5_test.go | 250 +++++++++++++ pkg/pod/pod.go | 135 +++++-- pkg/pod/state.go | 34 +- pkg/pod/status.go | 20 +- pkg/response/errorresponse.go | 19 + pkg/response/setuniqueidresponse.go | 8 +- pkg/response/version_test.go | 62 ++++ pkg/response/versionresponse.go | 49 ++- 31 files changed, 3530 insertions(+), 226 deletions(-) create mode 100644 cmd/o5keypair/main.go create mode 100644 cmd/o5keypair/main_test.go create mode 100644 pkg/aid/aid.go create mode 100644 pkg/aid/aid_test.go create mode 100644 pkg/bluetooth/o5_link_test.go create mode 100644 pkg/bluetooth/packet.go create mode 100644 pkg/bluetooth/packet_test.go create mode 100644 pkg/bluetooth/profile.go create mode 100644 pkg/crc/crc_test.go create mode 100644 pkg/pair/o5.go create mode 100644 pkg/pair/o5_test.go create mode 100644 pkg/pod/o5.go create mode 100644 pkg/pod/o5_test.go create mode 100644 pkg/response/errorresponse.go create mode 100644 pkg/response/version_test.go diff --git a/README.md b/README.md index 6ed05a2..0b57f87 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Fake pod implementation * It can serve an app in the iOS Simulator instead of a phone; see [Run against the iOS Simulator](#run-against-the-ios-simulator). +* It simulates a DASH pod by default, or an Omnipod 5 with `-mode o5`; see [Simulate an Omnipod 5](#simulate-an-omnipod-5). + Requirements: 1. Version of iOS code (Loop app) that will interact with this simulator - Loop dev branch or FreeAPS freeaps_dev branch 2. Raspberry pi with Bluetooth BLE (using a pi3b or pi4 right now) @@ -64,6 +66,7 @@ To restore communication between the app and an existing simulated dash pod, iss ``` To change the reporting level, add one of these two flags: +* `-mode dash` (the default) or `-mode o5` to choose the pod to simulate * `-v` to make reporting more verbose (Trace Level) * `-q` to make reporting less verbose (recommended) * no extra flag - medium verbose (Debug Level) @@ -108,6 +111,75 @@ panel reaches a backgrounded Loop the way a real one does, through its scan for the advertisement wakes the suspended app. A real pod's fault is final, and Loop treats it so: clearing the fault here doesn't restart Loop's scan for `C00A`. Relaunch Loop before testing another fault. +## Simulate an Omnipod 5 + +`-mode o5` makes the pod an Omnipod 5 instead of a DASH. The mode is kept in the state +file, so restarting the simulator does not change the pod the phone paired with; a +different `-mode` is then ignored with a warning, and `-fresh` starts a new pod. + +``` +./pod -transport impossible -mode o5 -fresh +``` + +An Omnipod 5 differs from a DASH in what it advertises and how it talks: + +* one 128-bit service UUID, `CE1F923D-C539-48EA-7300-0A00`, holding the + controller it paired with (`FFFFFFFE` before pairing, and a trailing `02` instead of + `00` while the pod wants attention, which is how a backgrounded app hears about a fault) +* the data characteristic `1A7E2443` rather than `1A7E2442` +* 244-byte packets and no RTS/CTS handshake: a message begins with its first packet, and + whoever received it answers SUCCESS +* a longer pairing exchange, in which each side shows an X.509 certificate and signs what + both have said (SPS0, SPS1, SPS2.1, SPS2), ending in the same long-term key and the same + EAP-AKA session a DASH uses +* a real CRC16 on every response, which the phone checks for an Omnipod 5 and ignores for + a DASH +* a signature on every command that starts or stops insulin. One that arrives unsigned, or + signed by anything but the controller the pod paired with, is refused with error `0x2A` + as a real pod refuses it, and the control panel counts it under "Signed commands + rejected" — anything above zero is worth looking into. + +The control panel shows the pod type and the controller alongside the rest. + +`-mode o5` needs `-transport impossible`: the Linux Bluetooth stack here advertises only +16-bit service UUIDs and does not negotiate the 247-byte ATT MTU an Omnipod 5 packet needs. + +### Registration data for the app + +OmnipodKit will not begin an Omnipod 5 pairing without a controller signing key and +certificate chain. On a phone it downloads them, which needs Apple's App Attest and so +fails in the iOS Simulator. `cmd/o5keypair` makes a self-signed set instead: + +``` +go run ./cmd/o5keypair -swift > O5Data.swift # to build into OmnipodKit +go run ./cmd/o5keypair > o5keypair.json # the same data as JSON +``` + +Copy `O5Data.swift` into `LoopWorkspace/OmnipodKit/OmnipodKit/` and build. It defines +`O5RegistrationDataInstall`, which OmnipodKit looks up with `dlsym` at start, so the data +is in place before any pod is paired. OmnipodKit's project uses a synchronized folder, so +Xcode picks the file up on its own. Keep it out of git: it pairs with this simulator and +with nothing else, because neither side validates the other's certificate chain — each +only reads the public key out of the certificate to check the other's signature. + +### Pair it with Loop + +Loop must come from LoopWorkspace `next-dev`, which pins the OmnipodKit that talks to a +simulated pod when it finds ImpossiBLE. Build it for a simulator, start the pod, then +launch Loop with ImpossiBLE's library injected: + +``` +./pod -transport impossible -mode o5 -fresh +SIMCTL_CHILD_DYLD_INSERT_LIBRARIES=/path/to/libImpossiBLE.dylib \ + xcrun simctl launch booted +``` + +In Loop: Settings -> Insulin Pump -> Add Pump -> Omnipod, then Pod Type -> Omnipod 5. The +setup screen should say it is ready to pair; if it offers to download a certificate +instead, `O5Data.swift` was not built in. The pod log then shows the controller's HELLO, +the pairing rounds, `the phone's SPS2 signature verifies`, the EAP-AKA session, the setup +commands and the prime bolus. + # Original README.md We maintained the original README file below. It may be helpful if someone plans to cross-compile the code and just transfer the executable. diff --git a/cmd/o5keypair/main.go b/cmd/o5keypair/main.go new file mode 100644 index 0000000..488bb50 --- /dev/null +++ b/cmd/o5keypair/main.go @@ -0,0 +1,182 @@ +// Command o5keypair writes the Omnipod 5 registration data a phone needs +// before it will pair with the simulator. +// +// OmnipodKit will not start an Omnipod 5 pairing without a signing key and +// a certificate chain for the controller. On a phone it downloads them, +// which needs Apple's App Attest and so cannot work in the iOS Simulator. +// This makes a self-signed set instead. It pairs with this simulator +// because neither side validates the other's chain — the simulated pod +// reads the public key out of the certificate to check signatures, exactly +// as OmnipodKit does with the pod's — and it pairs with nothing else. +// +// go run ./cmd/o5keypair -swift > O5Data.swift # to build into OmnipodKit +// go run ./cmd/o5keypair > o5keypair.json # to import in the app +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "math/big" + "os" + "text/template" + "time" +) + +func main() { + // 0x002A1C6C is a plausible controller id: a serial number shifted up + // two bits, the way an Omnipod 5 controller forms its own. + id := flag.Uint64("id", 0x002A1C6C, "the controller id to issue the data for") + asSwift := flag.Bool("swift", false, "write a Swift file to build into OmnipodKit instead of JSON") + flag.Parse() + + if *id == 0 || *id > 0xFFFFFFFF { + fail(fmt.Errorf("the controller id must be between 1 and 0xFFFFFFFF, not 0x%X", *id)) + } + controllerId := uint32(*id) + + reg, err := generate(controllerId) + if err != nil { + fail(err) + } + + if *asSwift { + if err := swiftTemplate.Execute(os.Stdout, reg); err != nil { + fail(err) + } + return + } + out, err := json.MarshalIndent(reg.json(), "", " ") + if err != nil { + fail(err) + } + fmt.Println(string(out)) +} + +func fail(err error) { + fmt.Fprintf(os.Stderr, "o5keypair: %s\n", err) + os.Exit(1) +} + +// registration is what OmnipodKit's O5RegistrationData holds. +type registration struct { + ControllerId uint32 + PrivateKeyHex string + PublicKeyHex string + IntermediateCA string // base64 DER + TLSCertificate string // base64 DER +} + +// ControllerIdHex is the id as the app prints it. +func (r registration) ControllerIdHex() string { return fmt.Sprintf("0x%08X", r.ControllerId) } + +func (r registration) json() map[string]interface{} { + return map[string]interface{}{ + "controllerId": r.ControllerId, + "privateKey": r.PrivateKeyHex, + "publicKey": r.PublicKeyHex, + "intermediateCA": r.IntermediateCA, + "tlsCertificate": r.TLSCertificate, + } +} + +// generate makes the signing key and the two certificates. The signing +// key is what the phone signs pairing transcripts and insulin commands +// with, so the certificate it shows in SPS2 has to carry that same key: +// the pod checks the signature against the key in the certificate. +func generate(controllerId uint32) (registration, error) { + signing, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return registration{}, fmt.Errorf("could not make the signing key: %w", err) + } + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return registration{}, fmt.Errorf("could not make the issuing key: %w", err) + } + caDER, err := certificate(1, "pod simulator intermediate CA", &caKey.PublicKey, nil, caKey) + if err != nil { + return registration{}, fmt.Errorf("could not make the intermediate certificate: %w", err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + return registration{}, err + } + + leafDER, err := certificate(2, fmt.Sprintf("pod simulator controller %08X", controllerId), &signing.PublicKey, ca, caKey) + if err != nil { + return registration{}, fmt.Errorf("could not make the controller certificate: %w", err) + } + + public := make([]byte, 64) + signing.PublicKey.X.FillBytes(public[:32]) + signing.PublicKey.Y.FillBytes(public[32:]) + + private := make([]byte, 32) + signing.D.FillBytes(private) + + return registration{ + ControllerId: controllerId, + PrivateKeyHex: hex.EncodeToString(private), + PublicKeyHex: hex.EncodeToString(public), + IntermediateCA: base64.StdEncoding.EncodeToString(caDER), + TLSCertificate: base64.StdEncoding.EncodeToString(leafDER), + }, nil +} + +// certificate issues one certificate, self-signed when parent is nil. The +// layout puts the public key in the standard P-256 SubjectPublicKeyInfo +// bytes, which is how both sides find it +// (O5CertificateStore.extractP256PublicKey). +func certificate(serial int64, commonName string, public *ecdsa.PublicKey, parent *x509.Certificate, signer *ecdsa.PrivateKey) ([]byte, error) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{ + Organization: []string{"pod simulator"}, + CommonName: commonName, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + IsCA: true, + } + if parent == nil { + parent = template + } + return x509.CreateCertificate(rand.Reader, template, parent, public, signer) +} + +var swiftTemplate = template.Must(template.New("o5data").Parse(`// +// O5Data.swift +// OmnipodKit +// +// Omnipod 5 registration data for the pod simulator +// (github.com/LoopKit/pod, -mode o5), written by its cmd/o5keypair. +// Self-signed: it pairs with that simulator and with nothing else, so +// keep it out of git and out of any build meant for a real pod. +// +// OmnipodKit looks this symbol up with dlsym at start and calls it, so +// the data is in place before a pod is paired +// (O5CertificateStore.loadOptionalO5Data). +// + +import Foundation + +@_cdecl("O5RegistrationDataInstall") +public func O5RegistrationDataInstall() { + O5RegistrationData.install(O5RegistrationData( + controllerId: {{ .ControllerIdHex }}, + privateKeyHex: "{{ .PrivateKeyHex }}", + publicKeyHex: "{{ .PublicKeyHex }}", + intermediateCABase64: "{{ .IntermediateCA }}", + tlsCertificateBase64: "{{ .TLSCertificate }}" + )) +} +`)) diff --git a/cmd/o5keypair/main_test.go b/cmd/o5keypair/main_test.go new file mode 100644 index 0000000..45b7c43 --- /dev/null +++ b/cmd/o5keypair/main_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "math/big" + "testing" +) + +// The phone signs with the key in privateKey and shows tlsCertificate; the +// pod checks the signature against the key it reads out of that +// certificate, so the two have to be the same key, or every signed command +// would be refused. +func TestCertificateCarriesTheSigningKey(t *testing.T) { + reg, err := generate(0x002A1C6C) + if err != nil { + t.Fatal(err) + } + + der, err := base64.StdEncoding.DecodeString(reg.TLSCertificate) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("the controller certificate does not parse: %v", err) + } + public, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok || public.Curve != elliptic.P256() { + t.Fatal("the controller certificate does not carry a P-256 key") + } + + want, err := hex.DecodeString(reg.PublicKeyHex) + if err != nil { + t.Fatal(err) + } + got := make([]byte, 64) + public.X.FillBytes(got[:32]) + public.Y.FillBytes(got[32:]) + if hex.EncodeToString(got) != hex.EncodeToString(want) { + t.Error("the certificate's key is not the one in publicKey") + } + + // publicKey must also be what privateKey derives to: OmnipodKit + // refuses the registration otherwise (O5CertificateStore.init). + scalar, err := hex.DecodeString(reg.PrivateKeyHex) + if err != nil { + t.Fatal(err) + } + if len(scalar) != 32 { + t.Fatalf("privateKey is %d bytes, want 32", len(scalar)) + } + d := new(big.Int).SetBytes(scalar) + x, y := elliptic.P256().ScalarBaseMult(d.Bytes()) + derived := make([]byte, 64) + x.FillBytes(derived[:32]) + y.FillBytes(derived[32:]) + if hex.EncodeToString(derived) != reg.PublicKeyHex { + t.Error("publicKey is not what privateKey derives to") + } +} + +// The phone finds a key by looking for these exact SubjectPublicKeyInfo +// bytes in the certificate (O5CertificateStore.p256SPKIHeader), so both +// certificates have to be laid out that way. +func TestCertificatesHoldTheExpectedKeyLayout(t *testing.T) { + spki, _ := hex.DecodeString("3059" + "3013" + "06072a8648ce3d0201" + "06082a8648ce3d030107" + "0342" + "00" + "04") + + reg, err := generate(0x002A1C6C) + if err != nil { + t.Fatal(err) + } + for name, encoded := range map[string]string{ + "intermediate CA": reg.IntermediateCA, + "controller certificate": reg.TLSCertificate, + } { + der, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + if !contains(der, spki) { + t.Errorf("the %s does not hold the key layout the phone looks for", name) + } + } +} + +func contains(haystack, needle []byte) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + match := true + for j := range needle { + if haystack[i+j] != needle[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// The app treats a controller id of 0 as no registration at all. +func TestControllerIdIsKept(t *testing.T) { + reg, err := generate(0x00123456) + if err != nil { + t.Fatal(err) + } + if reg.ControllerId != 0x00123456 { + t.Errorf("controller id is %08x, want 00123456", reg.ControllerId) + } + if reg.ControllerIdHex() != "0x00123456" { + t.Errorf("ControllerIdHex = %s, want 0x00123456", reg.ControllerIdHex()) + } +} diff --git a/main.go b/main.go index 1c26251..8a0540a 100644 --- a/main.go +++ b/main.go @@ -20,9 +20,15 @@ func main() { var apiAddr = flag.String("api", ":8080", "address of the control panel and its API") var transport = flag.String("transport", "ble", "ble (a Linux Bluetooth adapter) or impossible (ImpossiBLE's socket, for an app in the iOS Simulator)") var socket = flag.String("socket", "/tmp/impossible.sock", "ImpossiBLE socket for -transport impossible") + var modeName = flag.String("mode", string(bluetooth.ModeDash), "the pod to simulate: dash or o5 (Omnipod 5)") flag.Parse() + mode, err := bluetooth.ParseMode(*modeName) + if err != nil { + log.Fatalf("%s", err) + } + if *traceLevel { log.SetLevel(log.TraceLevel) } else if *infoLevel { @@ -32,21 +38,30 @@ func main() { } log.SetFormatter(&logrus.TextFormatter{ - DisableQuote: true, - ForceColors: true, + DisableQuote: true, + ForceColors: true, FullTimestamp: true, }) var state *pod.PODState if *freshState { - state = pod.NewFreshState(*stateFile) + state = pod.NewFreshState(*stateFile, mode) } else { - var err error state, err = pod.NewState(*stateFile) if err != nil { log.Fatalf("pkg pod; could not restore pod state from %s: %+v", *stateFile, err) } + // The pod the phone paired with is the one in the state file. A + // different -mode would change the pod under the phone, so it is + // ignored; -fresh starts a new pod of whatever mode is asked for. + if saved := state.PodMode(); saved != mode { + log.Warnf("%s holds an %s pod; keeping it. Use -fresh to start a new %s pod", + *stateFile, saved.Description(), mode.Description()) + mode = saved + } } + log.Infof("simulating an %s pod", mode.Description()) + profile := bluetooth.ProfileFor(mode) if state.PeripheralID == "" { state.PeripheralID = bluetooth.PeripheralID() } @@ -64,7 +79,7 @@ func main() { default: log.Fatalf("transport %q: want ble or impossible", *transport) } - ble, err := bluetooth.New(attach) + ble, err := bluetooth.New(attach, profile) if err != nil { log.Fatalf("Could not start %s: %s", *transport, err) } diff --git a/pkg/aid/aid.go b/pkg/aid/aid.go new file mode 100644 index 0000000..ab43201 --- /dev/null +++ b/pkg/aid/aid.go @@ -0,0 +1,156 @@ +// Package aid answers the Omnipod 5 setup commands the phone sends +// between pairing the pod and setting it up. +// +// These are not Omnipod command blocks: they are ASCII key-value pairs, +// sent through the same encrypted transport but without the S0.0= wrapper +// and without the acknowledgement that follows a command +// (OmnipodKit's O5AidCommands and BlePodComms.o5SendAidSetupCommands). +// +// set and read S.=,G. answered .= +// read G. answered .= +// extended set SE.= answered ES.=0 +// +// The pod keeps what it is told and reads it back, which is what a real +// pod does for every one of these the phone sends during activation. +package aid + +import ( + "bytes" + "fmt" +) + +// Command is one parsed AID command. +type Command struct { + // Kind is "set", "get" or "extended set". + Kind string + // Feature and Attribute name what is being set or read, as in "3.1". + Feature string + Attribute string + // Data is what a set carries. It may be ASCII or raw bytes. + Data []byte +} + +// Key is the "." this command addresses. +func (c Command) Key() string { return c.Feature + "." + c.Attribute } + +func (c Command) String() string { + if c.Kind == "get" { + return fmt.Sprintf("%s %s", c.Kind, c.Key()) + } + return fmt.Sprintf("%s %s = %x (%d bytes)", c.Kind, c.Key(), c.Data, len(c.Data)) +} + +// Looks returns true when a decrypted payload looks like an AID command +// rather than an Omnipod command block, which always begins "S0.0=". +func Looks(payload []byte) bool { + if bytes.HasPrefix(payload, []byte("S0.0=")) { + return false + } + return bytes.HasPrefix(payload, []byte("SE")) || + bytes.HasPrefix(payload, []byte("S")) || + bytes.HasPrefix(payload, []byte("G")) +} + +// Parse reads one AID command. +func Parse(payload []byte) (Command, error) { + switch { + case bytes.HasPrefix(payload, []byte("SE")): + feature, attribute, data, err := splitSet(payload[2:]) + if err != nil { + return Command{}, err + } + return Command{Kind: "extended set", Feature: feature, Attribute: attribute, Data: data}, nil + + case bytes.HasPrefix(payload, []byte("S")): + feature, attribute, rest, err := splitSet(payload[1:]) + if err != nil { + return Command{}, err + } + // A set is followed by the read of the same attribute, which the + // pod answers; the data is everything before that suffix. + suffix := []byte(",G" + feature + "." + attribute) + if !bytes.HasSuffix(rest, suffix) { + return Command{}, fmt.Errorf("set of %s.%s does not end in %q: %x", feature, attribute, suffix, payload) + } + return Command{Kind: "set", Feature: feature, Attribute: attribute, Data: rest[:len(rest)-len(suffix)]}, nil + + case bytes.HasPrefix(payload, []byte("G")): + feature, attribute, err := splitKey(payload[1:]) + if err != nil { + return Command{}, err + } + return Command{Kind: "get", Feature: feature, Attribute: attribute}, nil + } + return Command{}, fmt.Errorf("not an AID command: %x", payload) +} + +// splitSet reads ".=" and returns what follows. +func splitSet(b []byte) (feature, attribute string, rest []byte, err error) { + eq := bytes.IndexByte(b, '=') + if eq < 0 { + return "", "", nil, fmt.Errorf("AID set has no '=': %x", b) + } + feature, attribute, err = splitKey(b[:eq]) + if err != nil { + return "", "", nil, err + } + return feature, attribute, b[eq+1:], nil +} + +// splitKey reads ".". +func splitKey(b []byte) (feature, attribute string, err error) { + dot := bytes.IndexByte(b, '.') + if dot < 0 { + return "", "", fmt.Errorf("AID key has no '.': %x", b) + } + feature, attribute = string(b[:dot]), string(b[dot+1:]) + if feature == "" || attribute == "" { + return "", "", fmt.Errorf("AID key %q is incomplete", string(b)) + } + return feature, attribute, nil +} + +// Store is what the pod remembers of what the phone set. +type Store struct { + values map[string][]byte +} + +func NewStore() *Store { return &Store{values: map[string][]byte{}} } + +// Value returns what was set for a key. +func (s *Store) Value(key string) ([]byte, bool) { + v, ok := s.values[key] + return v, ok +} + +// unifiedAidPodStatus is the answer to the status read the phone makes on +// a pod whose firmware is new enough (G3.12): a length and a body the +// phone only logs during setup. +var unifiedAidPodStatus = append([]byte{0x00, 0x1b}, make([]byte, 27)...) + +// Respond keeps what a command sets and returns the payload to answer +// with, or nil for a command the pod should not answer. +func (s *Store) Respond(c Command) []byte { + switch c.Kind { + case "extended set": + // An extended set is acknowledged, not read back. + s.values[c.Key()] = append([]byte(nil), c.Data...) + return []byte("ES" + c.Key() + "=0") + + case "set": + s.values[c.Key()] = append([]byte(nil), c.Data...) + return append([]byte(c.Key()+"="), c.Data...) + + case "get": + if v, ok := s.values[c.Key()]; ok { + return append([]byte(c.Key()+"="), v...) + } + // The two status reads are the only ones the phone makes without + // having set the attribute first. + if c.Key() == "3.12" || c.Key() == "3.11" { + return append([]byte(c.Key()+"="), unifiedAidPodStatus...) + } + return []byte(c.Key() + "=") + } + return nil +} diff --git a/pkg/aid/aid_test.go b/pkg/aid/aid_test.go new file mode 100644 index 0000000..a386b2b --- /dev/null +++ b/pkg/aid/aid_test.go @@ -0,0 +1,143 @@ +package aid + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" +) + +// The nine setup commands OmnipodKit sends during an Omnipod 5 activation, +// built as O5AidCommands builds them, with the response prefix it then +// insists on (sendO5AidCommand throws if the answer does not start with it). +func TestRespondToActivationCommands(t *testing.T) { + targets := make([]byte, 0, 2+48*4) + targets = append(targets, 0x00, 0xc0) + for i := 0; i < 48; i++ { + targets = binary.BigEndian.AppendUint32(targets, 110) + } + history := append([]byte{0x00, 0xa8}, make([]byte, 168)...) + + for _, tc := range []struct { + name string + payload []byte + wantPrefix string + wantEcho []byte // what must follow the prefix, when the phone can tell + }{ + { + name: "UTC time", + payload: []byte("SE255.2=1761600000"), + wantPrefix: "ES255.2=", + }, + { + name: "therapy delivery information", + payload: append(append([]byte("S3.2="), 0x00, 0x03, 0x00, 0x0e, 0x00), []byte(",G3.2")...), + wantPrefix: "3.2=", + wantEcho: []byte{0x00, 0x03, 0x00, 0x0e, 0x00}, + }, + { + name: "target BG profile", + payload: append(append([]byte("S3.1="), targets...), []byte(",G3.1")...), + wantPrefix: "3.1=", + wantEcho: targets, + }, + { + name: "duration of insulin action", + payload: []byte("S3.9=8,G3.9"), + wantPrefix: "3.9=", + wantEcho: []byte("8"), + }, + { + name: "glucose configuration", + payload: []byte("S3.7=3670015,G3.7"), + wantPrefix: "3.7=", + wantEcho: []byte("3670015"), + }, + { + name: "algorithm insulin history", + payload: append([]byte("SE2.1="), history...), + wantPrefix: "ES2.1=", + }, + { + name: "unified pod status", + payload: []byte("G3.12"), + wantPrefix: "3.12=", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if !Looks(tc.payload) { + t.Fatalf("%q was not recognised as an AID command", tc.payload[:8]) + } + cmd, err := Parse(tc.payload) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := NewStore().Respond(cmd) + if !bytes.HasPrefix(got, []byte(tc.wantPrefix)) { + t.Fatalf("answer %q does not start with %q", got, tc.wantPrefix) + } + if tc.wantEcho != nil { + if echo := got[len(tc.wantPrefix):]; !bytes.Equal(echo, tc.wantEcho) { + t.Errorf("answer echoed %x, want %x", echo, tc.wantEcho) + } + } + }) + } +} + +// A set is read back by a later read of the same attribute. +func TestStoreReadsBackWhatWasSet(t *testing.T) { + store := NewStore() + set, err := Parse([]byte("S3.9=8,G3.9")) + if err != nil { + t.Fatal(err) + } + store.Respond(set) + + get, err := Parse([]byte("G3.9")) + if err != nil { + t.Fatal(err) + } + if got := store.Respond(get); string(got) != "3.9=8" { + t.Errorf("read back %q, want %q", got, "3.9=8") + } +} + +// An Omnipod command block must not be mistaken for an AID command: both +// arrive on the same encrypted transport and only the payload tells them +// apart. +func TestOmnipodCommandIsNotAnAIDCommand(t *testing.T) { + block := append([]byte("S0.0="), []byte{0x00, 0x0b, 0x00, 0x2a, 0x1c, 0x6d, 0x00, 0x03, 0x0e, 0x01, 0x00, 0x00, 0x00}...) + block = append(block, []byte(",G0.0")...) + if Looks(block) { + t.Error("an S0.0= command block was taken for an AID command") + } +} + +// Binary data can hold the bytes that end a set command, so the parser has +// to split on the suffix rather than search for a comma. +func TestSetWithBinaryDataHoldingItsOwnSuffix(t *testing.T) { + data := append([]byte(",G3.2"), 0x00, 0x01) + payload := append(append([]byte("S3.2="), data...), []byte(",G3.2")...) + cmd, err := Parse(payload) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !bytes.Equal(cmd.Data, data) { + t.Errorf("data is %x, want %x", cmd.Data, data) + } +} + +func TestParseRejectsRubbish(t *testing.T) { + for _, payload := range []string{"", "S", "S3=1", "G", "G3", "S3.2=novalue"} { + if _, err := Parse([]byte(payload)); err == nil { + t.Errorf("Parse(%q) should have failed", payload) + } + } +} + +func ExampleCommand_String() { + cmd, _ := Parse([]byte("S3.9=8,G3.9")) + fmt.Println(cmd) + // Output: set 3.9 = 38 (1 bytes) +} diff --git a/pkg/api/web.go b/pkg/api/web.go index c4a88d3..bbe34bd 100644 --- a/pkg/api/web.go +++ b/pkg/api/web.go @@ -51,7 +51,7 @@ const indexHTML = `
-

DASH pod

+

pod

—
@@ -71,6 +71,8 @@ const indexHTML = `
Bolus
—
Alerts
—
Fault
—
+
Controller
—
+ @@ -303,7 +305,16 @@ function chip(el, text, cls){ el.textContent=text; el.className='chip '+(cls||'' function show(s){ last = s; + document.title = s.podType + ' simulator'; + $('podType').textContent = s.podType + ' pod'; $('podid').textContent = s.id ? 'id '+s.id : (s.paired ? '' : 'not paired'); + $('controller').textContent = s.controller ? s.controller : 'not paired'; + // An Omnipod 5 must have a good signature on every insulin, cancel and + // deactivate command, so a refusal is worth showing. + const anyRejected = s.signedRejected > 0; + $('rejectedLabel').hidden = !anyRejected; + $('rejected').hidden = !anyRejected; + if(anyRejected){ $('rejected').textContent = s.signedRejected; $('rejected').className = 'bad'; } chip($('link'), s.connected ? 'connected' : 'not connected', s.connected ? 'ok' : ''); $('reservoir').textContent = Math.round(s.reservoir*100)/100; const p = s.fault ? ['bad'] : s.progress>=8 && s.progress<=9 ? ['ok'] : ['warn']; diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index db1b6a2..e8c3adc 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -5,7 +5,7 @@ import ( "encoding/binary" "encoding/hex" "errors" - "hash/crc32" + "fmt" "sync" "time" @@ -23,6 +23,10 @@ var ( CmdAbort = Packet([]byte{3}) CmdSuccess = Packet([]byte{4}) CmdFail = Packet([]byte{5}) + CmdHello = Packet([]byte{6}) + // An Omnipod 5 reports progress with this while it works through a + // pairing message (OmnipodKit's PodCommand.PAIR_STATUS). + CmdPairStatus = Packet([]byte{8}) ) // Radio carries the pod's command and data characteristics to the phone: @@ -30,7 +34,7 @@ var ( type Radio interface { NotifyCmd(Packet) error NotifyData(Packet) error - Advertise(podId []byte) error + Advertise(podId []byte, pdmId uint32) error AdvertiseState(state AdvertState) error Disconnect() } @@ -119,6 +123,10 @@ type Ble struct { reconnected chan bool radio Radio + profile Profile + pdmId uint32 // the controller the pod is paired to; guarded by genMtx + podIdLocked []byte // the pod id last advertised; guarded by genMtx + // connGen counts central connections; servingGen is the one the // message loop was started for. They differ only after a real reconnect. genMtx sync.Mutex @@ -128,8 +136,9 @@ type Ble struct { } // New starts the pod's link layer on the radio that attach returns. -func New(attach func(*Ble) (Radio, error)) (*Ble, error) { +func New(attach func(*Ble) (Radio, error), profile Profile) (*Ble, error) { b := &Ble{ + profile: profile, in: newInput(), dataOutput: make(chan Packet, 5), cmdOutput: make(chan Packet, 5), @@ -221,9 +230,30 @@ func read(ch chan Packet, stop chan bool) (Packet, error) { func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, first 2 usually empty log.Debugf("RefreshAdvertisingWithSpecifiedId %x", id) - return b.radio.Advertise(id) + b.genMtx.Lock() + b.podIdLocked = id + pdmId := b.pdmId + b.genMtx.Unlock() + return b.radio.Advertise(id, pdmId) +} + +// SetPdmId records the controller the pod is paired to. An Omnipod 5 +// advertises it, so the phone can pick its own pod out while disconnected +// (BluetoothManager.podScanServiceUUID). +func (b *Ble) SetPdmId(pdmId uint32) { + b.genMtx.Lock() + changed := b.pdmId != pdmId + b.pdmId = pdmId + podId := b.podIdLocked + b.genMtx.Unlock() + if changed { + b.radio.Advertise(podId, pdmId) + } } +// Profile is the BLE profile the pod is serving. +func (b *Ble) Profile() Profile { return b.profile } + // SetAdvertisedState advertises the pod's fault and firing alerts. func (b *Ble) SetAdvertisedState(state AdvertState) error { return b.radio.AdvertiseState(state) @@ -313,6 +343,15 @@ func (b *Ble) WriteMessage(message *message.Message) { } func (b *Ble) loop(stop chan bool, in *input) { + // A DASH pod is asked for every exchange with RTS on the command + // characteristic; an Omnipod 5 has no such handshake, so an inbound + // message simply begins with its first data packet. Leaving the data + // channel nil for a DASH keeps this select out of the way of the + // packet reads inside readMessage. + var dataStart chan Packet + if !b.profile.UsesRTS() { + dataStart = in.data + } for { select { case <-stop: @@ -323,10 +362,19 @@ func (b *Ble) loop(stop chan bool, in *input) { case <-stop: return case msg := <-b.messageOutput: - if err := b.writeMessage(msg, in, stop); err != nil { + if err := b.writeMessage(msg, in, stop); err == errStopped { return + } else if err != nil { + // The phone gets no answer and asks again. + log.Errorf("pkg bluetooth; error writing message: %s", err) } case cmd := <-in.cmd: + if !b.profile.UsesRTS() { + // The phone only acknowledges here; nothing it writes to + // the command characteristic starts a message. + log.Tracef("pkg bluetooth; command from the phone: %x", cmd) + continue + } msg, err := b.readMessage(cmd, in, stop) if err == errStopped { return @@ -341,6 +389,20 @@ func (b *Ble) loop(stop chan bool, in *input) { case <-stop: return } + case first := <-dataStart: + msg, err := b.readMessagePackets(first, in, stop) + if err == errStopped { + return + } + if err != nil { + log.Errorf("pkg bluetooth; error reading message: %s", err) + continue + } + select { + case b.messageInput <- msg: + case <-stop: + return + } } } } @@ -369,180 +431,99 @@ func (b *Ble) StopMessageLoop() { } func (b *Ble) expectCommand(expected Packet, in *input, stop chan bool) error { - cmd, err := read(in.cmd, stop) - if err != nil { - return err - } - if !bytes.Equal(expected[:1], cmd[:1]) { - log.Fatalf("pkg bluetooth; expected command: %s. received command: %s", expected, cmd) + for { + cmd, err := read(in.cmd, stop) + if err != nil { + return err + } + if len(cmd) == 0 { + return fmt.Errorf("expected command: %x. received an empty command", expected) + } + // A real Omnipod 5 reports progress with 0x08 while it works + // through a pairing message; OmnipodKit skips those and keeps + // waiting for what it asked for, so the pod tolerates them too. + if cmd[0] == CmdPairStatus[0] { + log.Tracef("pkg bluetooth; skipping pair status %x", cmd) + continue + } + if cmd[0] != expected[0] { + return fmt.Errorf("expected command: %x. received command: %x", expected, cmd) + } + return nil } - return nil } +// writeMessage sends one pod message as a run of packets. A DASH asks to +// send first and an Omnipod 5 does not, but both wait to be told the +// message arrived (OmnipodKit's PeripheralManager.sendMessagePacket). func (b *Ble) writeMessage(msg *message.Message, in *input, stop chan bool) error { - var buf bytes.Buffer - var index = 0 - - b.WriteCmd(CmdRTS) - if err := b.expectCommand(CmdCTS, in, stop); err != nil { // TODO figure out what to do if !CTS - return err - } - bytes, err := msg.Marshal() + raw, err := msg.Marshal() if err != nil { log.Fatalf("pkg bluetooth; could not marshal the message %s", err) } - log.Tracef("pkg bluetooth; Sending message: %x", bytes) - sum := crc32.ChecksumIEEE(bytes) - if len(bytes) <= 18 { - buf.WriteByte(byte(index)) - buf.WriteByte(0) // fragments - - buf.WriteByte(byte(sum >> 24)) - buf.WriteByte(byte(sum >> 16)) - buf.WriteByte(byte(sum >> 8)) - buf.WriteByte(byte(sum)) - buf.WriteByte((byte(len(bytes)))) - end := len(bytes) - if len(bytes) > 14 { - end = 14 - } - buf.Write(bytes[:end]) - b.writeDataBuffer(&buf) - - if len(bytes) > 14 { - buf.WriteByte(byte(index)) - buf.WriteByte(byte(len(bytes) - 14)) - buf.Write(bytes[14:]) - b.writeDataBuffer(&buf) - } - return nil - } + log.Tracef("pkg bluetooth; Sending message: %x", raw) - size := len(bytes) - fullFragments := (size - 18) / 19 - rest := (size - (fullFragments * 19)) - 18 - buf.WriteByte(byte(index)) - buf.WriteByte(byte(fullFragments + 1)) - buf.Write(bytes[:18]) - - b.writeDataBuffer(&buf) - - for index = 1; index <= fullFragments; index++ { - buf.WriteByte(byte(index)) - if index == 1 { - buf.Write(bytes[18:37]) - } else { - buf.Write(bytes[(index-1)*19+18 : (index-1)*19+18+19]) + if b.profile.UsesRTS() { + b.WriteCmd(CmdRTS) + if err := b.expectCommand(CmdCTS, in, stop); err != nil { // TODO figure out what to do if !CTS + return err } - b.writeDataBuffer(&buf) } - buf.WriteByte(byte(index)) - buf.WriteByte(byte(rest)) - buf.WriteByte(byte(sum >> 24)) - buf.WriteByte(byte(sum >> 16)) - buf.WriteByte(byte(sum >> 8)) - buf.WriteByte(byte(sum)) - end := rest - if rest > 14 { - end = 14 - } - buf.Write(bytes[(fullFragments*19)+18 : (fullFragments*19)+18+end]) - b.writeDataBuffer(&buf) - if rest > 14 { - index++ - buf.WriteByte(byte(index)) - buf.WriteByte(byte(rest - 14)) - buf.Write(bytes[fullFragments*19+18+14:]) - for buf.Len() < 20 { - buf.WriteByte(0) + for _, packet := range b.profile.Layout.Split(raw) { + if err := b.WriteData(packet); err != nil { + return err } - b.writeDataBuffer(&buf) } return b.expectCommand(CmdSuccess, in, stop) } +// readMessage reads a DASH message, which the phone opens with RTS. func (b *Ble) readMessage(cmd Packet, in *input, stop chan bool) (*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) + if len(cmd) == 0 || cmd[0] != CmdRTS[0] { + return nil, fmt.Errorf("expected command: %x. received command: %x", CmdRTS, cmd) } log.Trace("pkg bluetooth; Sending CTS") - b.WriteCmd(CmdCTS) first, err := read(in.data, stop) if err != nil { return nil, err } - fragments := int(first[1]) - expectedIndex := 1 - oneExtra := false - if fragments == 0 { - checksum = first[2:6] - len := first[6] - end := len + 7 - if len > 13 { - oneExtra = true - end = 20 - } - buf.Write(first[7:end]) - } else { - buf.Write(first[2:20]) - } - for i := 1; i < fragments; i++ { - data, err := read(in.data, stop) - if err != nil { - return nil, err - } - if i == expectedIndex { - buf.Write(data[1:20]) - } else { - log.Warnf("pkg bluetooth; sending NACK, packet index is wrong") - buf.Write(data[:]) - CmdNACK[1] = byte(expectedIndex) - b.WriteCmd(CmdNACK) - } - expectedIndex++ + return b.readMessagePackets(first, in, stop) +} + +// readMessagePackets reassembles a message that has begun with first, and +// tells the phone how it went. +func (b *Ble) readMessagePackets(first Packet, in *input, stop chan bool) (*message.Message, error) { + joiner, err := NewJoiner(first, b.profile.Layout) + if err != nil { + log.Warnf("pkg bluetooth; sending FAIL: %s", err) + b.WriteCmd(CmdFail) + return nil, err } - if fragments != 0 { - data, err := read(in.data, stop) + for joiner.More() { + packet, err := read(in.data, stop) if err != nil { return nil, err } - len := data[1] - if len > 14 { - oneExtra = true - len = 14 - } - checksum = data[2:6] - buf.Write(data[6 : len+6]) - } - log.Tracef("pkg bluetooth; One extra: %t", oneExtra) - if oneExtra { - data, err := read(in.data, stop) - if err != nil { + if err := joiner.Accumulate(packet); err != nil { + log.Warnf("pkg bluetooth; sending FAIL: %s", err) + b.WriteCmd(CmdFail) return nil, err } - buf.Write(data[2 : data[1]+2]) } - bytes := buf.Bytes() - sum := crc32.ChecksumIEEE(bytes) - if binary.BigEndian.Uint32(checksum) != sum { - log.Warnf("pkg bluetooth; checksum missmatch. checksum is: %x. want: %x", sum, checksum) - log.Warnf("pkg bluetooth; data: %s", hex.EncodeToString(bytes)) - + body, err := joiner.Finalize() + if err != nil { + log.Warnf("pkg bluetooth; sending FAIL: %s", err) b.WriteCmd(CmdFail) - return nil, errors.New("checksum missmatch") + return nil, err } b.WriteCmd(CmdSuccess) - msg, _err := message.Unmarshal(bytes) + msg, err := message.Unmarshal(body) log.Tracef("pkg bluetooth; Received message: %s", spew.Sdump(msg)) - - return msg, _err + return msg, err } diff --git a/pkg/bluetooth/bluetooth_test.go b/pkg/bluetooth/bluetooth_test.go index 3bf1183..396e173 100644 --- a/pkg/bluetooth/bluetooth_test.go +++ b/pkg/bluetooth/bluetooth_test.go @@ -36,12 +36,12 @@ func (w *wire) NotifyData(p Packet) error { return nil } -func (w *wire) Advertise([]byte) error { return nil } +func (w *wire) Advertise([]byte, uint32) error { return nil } func (w *wire) AdvertiseState(AdvertState) error { return nil } func (w *wire) Disconnect() {} func newEnd(t *testing.T, w *wire) *Ble { - b, err := New(func(*Ble) (Radio, error) { return w, nil }) + b, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeDash)) if err != nil { t.Fatal(err) } diff --git a/pkg/bluetooth/gatt_linux.go b/pkg/bluetooth/gatt_linux.go index 8456ffc..d4c188b 100644 --- a/pkg/bluetooth/gatt_linux.go +++ b/pkg/bluetooth/gatt_linux.go @@ -2,6 +2,7 @@ package bluetooth import ( "encoding/hex" + "errors" "fmt" "sync" @@ -38,6 +39,12 @@ var DefaultServerOptions = []gatt.Option{ // NewGattRadio serves the pod from the first Bluetooth adapter. func NewGattRadio(b *Ble, podId []byte) (Radio, error) { + // An Omnipod 5 advertises one 128-bit service UUID and needs a + // 247-byte ATT MTU; the vendored gatt library advertises neither, so + // say so rather than advertise something no phone will answer. + if b.profile.Mode.IsO5() { + return nil, errors.New("-mode o5 needs -transport impossible: this Bluetooth stack advertises only 16-bit service UUIDs") + } d, err := gatt.NewDevice(DefaultServerOptions...) if err != nil { log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) @@ -131,7 +138,7 @@ func (r *gattRadio) advertise() error { } // Advertise updates the advertisement; paypal/gatt needs no StopAdvertising first. -func (r *gattRadio) Advertise(podId []byte) error { +func (r *gattRadio) Advertise(podId []byte, pdmId uint32) error { r.advertMtx.Lock() r.podId = podId r.advertMtx.Unlock() diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go index 75a8755..947f5f1 100644 --- a/pkg/bluetooth/impossible.go +++ b/pkg/bluetooth/impossible.go @@ -23,14 +23,16 @@ import ( // place of ImpossiBLE's menu bar app. Like that app it serves one client, and // a new connection takes over from the old one. type simRadio struct { - ble *Ble - id string // the peripheral identifier the app sees + ble *Ble + profile Profile + id string // the peripheral identifier the app sees mu sync.Mutex client *simClient scanning bool connected bool podId []byte + pdmId uint32 // the controller an Omnipod 5 advertises once paired state AdvertState notifying map[string]bool // characteristic ids delayed *time.Timer // a connect held for its start delay @@ -71,10 +73,6 @@ const ( propWriteNoResponse = 0x04 propWrite = 0x08 propNotify = 0x10 - - podServiceUUID = "1A7E4024-E3ED-4464-8B7E-751E03D0DC5F" - cmdCharUUID = "1A7E2441-E3ED-4464-8B7E-751E03D0DC5F" - dataCharUUID = "1A7E2442-E3ED-4464-8B7E-751E03D0DC5F" ) // NewSimRadio serves the pod on ImpossiBLE's socket at path, as the @@ -91,7 +89,7 @@ func NewSimRadio(b *Ble, path, id string, podId []byte) (Radio, error) { if err != nil { return nil, err } - r := &simRadio{ble: b, id: id, podId: podId, notifying: map[string]bool{}} + r := &simRadio{ble: b, profile: b.profile, id: id, podId: podId, notifying: map[string]bool{}} log.Infof("pkg bluetooth; serving the iOS Simulator on %s as peripheral %s", path, id) go func() { for { @@ -183,7 +181,7 @@ func (r *simRadio) connect(c *simClient) { c.send(map[string]interface{}{"type": "didConnect", "id": r.id}) } -func (r *simRadio) serviceID() string { return r.id + ":" + podServiceUUID } +func (r *simRadio) serviceID() string { return r.id + ":" + r.profile.ServiceUUID } func (r *simRadio) charID(uuid string) string { return r.serviceID() + ":" + uuid } @@ -241,12 +239,12 @@ func (r *simRadio) handle(c *simClient, msg map[string]interface{}) { r.disconnect(c, "app") case "discoverServices": c.send(map[string]interface{}{"type": "didDiscoverServices", "id": id, "error": "", - "services": []map[string]interface{}{{"id": r.serviceID(), "uuid": podServiceUUID, "primary": true}}}) + "services": []map[string]interface{}{{"id": r.serviceID(), "uuid": r.profile.ServiceUUID, "primary": true}}}) case "discoverCharacteristics": serviceID := str(msg, "serviceId") chars := []map[string]interface{}{} if serviceID == r.serviceID() { - for _, u := range []string{cmdCharUUID, dataCharUUID} { + for _, u := range []string{r.profile.CmdCharUUID, r.profile.DataCharUUID} { chars = append(chars, map[string]interface{}{"id": r.charID(u), "uuid": u, "properties": propWrite | propWriteNoResponse | propNotify}) } @@ -284,15 +282,12 @@ func (r *simRadio) handle(c *simClient, msg map[string]interface{}) { // advertising, which it does whenever it has no connection. func (r *simRadio) discovered() { r.mu.Lock() - c, scanning, connected, podId, state := r.client, r.scanning, r.connected, r.podId, r.state + c, scanning, connected, podId, pdmId, state := r.client, r.scanning, r.connected, r.podId, r.pdmId, r.state r.mu.Unlock() if c == nil || !scanning || connected { return } - var uuids []string - for _, u := range AdvertisedServices(podId, state) { - uuids = append(uuids, fmt.Sprintf("%04X", u)) - } + uuids := r.profile.AdvertisedServiceUUIDs(podId, pdmId, state) c.send(map[string]interface{}{"type": "didDiscover", "id": r.id, "name": AdvertisedName, "rssi": -60, "adv": map[string]interface{}{ "kCBAdvDataLocalName": AdvertisedName, @@ -312,7 +307,7 @@ func (r *simRadio) write(c *simClient, msg map[string]interface{}) { switch { case err != nil: reply["error"] = "bad value" - case charID != r.charID(cmdCharUUID) && charID != r.charID(dataCharUUID): + case charID != r.charID(r.profile.CmdCharUUID) && charID != r.charID(r.profile.DataCharUUID): reply["error"] = "unknown characteristic" case !connected: reply["error"] = "not connected" @@ -323,7 +318,7 @@ func (r *simRadio) write(c *simClient, msg map[string]interface{}) { if reply["error"] != "" { return } - if charID == r.charID(cmdCharUUID) { + if charID == r.charID(r.profile.CmdCharUUID) { log.Tracef("received CMD, %x", data) r.ble.CmdWritten(data) } else { @@ -342,7 +337,7 @@ func (r *simRadio) setNotify(c *simClient, msg map[string]interface{}) { "enabled": enabled, "error": ""} r.mu.Lock() switch { - case charID != r.charID(cmdCharUUID) && charID != r.charID(dataCharUUID): + case charID != r.charID(r.profile.CmdCharUUID) && charID != r.charID(r.profile.DataCharUUID): reply["error"] = "unknown characteristic" case !r.connected: reply["error"] = "not connected" @@ -354,7 +349,7 @@ func (r *simRadio) setNotify(c *simClient, msg map[string]interface{}) { reply["enabled"] = false } c.send(reply) - if enabled && charID == r.charID(dataCharUUID) && reply["error"] == "" { + if enabled && charID == r.charID(r.profile.DataCharUUID) && reply["error"] == "" { log.Infof(" *** OK to send commands from the phone app ***") } } @@ -375,13 +370,16 @@ func (r *simRadio) notify(uuid string, packet Packet) error { return nil } -func (r *simRadio) NotifyCmd(packet Packet) error { return r.notify(cmdCharUUID, packet) } +func (r *simRadio) NotifyCmd(packet Packet) error { return r.notify(r.profile.CmdCharUUID, packet) } -func (r *simRadio) NotifyData(packet Packet) error { return r.notify(dataCharUUID, packet) } +func (r *simRadio) NotifyData(packet Packet) error { return r.notify(r.profile.DataCharUUID, packet) } -func (r *simRadio) Advertise(podId []byte) error { +func (r *simRadio) Advertise(podId []byte, pdmId uint32) error { r.mu.Lock() r.podId = podId + if pdmId != 0 { + r.pdmId = pdmId + } r.mu.Unlock() r.discovered() return nil diff --git a/pkg/bluetooth/impossible_test.go b/pkg/bluetooth/impossible_test.go index 6e4dd53..3e6357d 100644 --- a/pkg/bluetooth/impossible_test.go +++ b/pkg/bluetooth/impossible_test.go @@ -51,7 +51,7 @@ func TestSimRadioPairsAndCarriesBothCharacteristics(t *testing.T) { defer os.RemoveAll(dir) path := filepath.Join(dir, "impossible.sock") const id = "11111111-2222-4333-8444-555555555555" - b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }) + b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }, ProfileFor(ModeDash)) if err != nil { t.Fatal(err) } @@ -147,7 +147,7 @@ func TestSimRadioHoldsAConnectForItsStartDelay(t *testing.T) { defer os.RemoveAll(dir) path := filepath.Join(dir, "impossible.sock") const id = "11111111-2222-4333-8444-555555555555" - b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }) + b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }, ProfileFor(ModeDash)) if err != nil { t.Fatal(err) } @@ -182,3 +182,65 @@ func TestSimRadioHoldsAConnectForItsStartDelay(t *testing.T) { t.Fatal("a cancelled connect completed") } } + +// An Omnipod 5 is served over the same socket as a DASH, but it +// advertises one 128-bit service UUID holding its controller's id and its +// data characteristic is 2443 rather than 2442 (OmnipodKit's +// o5OmnipodCharacteristicUUID and PodAdvertisement). +func TestSimRadioServesAnOmnipod5(t *testing.T) { + dir, err := ioutil.TempDir("/tmp", "podsim") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "impossible.sock") + const id = "11111111-2222-4333-8444-555555555556" + + b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }, ProfileFor(ModeO5)) + if err != nil { + t.Fatal(err) + } + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + app := &fakeApp{t: t, conn: conn, in: bufio.NewScanner(conn)} + + app.send(map[string]interface{}{"type": "hello", "bundleId": "org.loopkit.Loop", "pid": 0}) + app.send(map[string]interface{}{"type": "scan"}) + uuids := app.expect("didDiscover")["adv"].(map[string]interface{})["kCBAdvDataServiceUUIDs"].([]interface{}) + if len(uuids) != 1 || uuids[0] != "CE1F923D-C539-48EA-7300-0AFFFFFFFE00" { + t.Fatalf("an unpaired Omnipod 5 advertises %v, want one UUID holding FFFFFFFE", uuids) + } + + // Once it knows its controller, the pod advertises that instead, which + // is what the phone scans for to find its own pod. + b.SetPdmId(0x002A1C6C) + uuids = app.expect("didDiscover")["adv"].(map[string]interface{})["kCBAdvDataServiceUUIDs"].([]interface{}) + if len(uuids) != 1 || uuids[0] != "CE1F923D-C539-48EA-7300-0A002A1C6C00" { + t.Fatalf("a paired Omnipod 5 advertises %v, want its controller's UUID", uuids) + } + + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") + + app.send(map[string]interface{}{"type": "discoverServices", "id": id}) + svc := app.expect("didDiscoverServices")["services"].([]interface{})[0].(map[string]interface{}) + if svc["uuid"] != podServiceUUID { + t.Errorf("the pod serves service %v, want %s", svc["uuid"], podServiceUUID) + } + + app.send(map[string]interface{}{"type": "discoverCharacteristics", "id": id, "serviceId": svc["id"]}) + chars := app.expect("didDiscoverCharacteristics")["characteristics"].([]interface{}) + if len(chars) != 2 { + t.Fatalf("the pod serves %d characteristics, want 2", len(chars)) + } + got := []string{ + chars[0].(map[string]interface{})["uuid"].(string), + chars[1].(map[string]interface{})["uuid"].(string), + } + if got[0] != cmdCharUUID || got[1] != o5DataCharUUID { + t.Errorf("the pod serves %v, want the command characteristic and %s", got, o5DataCharUUID) + } +} diff --git a/pkg/bluetooth/o5_link_test.go b/pkg/bluetooth/o5_link_test.go new file mode 100644 index 0000000..30946ea --- /dev/null +++ b/pkg/bluetooth/o5_link_test.go @@ -0,0 +1,224 @@ +package bluetooth + +import ( + "bytes" + "testing" + "time" + + "github.com/avereha/pod/pkg/message" +) + +// phoneWire stands for the phone at the other end of the radio: it takes +// the pod's notifications and lets a test answer them. +type phoneWire struct { + cmd chan Packet + data chan Packet +} + +func newPhoneWire() *phoneWire { + return &phoneWire{cmd: make(chan Packet, 64), data: make(chan Packet, 64)} +} + +func (w *phoneWire) NotifyCmd(p Packet) error { + w.cmd <- append(Packet(nil), p...) + return nil +} + +func (w *phoneWire) NotifyData(p Packet) error { + w.data <- append(Packet(nil), p...) + return nil +} + +func (w *phoneWire) Advertise([]byte, uint32) error { return nil } +func (w *phoneWire) AdvertiseState(AdvertState) error { return nil } +func (w *phoneWire) Disconnect() {} + +func (w *phoneWire) nextCmd(t *testing.T) Packet { + t.Helper() + select { + case p := <-w.cmd: + return p + case <-time.After(2 * time.Second): + t.Fatal("the pod sent no command") + return nil + } +} + +func (w *phoneWire) nextData(t *testing.T) Packet { + t.Helper() + select { + case p := <-w.data: + return p + case <-time.After(2 * time.Second): + t.Fatal("the pod sent no data") + return nil + } +} + +// An Omnipod 5 has no RTS/CTS handshake: a message simply begins with its +// first data packet, and the side that received it answers SUCCESS on the +// command characteristic (OmnipodKit's PeripheralManager.sendMessagePacket +// and readMessagePacket, which skip RTS/CTS unless the pod is a DASH). +func TestO5LinkLayerBothWays(t *testing.T) { + w := newPhoneWire() + pod, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeO5)) + if err != nil { + t.Fatal(err) + } + layout := pod.Profile().Layout + pod.Connected() + pod.StartMessageLoop() + defer pod.StopMessageLoop() + + // A pairing-sized message, big enough to need several packets. + payload := make([]byte, 700) + for i := range payload { + payload[i] = byte(i) + } + + t.Run("the phone writes a message", func(t *testing.T) { + out := message.NewMessage(message.MessageTypePairing, []byte{0, 0x2a, 0x1c, 0x6c}, []byte{0xff, 0xff, 0xff, 0xfe}) + out.Payload = payload + raw, err := out.Marshal() + if err != nil { + t.Fatal(err) + } + for _, p := range layout.Split(raw) { + pod.DataWritten(p) + } + + // The pod must say it has the message, without being asked first. + if got := w.nextCmd(t); len(got) == 0 || got[0] != CmdSuccess[0] { + t.Fatalf("the pod answered %x, want SUCCESS %x", got, CmdSuccess) + } + + got, timedOut, _ := pod.ReadMessageWithTimeout(2 * time.Second) + if timedOut || got == nil { + t.Fatal("the pod did not pass the message on") + } + if !bytes.Equal(got.Payload, payload) { + t.Errorf("the pod read %d bytes, want %d", len(got.Payload), len(payload)) + } + }) + + t.Run("the pod writes a message", func(t *testing.T) { + out := message.NewMessage(message.MessageTypePairing, []byte{0xff, 0xff, 0xff, 0xfe}, []byte{0, 0x2a, 0x1c, 0x6c}) + out.Payload = payload + pod.WriteMessage(out) + + // No RTS: the packets arrive straight away. + first := w.nextData(t) + joiner, err := NewJoiner(first, layout) + if err != nil { + t.Fatalf("the pod's first packet: %v", err) + } + for joiner.More() { + if err := joiner.Accumulate(w.nextData(t)); err != nil { + t.Fatalf("the pod's packets: %v", err) + } + } + body, err := joiner.Finalize() + if err != nil { + t.Fatalf("the pod's message: %v", err) + } + back, err := message.Unmarshal(body) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(back.Payload, payload) { + t.Errorf("the pod wrote %d bytes, want %d", len(back.Payload), len(payload)) + } + + // The phone acknowledges, and the pod's write completes. + pod.CmdWritten(CmdSuccess) + }) +} + +// A DASH still opens every exchange with RTS and waits for CTS, which is +// what the pods and OmnipodKit have always done. +func TestDashLinkLayerStillHandshakes(t *testing.T) { + w := newPhoneWire() + pod, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeDash)) + if err != nil { + t.Fatal(err) + } + pod.Connected() + pod.StartMessageLoop() + defer pod.StopMessageLoop() + + out := message.NewMessage(message.MessageTypePairing, []byte{0xff, 0xff, 0xff, 0xfe}, []byte{0, 0x2a, 0x1c, 0x6c}) + out.Payload = []byte("hello") + pod.WriteMessage(out) + + if got := w.nextCmd(t); len(got) == 0 || got[0] != CmdRTS[0] { + t.Fatalf("the pod sent %x first, want RTS %x", got, CmdRTS) + } + pod.CmdWritten(CmdCTS) + if got := w.nextData(t); len(got) == 0 { + t.Fatal("the pod sent no data after CTS") + } + pod.CmdWritten(CmdSuccess) +} + +// An Omnipod 5 reports progress with 0x08 while it works through a pairing +// message; the pod must keep waiting for SUCCESS rather than treat that as +// a failure (OmnipodKit tolerates the same from a pod). +func TestO5PairStatusIsSkipped(t *testing.T) { + w := newPhoneWire() + pod, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeO5)) + if err != nil { + t.Fatal(err) + } + pod.Connected() + pod.StartMessageLoop() + defer pod.StopMessageLoop() + + out := message.NewMessage(message.MessageTypePairing, []byte{0xff, 0xff, 0xff, 0xfe}, []byte{0, 0x2a, 0x1c, 0x6c}) + out.Payload = []byte("hello") + pod.WriteMessage(out) + w.nextData(t) + + pod.CmdWritten(CmdPairStatus) + pod.CmdWritten(CmdSuccess) + + // The write finished, so the loop takes the next message. + out2 := message.NewMessage(message.MessageTypePairing, []byte{0xff, 0xff, 0xff, 0xfe}, []byte{0, 0x2a, 0x1c, 0x6c}) + out2.Payload = []byte("again") + pod.WriteMessage(out2) + if got := w.nextData(t); len(got) == 0 { + t.Fatal("the pod did not go on to the next message") + } + pod.CmdWritten(CmdSuccess) +} + +// A message whose packets do not add up is refused with FAIL rather than +// passed on as a command. +func TestO5DamagedMessageIsRefused(t *testing.T) { + w := newPhoneWire() + pod, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeO5)) + if err != nil { + t.Fatal(err) + } + layout := pod.Profile().Layout + pod.Connected() + pod.StartMessageLoop() + defer pod.StopMessageLoop() + + out := message.NewMessage(message.MessageTypePairing, []byte{0, 0x2a, 0x1c, 0x6c}, []byte{0xff, 0xff, 0xff, 0xfe}) + out.Payload = make([]byte, 700) + raw, err := out.Marshal() + if err != nil { + t.Fatal(err) + } + packets := layout.Split(raw) + damaged := append(Packet(nil), packets[1]...) + damaged[10] ^= 0xff + packets[1] = damaged + + for _, p := range packets { + pod.DataWritten(p) + } + if got := w.nextCmd(t); len(got) == 0 || got[0] != CmdFail[0] { + t.Fatalf("the pod answered %x, want FAIL %x", got, CmdFail) + } +} diff --git a/pkg/bluetooth/packet.go b/pkg/bluetooth/packet.go new file mode 100644 index 0000000..e62abbe --- /dev/null +++ b/pkg/bluetooth/packet.go @@ -0,0 +1,192 @@ +package bluetooth + +import ( + "encoding/binary" + "fmt" + "hash/crc32" +) + +// A pod message is carried over BLE as a run of fixed-size packets. Both +// sides build and read them the same way; OmnipodKit's PayloadSplitter, +// PayloadJoiner and BLEPacket are the reference, and its BlePacketLayout +// is what differs between a DASH pod and an Omnipod 5 (20-byte packets +// against 244-byte ones). +type PacketLayout struct { + MaxPayloadSize int + MaxFragments int + + FirstHeaderWithoutMiddle int + FirstHeaderWithMiddle int + LastHeader int +} + +// The capacities each kind of packet leaves for message bytes. +func (l PacketLayout) firstCapWithoutMiddle() int { + return l.MaxPayloadSize - l.FirstHeaderWithoutMiddle +} +func (l PacketLayout) firstCapWithMiddle() int { return l.MaxPayloadSize - l.FirstHeaderWithMiddle } +func (l PacketLayout) middleCap() int { return l.MaxPayloadSize - 1 } +func (l PacketLayout) lastCap() int { return l.MaxPayloadSize - l.LastHeader } + +// Split cuts a message into the packets to write, in order. +func (l PacketLayout) Split(payload []byte) []Packet { + sum := make([]byte, 4) + binary.BigEndian.PutUint32(sum, crc32.ChecksumIEEE(payload)) + + // Short enough for one packet, plus at most one carrying the remainder. + if len(payload) <= l.firstCapWithMiddle() { + end := min(l.firstCapWithoutMiddle(), len(payload)) + first := make([]byte, 0, l.FirstHeaderWithoutMiddle+end) + first = append(first, 0, 0) // index, no full fragments + first = append(first, sum...) + first = append(first, byte(len(payload))) + first = append(first, payload[:end]...) + packets := []Packet{first} + if len(payload) > end { + packets = append(packets, l.plusOne(1, payload[end:])) + } + return packets + } + + middles := (len(payload) - l.firstCapWithMiddle()) / l.middleCap() + rest := len(payload) - middles*l.middleCap() - l.firstCapWithMiddle() + + first := make([]byte, 0, l.MaxPayloadSize) + first = append(first, 0, byte(middles+1)) + first = append(first, payload[:l.firstCapWithMiddle()]...) + packets := []Packet{first} + + for i := 1; i <= middles; i++ { + from := l.firstCapWithMiddle() + (i-1)*l.middleCap() + mid := make([]byte, 0, l.MaxPayloadSize) + mid = append(mid, byte(i)) + mid = append(mid, payload[from:from+l.middleCap()]...) + packets = append(packets, mid) + } + + tail := l.firstCapWithMiddle() + middles*l.middleCap() + end := min(l.lastCap(), rest) + last := make([]byte, 0, l.MaxPayloadSize) + last = append(last, byte(middles+1), byte(rest)) + last = append(last, sum...) + last = append(last, payload[tail:tail+end]...) + last = append(last, make([]byte, l.MaxPayloadSize-end-l.LastHeader)...) + packets = append(packets, last) + + if rest > l.lastCap() { + packets = append(packets, l.plusOne(byte(middles+2), payload[tail+l.lastCap():])) + } + return packets +} + +// plusOne is the optional extra packet that carries what would not fit in +// a first or last packet (OmnipodKit's LastOptionalPlusOneBlePacket). +func (l PacketLayout) plusOne(index byte, payload []byte) Packet { + p := make([]byte, 0, l.MaxPayloadSize) + p = append(p, index, byte(len(payload))) + p = append(p, payload...) + return append(p, make([]byte, l.MaxPayloadSize-len(payload)-2)...) +} + +// Joiner reassembles a message from the packets the phone writes. +type Joiner struct { + layout PacketLayout + body []byte + sum []byte + fullFragments int + plusOne bool + seen int +} + +// NewJoiner starts on the first packet of a message. +func NewJoiner(first Packet, layout PacketLayout) (*Joiner, error) { + if len(first) < layout.FirstHeaderWithMiddle { + return nil, fmt.Errorf("first packet is %d bytes, too short to read", len(first)) + } + if first[0] != 0 { + return nil, fmt.Errorf("first packet has index %d, want 0", first[0]) + } + fragments := int(first[1]) + if fragments > layout.MaxFragments { + return nil, fmt.Errorf("first packet claims %d fragments, more than %d", fragments, layout.MaxFragments) + } + if len(first) < layout.FirstHeaderWithoutMiddle { + return nil, fmt.Errorf("first packet is %d bytes, too short to read", len(first)) + } + + j := &Joiner{layout: layout, fullFragments: fragments} + if fragments == 0 { + size := int(first[6]) + end := min(size+layout.FirstHeaderWithoutMiddle, len(first)) + j.sum = append([]byte(nil), first[2:6]...) + j.body = append(j.body, first[layout.FirstHeaderWithoutMiddle:end]...) + j.plusOne = size+layout.FirstHeaderWithoutMiddle > end + return j, nil + } + if len(first) < layout.MaxPayloadSize { + return nil, fmt.Errorf("first packet of a run is %d bytes, want %d", len(first), layout.MaxPayloadSize) + } + j.body = append(j.body, first[layout.FirstHeaderWithMiddle:layout.MaxPayloadSize]...) + return j, nil +} + +// More says whether the message needs another packet. +func (j *Joiner) More() bool { + return j.seen < j.fullFragments || (j.plusOne && j.seen < j.fullFragments+1) +} + +// Accumulate takes the next packet of the message. +func (j *Joiner) Accumulate(p Packet) error { + if len(p) < 3 { + return fmt.Errorf("packet is %d bytes, too short to read", len(p)) + } + index := int(p[0]) + if index != j.seen+1 { + return fmt.Errorf("packet has index %d, want %d", index, j.seen+1) + } + j.seen = index + + switch { + case index < j.fullFragments: + if len(p) < j.layout.MaxPayloadSize { + return fmt.Errorf("middle packet is %d bytes, want %d", len(p), j.layout.MaxPayloadSize) + } + j.body = append(j.body, p[1:j.layout.MaxPayloadSize]...) + case index == j.fullFragments: + if len(p) < j.layout.LastHeader { + return fmt.Errorf("last packet is %d bytes, too short to read", len(p)) + } + size := int(p[1]) + end := min(size+j.layout.LastHeader, len(p)) + j.sum = append([]byte(nil), p[2:6]...) + j.body = append(j.body, p[j.layout.LastHeader:end]...) + j.plusOne = size+j.layout.LastHeader > end + case index == j.fullFragments+1 && j.plusOne: + size := int(p[1]) + if len(p) < 2+size { + return fmt.Errorf("extra packet holds %d bytes, less than the %d it claims", len(p)-2, size) + } + j.body = append(j.body, p[2:2+size]...) + default: + return fmt.Errorf("packet has index %d, past the %d fragments of this message", index, j.fullFragments) + } + return nil +} + +// Finalize returns the message once every packet has arrived. +func (j *Joiner) Finalize() ([]byte, error) { + if j.sum == nil { + return nil, fmt.Errorf("the message never carried a checksum") + } + if want, got := binary.BigEndian.Uint32(j.sum), crc32.ChecksumIEEE(j.body); want != got { + return nil, fmt.Errorf("message checksum is %08x, want %08x", got, want) + } + return j.body, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/bluetooth/packet_test.go b/pkg/bluetooth/packet_test.go new file mode 100644 index 0000000..06d5538 --- /dev/null +++ b/pkg/bluetooth/packet_test.go @@ -0,0 +1,198 @@ +package bluetooth + +import ( + "bytes" + "encoding/hex" + "fmt" + "math/rand" + "testing" +) + +// Whatever the pod writes, the phone has to be able to put back together, +// at both packet sizes and at every length that changes which packets are +// used: one packet, one plus the extra, a run of middle packets, and a run +// whose last packet overflows into the extra one. +func TestSplitAndJoinRoundTrip(t *testing.T) { + for _, mode := range []Mode{ModeDash, ModeO5} { + layout := ProfileFor(mode).Layout + t.Run(string(mode), func(t *testing.T) { + sizes := []int{1, 2, + layout.firstCapWithoutMiddle() - 1, + layout.firstCapWithoutMiddle(), + layout.firstCapWithoutMiddle() + 1, + layout.firstCapWithMiddle(), + layout.firstCapWithMiddle() + 1, + layout.firstCapWithMiddle() + layout.lastCap(), + layout.firstCapWithMiddle() + layout.lastCap() + 1, + layout.firstCapWithMiddle() + layout.middleCap() + 1, + layout.firstCapWithMiddle() + 3*layout.middleCap() + 7, + } + // The pairing messages are what makes the Omnipod 5 packet + // size necessary, so cover their sizes too. + if mode == ModeO5 { + sizes = append(sizes, 16+80, 16+642, 16+1089, 16+1100) + } + for _, size := range sizes { + body := make([]byte, size) + rand.New(rand.NewSource(int64(size))).Read(body) + + packets := layout.Split(body) + if len(packets) == 0 { + t.Fatalf("%d bytes split into no packets", size) + } + for i, p := range packets { + if len(p) > layout.MaxPayloadSize { + t.Fatalf("%d bytes: packet %d is %d bytes, over the %d limit", size, i, len(p), layout.MaxPayloadSize) + } + } + + joiner, err := NewJoiner(packets[0], layout) + if err != nil { + t.Fatalf("%d bytes: first packet: %v", size, err) + } + for i := 1; i < len(packets); i++ { + if !joiner.More() { + t.Fatalf("%d bytes: packet %d was not expected", size, i) + } + if err := joiner.Accumulate(packets[i]); err != nil { + t.Fatalf("%d bytes: packet %d: %v", size, i, err) + } + } + if joiner.More() { + t.Fatalf("%d bytes: still waiting for a packet after all %d", size, len(packets)) + } + got, err := joiner.Finalize() + if err != nil { + t.Fatalf("%d bytes: %v", size, err) + } + if !bytes.Equal(got, body) { + t.Fatalf("%d bytes: came back as %d bytes and differs", size, len(got)) + } + } + }) + } +} + +// A DASH pod has 20-byte packets and an Omnipod 5 244-byte ones, which is +// what lets its ~1 KB pairing messages fit in the 15 fragments the phone +// allows (OmnipodKit's BlePodProfile). +func TestLayoutsMatchOmnipodKit(t *testing.T) { + dash := ProfileFor(ModeDash).Layout + if dash.MaxPayloadSize != 20 { + t.Errorf("DASH packets are %d bytes, want 20", dash.MaxPayloadSize) + } + o5 := ProfileFor(ModeO5).Layout + if o5.MaxPayloadSize != 244 { + t.Errorf("Omnipod 5 packets are %d bytes, want 244", o5.MaxPayloadSize) + } + + // The largest pairing message is SPS2, about 1.1 KB with its wrapper. + if _, err := NewJoiner(o5.Split(make([]byte, 1200))[0], o5); err != nil { + t.Errorf("a 1200-byte message does not fit the Omnipod 5 layout: %v", err) + } + if n := len(o5.Split(make([]byte, 1200))); n > o5.MaxFragments+2 { + t.Errorf("a 1200-byte message takes %d packets, more than the phone reads", n) + } +} + +// A damaged message has to be refused rather than passed on. +func TestJoinerRefusesDamagedMessages(t *testing.T) { + layout := ProfileFor(ModeO5).Layout + body := make([]byte, 600) + rand.New(rand.NewSource(1)).Read(body) + packets := layout.Split(body) + + t.Run("a flipped bit fails the checksum", func(t *testing.T) { + broken := append([]Packet(nil), packets...) + damaged := append(Packet(nil), broken[1]...) + damaged[5] ^= 0xff + broken[1] = damaged + + joiner, err := NewJoiner(broken[0], layout) + if err != nil { + t.Fatal(err) + } + for i := 1; i < len(broken); i++ { + if err := joiner.Accumulate(broken[i]); err != nil { + return // refused on the way in, which is fine + } + } + if _, err := joiner.Finalize(); err == nil { + t.Error("a flipped bit was accepted") + } + }) + + t.Run("a packet out of order is refused", func(t *testing.T) { + joiner, err := NewJoiner(packets[0], layout) + if err != nil { + t.Fatal(err) + } + if err := joiner.Accumulate(packets[len(packets)-1]); err == nil { + t.Error("a packet arriving out of order was accepted") + } + }) + + t.Run("a message that does not start at zero is refused", func(t *testing.T) { + if _, err := NewJoiner(packets[1], layout); err == nil { + t.Error("a message starting partway through was accepted") + } + }) + + t.Run("a truncated first packet is refused", func(t *testing.T) { + if _, err := NewJoiner(packets[0][:1], layout); err == nil { + t.Error("a one-byte first packet was accepted") + } + }) +} + +// An Omnipod 5 advertises one 128-bit service UUID holding its +// controller's id, and flips the last byte to 02 when it wants attention +// (OmnipodKit's o5ServiceAdvertisementUUID and o5FaultAdvertisementUUID). +func TestO5Advertisement(t *testing.T) { + o5 := ProfileFor(ModeO5) + + unpaired := o5.AdvertisedServiceUUIDs(nil, 0, AdvertState{}) + if len(unpaired) != 1 || unpaired[0] != "CE1F923D-C539-48EA-7300-0AFFFFFFFE00" { + t.Errorf("an unpaired pod advertises %v, want the FFFFFFFE UUID", unpaired) + } + + paired := o5.AdvertisedServiceUUIDs([]byte{0, 0x2a, 0x1c, 0x6d}, 0x002A1C6C, AdvertState{}) + if len(paired) != 1 || paired[0] != "CE1F923D-C539-48EA-7300-0A002A1C6C00" { + t.Errorf("a paired pod advertises %v, want its controller's UUID", paired) + } + + faulted := o5.AdvertisedServiceUUIDs(nil, 0x002A1C6C, AdvertState{Fault: 0x14}) + if len(faulted) != 1 || faulted[0] != "CE1F923D-C539-48EA-7300-0A002A1C6C02" { + t.Errorf("a faulted pod advertises %v, want the …02 UUID", faulted) + } + + // A DASH still advertises its nine 16-bit UUIDs. + dash := ProfileFor(ModeDash).AdvertisedServiceUUIDs([]byte{0, 0x2a, 0x1c, 0x6d}, 0, AdvertState{}) + if len(dash) != 9 || dash[0] != "4024" { + t.Errorf("a DASH advertises %v, want nine UUIDs starting 4024", dash) + } +} + +// The controller id comes from the HELLO the phone writes before anything +// else (OmnipodKit's PeripheralManager.sendHello). +func TestPdmIdFromHello(t *testing.T) { + hello, _ := hex.DecodeString("0601" + "04" + "002a1c6c") + got, ok := O5PdmIdFromHello(hello) + if !ok || got != 0x002A1C6C { + t.Errorf("O5PdmIdFromHello = %08x, %v; want 002a1c6c, true", got, ok) + } + for _, short := range []string{"", "06", "060104002a1c"} { + b, _ := hex.DecodeString(short) + if _, ok := O5PdmIdFromHello(b); ok { + t.Errorf("O5PdmIdFromHello(%q) should not have read an id", short) + } + } + if _, ok := O5PdmIdFromHello([]byte{0x04}); ok { + t.Error("a SUCCESS byte should not read as a HELLO") + } +} + +func ExampleMode_Description() { + fmt.Println(ModeDash.Description(), "/", ModeO5.Description()) + // Output: Omnipod DASH / Omnipod 5 +} diff --git a/pkg/bluetooth/profile.go b/pkg/bluetooth/profile.go new file mode 100644 index 0000000..a456a54 --- /dev/null +++ b/pkg/bluetooth/profile.go @@ -0,0 +1,137 @@ +package bluetooth + +import ( + "encoding/binary" + "fmt" +) + +// Mode is the kind of pod being simulated. It decides the BLE profile, the +// pairing exchange and the product id in the pod's version responses. +type Mode string + +const ( + ModeDash Mode = "dash" + ModeO5 Mode = "o5" +) + +// ParseMode reads a -mode value. +func ParseMode(s string) (Mode, error) { + switch Mode(s) { + case ModeDash: + return ModeDash, nil + case ModeO5: + return ModeO5, nil + } + return "", fmt.Errorf("mode %q: want dash or o5", s) +} + +// Description names the mode the way the control panel and the log do. +func (m Mode) Description() string { + if m == ModeO5 { + return "Omnipod 5" + } + return "Omnipod DASH" +} + +// IsO5 says whether this is an Omnipod 5. +func (m Mode) IsO5() bool { return m == ModeO5 } + +// Profile is what the two pod kinds do differently at the link layer +// (OmnipodKit's BlePodProfile). +type Profile struct { + Mode Mode + Layout PacketLayout + + ServiceUUID string + CmdCharUUID string + DataCharUUID string +} + +const ( + podServiceUUID = "1A7E4024-E3ED-4464-8B7E-751E03D0DC5F" + cmdCharUUID = "1A7E2441-E3ED-4464-8B7E-751E03D0DC5F" + dashDataCharUUID = "1A7E2442-E3ED-4464-8B7E-751E03D0DC5F" + o5DataCharUUID = "1A7E2443-E3ED-4464-8B7E-751E03D0DC5F" +) + +// ProfileFor is the profile a mode runs on. +func ProfileFor(m Mode) Profile { + if m.IsO5() { + return Profile{ + Mode: ModeO5, + // An Omnipod 5 fills a 247-byte ATT MTU, and skips the + // RTS/CTS handshake a DASH does before every message. + Layout: PacketLayout{ + MaxPayloadSize: 244, + MaxFragments: 15, + FirstHeaderWithoutMiddle: 7, + FirstHeaderWithMiddle: 2, + LastHeader: 6, + }, + ServiceUUID: podServiceUUID, + CmdCharUUID: cmdCharUUID, + DataCharUUID: o5DataCharUUID, + } + } + return Profile{ + Mode: ModeDash, + Layout: PacketLayout{ + MaxPayloadSize: 20, + MaxFragments: 15, + FirstHeaderWithoutMiddle: 7, + FirstHeaderWithMiddle: 2, + LastHeader: 6, + }, + ServiceUUID: podServiceUUID, + CmdCharUUID: cmdCharUUID, + DataCharUUID: dashDataCharUUID, + } +} + +// UsesRTS says whether a message exchange opens with RTS/CTS on the +// command characteristic. Only a DASH does; an Omnipod 5 writes its +// packets straight away and answers a finished message with SUCCESS +// (PeripheralManager.sendMessagePacket / readMessagePacket). +func (p Profile) UsesRTS() bool { return !p.Mode.IsO5() } + +// O5NotPairedPdmId is the controller id an unpaired Omnipod 5 advertises. +const O5NotPairedPdmId uint32 = 0xFFFFFFFE + +// o5AdvertisementUUID is the single service UUID an Omnipod 5 advertises: +// its controller's id, and a trailing status byte that goes from 00 to 02 +// when the pod wants attention (o5ServiceAdvertisementUUID, +// o5FaultAdvertisementUUID). +func o5AdvertisementUUID(pdmId uint32, attention bool) string { + status := "00" + if attention { + status = "02" + } + return fmt.Sprintf("CE1F923D-C539-48EA-7300-0A%08X%s", pdmId, status) +} + +// AdvertisedServiceUUIDs is what the pod puts in kCBAdvDataServiceUUIDs: +// one 128-bit UUID for an Omnipod 5, and the nine 16-bit ones a DASH +// advertises. +func (p Profile) AdvertisedServiceUUIDs(podId []byte, pdmId uint32, state AdvertState) []string { + if p.Mode.IsO5() { + id := O5NotPairedPdmId + if pdmId != 0 { + id = pdmId + } + return []string{o5AdvertisementUUID(id, state.Fault != 0)} + } + var uuids []string + for _, u := range AdvertisedServices(podId, state) { + uuids = append(uuids, fmt.Sprintf("%04X", u)) + } + return uuids +} + +// O5PdmIdFromHello reads the controller id out of the phone's HELLO +// (PeripheralManager.sendHello writes 06 01 04 then the 4-byte id). +func O5PdmIdFromHello(cmd []byte) (uint32, bool) { + if len(cmd) < 7 || cmd[0] != 0x06 { + return 0, false + } + return binary.BigEndian.Uint32(cmd[3:7]), true +} diff --git a/pkg/crc/crc.go b/pkg/crc/crc.go index 15db47d..a19138f 100644 --- a/pkg/crc/crc.go +++ b/pkg/crc/crc.go @@ -1,6 +1,59 @@ package crc +// Omnipod messages carry two checksums, and neither is a textbook one. + +// crc16Table is the table OmnipodKit's crc16Table holds, built MSB-first +// from the polynomial 0x8005 but used by CRC16 LSB-first. The combination +// is what the pods do, so it is what the phone checks. +var crc16Table = func() [256]uint16 { + var t [256]uint16 + for i := range t { + c := uint16(i) << 8 + for bit := 0; bit < 8; bit++ { + if c&0x8000 != 0 { + c = c<<1 ^ 0x8005 + } else { + c <<= 1 + } + } + t[i] = c + } + return t +}() + +// CRC16Sum is the checksum of an Omnipod message body +// (OmnipodCommon/CRC16.swift, Sequence.crc16). +func CRC16Sum(data []byte) uint16 { + var acc uint16 + for _, b := range data { + acc = acc>>8 ^ crc16Table[(acc^uint16(b))&0xff] + } + return acc +} + +// CRC16 is CRC16Sum big-endian, as it goes on the wire. +// +// Omnipod 5 pods compute this and the phone rejects a response whose value +// is wrong (BleMessageTransport.parseResponse checks it for an O5 pod and +// not for a DASH, whose own value the DASH PDM ignores). func CRC16(data []byte) []byte { - // TODO implement this. Strange that it's not used! - return []byte{0, 0} + sum := CRC16Sum(data) + return []byte{byte(sum >> 8), byte(sum)} +} + +// XMODEM is CRC-16/XMODEM: the polynomial 0x1021 MSB-first from zero. The +// Omnipod 5 SPS0 pairing payload carries it (O5LTKExchanger.crc16XMODEM). +func XMODEM(data []byte) uint16 { + var crc uint16 + for _, b := range data { + crc ^= uint16(b) << 8 + for bit := 0; bit < 8; bit++ { + if crc&0x8000 != 0 { + crc = crc<<1 ^ 0x1021 + } else { + crc <<= 1 + } + } + } + return crc } diff --git a/pkg/crc/crc_test.go b/pkg/crc/crc_test.go new file mode 100644 index 0000000..567252f --- /dev/null +++ b/pkg/crc/crc_test.go @@ -0,0 +1,43 @@ +package crc + +import ( + "encoding/hex" + "testing" +) + +// The vector OmnipodKit's CRC16Tests checks. +func TestCRC16Sum(t *testing.T) { + data, _ := hex.DecodeString("1f01482a10030e0100") + if got := CRC16Sum(data); got != 0x802c { + t.Errorf("CRC16Sum = %04x, want 802c", got) + } +} + +// Real pod messages carry their checksum in the last two bytes, so +// computing it over the rest has to give those bytes back. These are the +// messages in OmnipodKit's MessageTests. +func TestCRC16OverCapturedMessages(t *testing.T) { + for _, encoded := range []string{ + "1f00ee84300a1d18003f1800004297ff8128", + "ffffffff041d011b13881008340a5002070002070002030000a62b000447941f00ee878352", + "ffffffff0c1d011b13881008340a50031b0008080004030812a011000c175717244389816c", + "ffffffff04170115020700020700020e0000a5ad00053030971f08686301fd", + } { + msg, err := hex.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + body, want := msg[:len(msg)-2], msg[len(msg)-2:] + if got := CRC16(body); hex.EncodeToString(got) != hex.EncodeToString(want) { + t.Errorf("CRC16(%s...) = %x, want %x", encoded[:12], got, want) + } + } +} + +// The SPS0 pairing payload carries CRC-16/XMODEM, whose check value over +// "123456789" is the published 0x31c3. +func TestXMODEM(t *testing.T) { + if got := XMODEM([]byte("123456789")); got != 0x31c3 { + t.Errorf("XMODEM = %04x, want 31c3", got) + } +} diff --git a/pkg/message/message.go b/pkg/message/message.go index 7dc4b0b..a7c0c5c 100644 --- a/pkg/message/message.go +++ b/pkg/message/message.go @@ -14,7 +14,13 @@ const ( MessageTypeEncrypted MessageTypeSessionEstablishment MessageTypePairing - MagicPattern = "TW" + // MessageTypeEncryptedSigned carries an ECDSA signature after the + // payload. Omnipod 5 pods require it for the commands that deliver or + // stop insulin (OmnipodKit's MessageType.ENCRYPTED_SIGNED). + MessageTypeEncryptedSigned + + MagicPattern = "TW" + SignatureLength = 64 // ECDSA P-256, r||s ) // Message is one CTwiPacket @@ -29,12 +35,15 @@ type Message struct { Source []byte Destination []byte Payload []byte - Raw []byte - Sas bool - Tfs bool - SequenceNumber uint8 - AckNumber uint8 - Version uint16 + // Signature is the 64 bytes after the payload of a signed message. It + // is not counted in the header's length field. + Signature []byte + Raw []byte + Sas bool + Tfs bool + SequenceNumber uint8 + AckNumber uint8 + Version uint16 } type flag byte @@ -69,7 +78,7 @@ 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.HasTag() && m.EncryptedPayload { // Already encrypted return m.Raw, nil } @@ -111,6 +120,9 @@ func (m *Message) Marshal() ([]byte, error) { if m.Payload != nil { buf.Write(m.Payload) } + if m.Signature != nil { + buf.Write(m.Signature) + } ret := make([]byte, buf.Len()) copy(ret, buf.Bytes()) @@ -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,18 +163,51 @@ func Unmarshal(data []byte) (*Message, error) { } ret.SequenceNumber = data[4] ret.AckNumber = data[5] - var n = data[6]<<3 | data[7]>>5 + // The length is 11 bits across two bytes, so it has to be read wider + // than a byte: an Omnipod 5 pairing message runs past 1000 bytes. + 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) } ret.Source = data[8:12] ret.Destination = data[12:16] - if ret.Type == MessageTypeEncrypted { - ret.Payload = data[16 : 16+n+8] - ret.EncryptedPayload = true - } else { + if !ret.HasTag() { ret.Payload = data[16 : 16+n] + return ret, nil + } + + // An encrypted payload is followed by its 8-byte authentication tag, + // which the length field leaves out. + end := 16 + int(n) + 8 + if end > len(data) { + return nil, fmt.Errorf("message claims %d encrypted bytes but holds %d in %x", int(n)+8, len(data)-16, data) + } + ret.Payload = data[16:end] + ret.EncryptedPayload = true + + if ret.Type == MessageTypeEncryptedSigned { + if end+SignatureLength > len(data) { + return nil, fmt.Errorf("signed message has %d bytes after its payload, want %d, in %x", len(data)-end, SignatureLength, data) + } + ret.Signature = data[end : end+SignatureLength] } return ret, nil } + +// HasTag says whether the payload ends in an AES-CCM authentication tag +// that the header's length field does not count. +func (m *Message) HasTag() bool { + return m.Type == MessageTypeEncrypted || m.Type == MessageTypeEncryptedSigned +} + +// SigningInput is what a signed message's signature covers: the 16-byte +// header as it goes on the wire, then the encrypted payload and its tag +// (OmnipodKit's BlePodMessageTransport.getCmdMessage). +func (m *Message) SigningInput() []byte { + if len(m.Raw) < 16 { + return nil + } + out := append([]byte(nil), m.Raw[:16]...) + return append(out, m.Payload...) +} diff --git a/pkg/message/message_test.go b/pkg/message/message_test.go index f6715fe..e9074a7 100644 --- a/pkg/message/message_test.go +++ b/pkg/message/message_test.go @@ -26,3 +26,132 @@ func Test_PrintfromByteArray(t *testing.T) { spew.Dump(msg) } } + +// An Omnipod 5 pairing message runs past 1000 bytes, and the length field +// holding that is 11 bits spread over two header bytes. Reading it a byte +// at a time silently wraps, which is how a 1089-byte SPS2 would come back +// as nonsense. +func TestLongPayloadLength(t *testing.T) { + for _, size := range []int{0, 1, 20, 255, 256, 642, 1089, 2047} { + msg := NewMessage(MessageTypePairing, []byte{0, 0x2a, 0x1c, 0x6c}, []byte{0xff, 0xff, 0xff, 0xfe}) + msg.Payload = make([]byte, size) + for i := range msg.Payload { + msg.Payload[i] = byte(i) + } + raw, err := msg.Marshal() + if err != nil { + t.Fatalf("%d bytes: %v", size, err) + } + + back, err := Unmarshal(raw) + if err != nil { + t.Fatalf("%d bytes: %v", size, err) + } + if len(back.Payload) != size { + t.Errorf("%d bytes came back as %d", size, len(back.Payload)) + continue + } + if hex.EncodeToString(back.Payload) != hex.EncodeToString(msg.Payload) { + t.Errorf("%d bytes came back changed", size) + } + } +} + +// wireMessage builds the bytes a message has on the wire. An encrypted +// payload is the ciphertext followed by its 8-byte tag, and the header's +// length field counts only the ciphertext: EncryptMessage marshals the +// plaintext message and then appends the tag to Raw, so the length that +// went into the header is the plaintext's. +func wireMessage(t *testing.T, msgType MessageType, ciphertext, tag, signature []byte) []byte { + t.Helper() + size := len(ciphertext) + header := []byte{ + 'T', 'W', + 0x00, byte(msgType), + 0x00, 0x00, + byte(size >> 3), byte(size << 5), + 0x00, 0x2a, 0x1c, 0x6d, + 0x00, 0x2a, 0x1c, 0x6c, + } + out := append(append([]byte(nil), header...), ciphertext...) + out = append(out, tag...) + return append(out, signature...) +} + +// A signed message carries 64 bytes of signature after the payload, and +// the length field counts neither those nor the payload's 8-byte tag +// (OmnipodKit's MessagePacket.asData and getCmdMessage). +func TestSignedMessage(t *testing.T) { + ciphertext := make([]byte, 40) + for i := range ciphertext { + ciphertext[i] = byte(i) + } + tag := []byte{0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7} + signature := make([]byte, SignatureLength) + for i := range signature { + signature[i] = byte(0xA0 + i%16) + } + raw := wireMessage(t, MessageTypeEncryptedSigned, ciphertext, tag, signature) + + back, err := Unmarshal(raw) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if back.Type != MessageTypeEncryptedSigned { + t.Errorf("type is %d, want %d", back.Type, MessageTypeEncryptedSigned) + } + if !back.EncryptedPayload { + t.Error("a signed message should carry an encrypted payload") + } + if hex.EncodeToString(back.Signature) != hex.EncodeToString(signature) { + t.Errorf("the signature came back as %x", back.Signature) + } + // The payload is the ciphertext and its tag, without the signature. + wantPayload := append(append([]byte(nil), ciphertext...), tag...) + if hex.EncodeToString(back.Payload) != hex.EncodeToString(wantPayload) { + t.Errorf("the payload came back as %d bytes: %x", len(back.Payload), back.Payload) + } + + // The signature covers the header and the payload, and nothing else. + input := back.SigningInput() + if want := 16 + len(wantPayload); len(input) != want { + t.Fatalf("the signing input is %d bytes, want %d", len(input), want) + } + if hex.EncodeToString(input) != hex.EncodeToString(raw[:16+len(wantPayload)]) { + t.Error("the signing input is not the header followed by the payload") + } +} + +// A signed message that stops short of its signature has to be refused +// rather than read past its end. +func TestSignedMessageTruncated(t *testing.T) { + raw := wireMessage(t, MessageTypeEncryptedSigned, make([]byte, 24), make([]byte, 8), make([]byte, SignatureLength)) + for _, cut := range []int{1, SignatureLength / 2, SignatureLength} { + if _, err := Unmarshal(raw[:len(raw)-cut]); err == nil { + t.Errorf("a message %d bytes short of its signature was accepted", cut) + } + } +} + +// An unsigned encrypted message still parses as it always did. +func TestEncryptedMessageUnchanged(t *testing.T) { + raw := wireMessage(t, MessageTypeEncrypted, make([]byte, 16), make([]byte, 8), nil) + back, err := Unmarshal(raw) + if err != nil { + t.Fatal(err) + } + if back.Signature != nil { + t.Error("an unsigned message came back with a signature") + } + if !back.EncryptedPayload || len(back.Payload) != 24 { + t.Errorf("the payload came back as %d bytes", len(back.Payload)) + } +} + +// A message type the pod does not know must be refused. +func TestUnknownMessageType(t *testing.T) { + raw := wireMessage(t, MessageType(5), make([]byte, 8), nil, nil) + if _, err := Unmarshal(raw); err == nil { + t.Error("a message of an unknown type was accepted") + } +} diff --git a/pkg/pair/o5.go b/pkg/pair/o5.go new file mode 100644 index 0000000..dd9f108 --- /dev/null +++ b/pkg/pair/o5.go @@ -0,0 +1,532 @@ +package pair + +import ( + "bytes" + "crypto/aes" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "fmt" + "math/big" + "time" + + "github.com/avereha/pod/pkg/crc" + "github.com/avereha/pod/pkg/message" + + aesccm "github.com/pschlump/AesCCM" + log "github.com/sirupsen/logrus" +) + +// The Omnipod 5 pairing exchange, from the pod's side. OmnipodKit runs the +// phone's side in O5LTKExchanger and O5KeyExchange; this follows those. +// +// It replaces the DASH exchange's Curve25519 and CMAC with P-256 ECDH, a +// SHA-256 key derivation, and two rounds in which each side shows an X.509 +// certificate and signs what both have said so far. Neither side validates +// the other's certificate chain — the phone reads the public key out of the +// pod's certificate to check its signature, and the pod does the same with +// the phone's — so a self-signed certificate pairs with this simulator +// while a real pod would still want Insulet's. + +const ( + o5sps0 = "SPS0=" + o5sps1 = "SPS1=" + o5sps21 = "SPS2.1=" + o5sps2 = "SPS2=" + o5p0 = "P0=" + o5sp0gp0 = "SP0,GP0" +) + +const ( + publicKeySize = 64 // a P-256 point as X||Y, without the 04 prefix + nonceSize = 16 + spsNonceSize = 13 + spsTagSize = 8 + signatureSize = 64 // ECDSA P-256 as r||s + transcriptLen = 171 +) + +// firmwareID is the fixed 6-byte value the PDM firmware mixes into the key +// derivation and into both signed transcripts (O5LTKExchanger.FIRMWARE_ID). +var firmwareID = []byte{0x9b, 0x0a, 0xb9, 0x6a, 0x76, 0xf4} + +// O5 is one Omnipod 5 pairing exchange. +type O5 struct { + podID []byte + pdmID []byte + + ecdhPrivate *ecdh.PrivateKey + podPublic []byte + podNonce []byte + pdmPublic []byte + pdmNonce []byte + + conf []byte // the key the SPS2.1 and SPS2 payloads are encrypted under + ltk []byte + nonces *spsNonces + + // identity is the key the pod's certificate carries and signs with. + identity *ecdsa.PrivateKey + certDER []byte + + // controllerPublic is the phone's signing key, read out of the + // certificate it shows in SPS2. Signed commands are checked against it. + controllerPublic []byte +} + +// NewO5 makes a pod identity for one pairing: an ECDH key for the key +// exchange and a self-signed certificate for the two certificate rounds. +func NewO5() (*O5, error) { + ecdhKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("could not make the pod's ECDH key: %w", err) + } + identity, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("could not make the pod's signing key: %w", err) + } + certDER, err := selfSignedCert(identity, "pod simulator pod") + if err != nil { + return nil, err + } + nonce := make([]byte, nonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return &O5{ + ecdhPrivate: ecdhKey, + podPublic: ecdhKey.PublicKey().Bytes()[1:], // drop the 04 prefix + podNonce: nonce, + identity: identity, + certDER: certDER, + }, nil +} + +// LTK is the long-term key the exchange produced. +func (c *O5) LTK() ([]byte, error) { + if len(c.ltk) != 16 { + return nil, fmt.Errorf("the pairing produced no long-term key") + } + return c.ltk, nil +} + +// ControllerPublic is the phone's signing key, for checking the signature +// on the commands that must carry one. +func (c *O5) ControllerPublic() []byte { return c.controllerPublic } + +// PodID is the id the phone assigned the pod in SP1. +func (c *O5) PodID() []byte { return c.podID } + +// PdmID is the controller's id. +func (c *O5) PdmID() []byte { return c.pdmID } + +// ParseSP1SP2 reads the first pairing message, which names the id the pod +// is to take (SP1) and carries a status command for it (SP2). +func (c *O5) ParseSP1SP2(msg *message.Message) error { + sp, err := parseStringByte([]string{sp1, sp2}, msg.Payload) + if err != nil { + return err + } + if len(sp[sp1]) != 4 { + return fmt.Errorf("SP1 holds %d bytes, want the pod's 4-byte id", len(sp[sp1])) + } + c.podID = append([]byte(nil), sp[sp1]...) + c.pdmID = append([]byte(nil), msg.Source...) + log.Infof("pkg pair; Omnipod 5 pairing for pod %x from controller %x", c.podID, c.pdmID) + return nil +} + +// ParseSPS0 checks the phone's SPS0, which names the encryption algorithm. +func (c *O5) ParseSPS0(msg *message.Message) error { + sp, err := parseStringByte([]string{o5sps0}, msg.Payload) + if err != nil { + return err + } + return checkSPS0(sp[o5sps0], true) +} + +// GenerateSPS0 answers with the pod's side of SPS0. +func (c *O5) GenerateSPS0() (*message.Message, error) { + return c.pairMessage(o5sps0, sps0Payload(false)) +} + +// ParseSPS1 takes the phone's public key and nonce, and derives the keys. +func (c *O5) ParseSPS1(msg *message.Message) error { + sp, err := parseStringByte([]string{o5sps1}, msg.Payload) + if err != nil { + return err + } + payload := sp[o5sps1] + if len(payload) != publicKeySize+nonceSize { + return fmt.Errorf("SPS1 holds %d bytes, want %d", len(payload), publicKeySize+nonceSize) + } + c.pdmPublic = append([]byte(nil), payload[:publicKeySize]...) + c.pdmNonce = append([]byte(nil), payload[publicKeySize:]...) + + peer, err := ecdh.P256().NewPublicKey(append([]byte{0x04}, c.pdmPublic...)) + if err != nil { + return fmt.Errorf("the phone's public key is not a P-256 point: %w", err) + } + shared, err := c.ecdhPrivate.ECDH(peer) + if err != nil { + return fmt.Errorf("could not agree a shared secret: %w", err) + } + c.conf, c.ltk, err = deriveKeys(c.pdmPublic, c.podPublic, shared) + if err != nil { + return err + } + c.nonces, err = newSPSNonces(c.pdmNonce, c.podNonce) + if err != nil { + return err + } + log.Debugf("pkg pair; derived conf %x and LTK %x", c.conf, c.ltk) + return nil +} + +// GenerateSPS1 sends the pod's public key and nonce. +func (c *O5) GenerateSPS1() (*message.Message, error) { + return c.pairMessage(o5sps1, append(append([]byte(nil), c.podPublic...), c.podNonce...)) +} + +// ParseSPS21 reads the phone's first certificate. Its contents are not +// checked: the phone's signing key arrives with SPS2, and no chain is +// validated on either side. +func (c *O5) ParseSPS21(msg *message.Message) error { + sp, err := parseStringByte([]string{o5sps21}, msg.Payload) + if err != nil { + return err + } + plain, err := c.nonces.open(c.conf, spsPhoneToPod, sp[o5sps21]) + if err != nil { + return fmt.Errorf("could not decrypt SPS2.1: %w", err) + } + log.Debugf("pkg pair; the phone's SPS2.1 certificate is %d bytes", len(plain)) + return nil +} + +// GenerateSPS21 shows the pod's certificate. +func (c *O5) GenerateSPS21() (*message.Message, error) { + sealed, err := c.nonces.seal(c.conf, spsPodToPhone, c.certDER) + if err != nil { + return nil, fmt.Errorf("could not encrypt SPS2.1: %w", err) + } + return c.pairMessage(o5sps21, sealed) +} + +// ParseSPS2 reads the phone's certificate and its signature over +// everything both sides have said, and checks that signature. +func (c *O5) ParseSPS2(msg *message.Message) error { + sp, err := parseStringByte([]string{o5sps2}, msg.Payload) + if err != nil { + return err + } + + // The phone signed before the write that carries the signature, so its + // transcript holds the nonces as they are now, before open advances them. + transcript := c.phoneTranscript() + + plain, err := c.nonces.open(c.conf, spsPhoneToPod, sp[o5sps2]) + if err != nil { + return fmt.Errorf("could not decrypt SPS2: %w", err) + } + if len(plain) <= signatureSize { + return fmt.Errorf("SPS2 holds %d bytes, too few for a certificate and a signature", len(plain)) + } + certDER := plain[:len(plain)-signatureSize] + signature := plain[len(plain)-signatureSize:] + + public, err := publicKeyFromCert(certDER) + if err != nil { + return fmt.Errorf("could not read the phone's certificate: %w", err) + } + c.controllerPublic = public + + if VerifyP256(public, transcript, signature) { + log.Infof("pkg pair; the phone's SPS2 signature verifies") + } else { + // OmnipodKit logs its own mismatch and carries on, so the pod does + // the same rather than refusing a pairing the phone thinks worked. + log.Warnf("pkg pair; the phone's SPS2 signature does NOT verify over %d bytes", len(transcript)) + } + return nil +} + +// GenerateSPS2 shows the pod's certificate and signs the exchange. +func (c *O5) GenerateSPS2() (*message.Message, error) { + signature, err := signP256(c.identity, c.podTranscript()) + if err != nil { + return nil, err + } + plain := append(append([]byte(nil), c.certDER...), signature...) + sealed, err := c.nonces.seal(c.conf, spsPodToPhone, plain) + if err != nil { + return nil, fmt.Errorf("could not encrypt SPS2: %w", err) + } + return c.pairMessage(o5sps2, sealed) +} + +// ParseSP0GP0 reads the message that ends the exchange. +func (c *O5) ParseSP0GP0(msg *message.Message) error { + if string(msg.Payload) != o5sp0gp0 { + return fmt.Errorf("expected SP0,GP0, got %x", msg.Payload) + } + return nil +} + +// GenerateP0 sends the fixed byte the phone waits for last. +func (c *O5) GenerateP0() (*message.Message, error) { + return c.pairMessage(o5p0, []byte{0xa5}) +} + +// pairMessage wraps a pairing payload under its key, from the pod to the +// phone. +func (c *O5) pairMessage(key string, payload []byte) (*message.Message, error) { + msg := message.NewMessage(message.MessageTypePairing, c.podID, c.pdmID) + body, err := buildStringByte([]string{key}, map[string][]byte{key: payload}) + if err != nil { + return nil, err + } + msg.Payload = body + return msg, nil +} + +// SPS0 is five bytes: 00, who is writing (01 the phone, 00 the pod), the +// algorithm 09, and the CRC-16/XMODEM of those three +// (O5LTKExchanger.o5sps0 and validatePodO5sps0). +func sps0Payload(fromPhone bool) []byte { + direction := byte(0) + if fromPhone { + direction = 1 + } + header := []byte{0x00, direction, 0x09} + sum := crc.XMODEM(header) + return append(header, byte(sum>>8), byte(sum)) +} + +func checkSPS0(payload []byte, fromPhone bool) error { + want := sps0Payload(fromPhone) + if !bytes.Equal(payload, want) { + return fmt.Errorf("SPS0 is %x, want %x", payload, want) + } + return nil +} + +// deriveKeys hashes the exchange into the conf key and the LTK. Each field +// goes in behind its length as a 64-bit big-endian number +// (O5KeyExchange.o5generateKeys). +func deriveKeys(pdmPublic, podPublic, shared []byte) (conf, ltk []byte, err error) { + if len(pdmPublic) != publicKeySize || len(podPublic) != publicKeySize { + return nil, nil, fmt.Errorf("public keys are %d and %d bytes, want %d", len(pdmPublic), len(podPublic), publicKeySize) + } + if len(shared) != 32 { + return nil, nil, fmt.Errorf("the shared secret is %d bytes, want 32", len(shared)) + } + var buf bytes.Buffer + field := func(b []byte) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(b))) + buf.Write(length[:]) + buf.Write(b) + } + field(firmwareID) + field([]byte{0, 0, 0, 0}) + field(pdmPublic) + field(podPublic) + field(shared) + + sum := sha256.Sum256(buf.Bytes()) + return append([]byte(nil), sum[:16]...), append([]byte(nil), sum[16:]...), nil +} + +// phoneTranscript is what the phone signs in SPS2 +// (O5KeyExchange.buildChannelBindingTranscript). +func (c *O5) phoneTranscript() []byte { + out := make([]byte, 0, transcriptLen) + out = append(out, 0x01) + out = append(out, firmwareID...) + out = append(out, 0, 0, 0, 0) + out = append(out, c.pdmPublic...) + out = append(out, c.podPublic...) + out = append(out, c.nonces.pdm...) + out = append(out, c.nonces.pod...) + return out +} + +// podTranscript is what the pod signs in SPS2. The phone rebuilds it in +// buildPodChannelBindingTranscript: the two fixed fields swap places, each +// side's key and nonce swap, and the nonces are the ones the pod holds at +// this point in the exchange. +func (c *O5) podTranscript() []byte { + out := make([]byte, 0, transcriptLen) + out = append(out, 0x02) + out = append(out, 0, 0, 0, 0) + out = append(out, firmwareID...) + out = append(out, c.podPublic...) + out = append(out, c.pdmPublic...) + out = append(out, c.nonces.pod...) + out = append(out, c.nonces.pdm...) + return out +} + +// spsDirection says who is writing a message encrypted under conf. +type spsDirection byte + +const ( + spsPhoneToPod spsDirection = 0x01 // OmnipodKit's Direction.write + spsPodToPhone spsDirection = 0x02 // OmnipodKit's Direction.read +) + +// spsNonces holds the two 16-byte nonces from SPS1. Each side's first +// eight bytes are a little-endian counter that goes up by one after every +// message that side writes (O5KeyExchange.getSPSNonce, incrementNonce). +type spsNonces struct { + pdm []byte + pod []byte +} + +func newSPSNonces(pdmNonce, podNonce []byte) (*spsNonces, error) { + if len(pdmNonce) != nonceSize || len(podNonce) != nonceSize { + return nil, fmt.Errorf("nonces are %d and %d bytes, want %d", len(pdmNonce), len(podNonce), nonceSize) + } + return &spsNonces{ + pdm: append([]byte(nil), pdmNonce...), + pod: append([]byte(nil), podNonce...), + }, nil +} + +// nonce is the 13-byte AES-CCM nonce: the direction, then six bytes of the +// writer's nonce and six of the reader's. +func (n *spsNonces) nonce(dir spsDirection) []byte { + out := make([]byte, 0, spsNonceSize) + out = append(out, byte(dir)) + if dir == spsPhoneToPod { + out = append(out, n.pdm[:6]...) + return append(out, n.pod[:6]...) + } + out = append(out, n.pod[:6]...) + return append(out, n.pdm[:6]...) +} + +func (n *spsNonces) increment(dir spsDirection) { + target := n.pdm + if dir == spsPodToPhone { + target = n.pod + } + binary.LittleEndian.PutUint64(target[:8], binary.LittleEndian.Uint64(target[:8])+1) +} + +func spsCipher(conf, nonce []byte) (aesccm.CCM, error) { + block, err := aes.NewCipher(conf) + if err != nil { + return nil, err + } + return aesccm.NewCCM(block, spsTagSize, len(nonce)) +} + +// seal encrypts a pairing payload for dir and advances that side's nonce. +// These payloads carry no associated data, unlike session messages. +func (n *spsNonces) seal(conf []byte, dir spsDirection, plaintext []byte) ([]byte, error) { + nonce := n.nonce(dir) + c, err := spsCipher(conf, nonce) + if err != nil { + return nil, err + } + out := c.Seal(nil, nonce, plaintext, nil) + n.increment(dir) + return out, nil +} + +// open decrypts a pairing payload written for dir and advances that side's +// nonce. +func (n *spsNonces) open(conf []byte, dir spsDirection, ciphertext []byte) ([]byte, error) { + if len(ciphertext) <= spsTagSize { + return nil, fmt.Errorf("%d bytes is too few to hold a tag", len(ciphertext)) + } + nonce := n.nonce(dir) + c, err := spsCipher(conf, nonce) + if err != nil { + return nil, err + } + out, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + n.increment(dir) + return out, nil +} + +// signP256 signs the SHA-256 of data and returns r||s, the form CryptoKit's +// P256.Signing.ECDSASignature(rawRepresentation:) reads. +func signP256(key *ecdsa.PrivateKey, data []byte) ([]byte, error) { + digest := sha256.Sum256(data) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return nil, fmt.Errorf("could not sign: %w", err) + } + out := make([]byte, signatureSize) + r.FillBytes(out[:32]) + s.FillBytes(out[32:]) + return out, nil +} + +// VerifyP256 checks an r||s signature over the SHA-256 of data against a +// raw X||Y public key. +func VerifyP256(publicKey, data, signature []byte) bool { + if len(publicKey) != publicKeySize || len(signature) != signatureSize { + return false + } + key := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(publicKey[:32]), + Y: new(big.Int).SetBytes(publicKey[32:]), + } + if !key.Curve.IsOnCurve(key.X, key.Y) { + return false + } + digest := sha256.Sum256(data) + return ecdsa.Verify(key, digest[:], + new(big.Int).SetBytes(signature[:32]), + new(big.Int).SetBytes(signature[32:])) +} + +// publicKeyFromCert reads the P-256 public key out of a DER certificate as +// raw X||Y. +func publicKeyFromCert(der []byte) ([]byte, error) { + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + key, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok || key.Curve != elliptic.P256() { + return nil, fmt.Errorf("the certificate does not carry a P-256 key") + } + out := make([]byte, publicKeySize) + key.X.FillBytes(out[:32]) + key.Y.FillBytes(out[32:]) + return out, nil +} + +// selfSignedCert makes the certificate a side shows during pairing. The +// phone finds the public key by looking for the standard P-256 +// SubjectPublicKeyInfo bytes, which this layout gives it +// (O5CertificateStore.extractP256PublicKey). +func selfSignedCert(key *ecdsa.PrivateKey, commonName string) ([]byte, error) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"pod simulator"}, + CommonName: commonName, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + IsCA: true, + BasicConstraintsValid: false, + } + return x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) +} diff --git a/pkg/pair/o5_test.go b/pkg/pair/o5_test.go new file mode 100644 index 0000000..d91c871 --- /dev/null +++ b/pkg/pair/o5_test.go @@ -0,0 +1,335 @@ +package pair + +import ( + "bytes" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "testing" + + "github.com/avereha/pod/pkg/message" +) + +// o5Phone is the phone's side of the Omnipod 5 pairing, written from the +// order OmnipodKit's O5LTKExchanger does things in, so that a mistake +// shared with the pod's side cannot hide: it signs and encrypts from its +// own copy of the rules rather than calling the pod's code. +type o5Phone struct { + t *testing.T + + myID []byte + podID []byte + + ecdhPrivate *ecdh.PrivateKey + public []byte + nonce []byte + + podPublic []byte + podNonce []byte + + conf []byte + ltk []byte + + // the phone tracks each side's nonce as a counter it advances itself + pdmCounter []byte + podCounter []byte + + signing *ecdsa.PrivateKey + certDER []byte + + seq uint8 +} + +func newO5Phone(t *testing.T, myID, podID []byte) *o5Phone { + t.Helper() + ecdhKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signing, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + certDER, err := selfSignedCert(signing, "pod simulator controller") + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, nonceSize) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + return &o5Phone{ + t: t, myID: myID, podID: podID, + ecdhPrivate: ecdhKey, + public: ecdhKey.PublicKey().Bytes()[1:], + nonce: nonce, + signing: signing, + certDER: certDER, + seq: 1, + } +} + +func (p *o5Phone) send(keys []string, payloads [][]byte) *message.Message { + p.t.Helper() + values := map[string][]byte{} + for i, k := range keys { + values[k] = payloads[i] + } + body, err := buildStringByte(keys, values) + if err != nil { + p.t.Fatal(err) + } + msg := message.NewMessage(message.MessageTypePairing, p.myID, []byte{0xff, 0xff, 0xff, 0xfe}) + msg.Payload = body + msg.SequenceNumber = p.seq + p.seq++ + return msg +} + +func (p *o5Phone) read(msg *message.Message, key string) []byte { + p.t.Helper() + fields, err := parseStringByte([]string{key}, msg.Payload) + if err != nil { + p.t.Fatalf("reading %s: %v", key, err) + } + return fields[key] +} + +// deriveFromPod is O5KeyExchange.o5updatePodPublicData plus o5generateKeys. +func (p *o5Phone) deriveFromPod(sps1 []byte) { + p.t.Helper() + if len(sps1) != publicKeySize+nonceSize { + p.t.Fatalf("the pod's SPS1 is %d bytes, want %d", len(sps1), publicKeySize+nonceSize) + } + p.podPublic = sps1[:publicKeySize] + p.podNonce = sps1[publicKeySize:] + + peer, err := ecdh.P256().NewPublicKey(append([]byte{0x04}, p.podPublic...)) + if err != nil { + p.t.Fatal(err) + } + shared, err := p.ecdhPrivate.ECDH(peer) + if err != nil { + p.t.Fatal(err) + } + + var buf bytes.Buffer + field := func(b []byte) { + var l [8]byte + binary.BigEndian.PutUint64(l[:], uint64(len(b))) + buf.Write(l[:]) + buf.Write(b) + } + field(firmwareID) + field([]byte{0, 0, 0, 0}) + field(p.public) + field(p.podPublic) + field(shared) + sum := sha256.Sum256(buf.Bytes()) + p.conf, p.ltk = sum[:16], sum[16:] + + p.pdmCounter = append([]byte(nil), p.nonce...) + p.podCounter = append([]byte(nil), p.podNonce...) +} + +// writeNonce and readNonce are O5KeyExchange.getSPSNonce. +func (p *o5Phone) writeNonce() []byte { + out := []byte{0x01} + out = append(out, p.pdmCounter[:6]...) + return append(out, p.podCounter[:6]...) +} + +func (p *o5Phone) readNonce() []byte { + out := []byte{0x02} + out = append(out, p.podCounter[:6]...) + return append(out, p.pdmCounter[:6]...) +} + +func bump(counter []byte) { + binary.LittleEndian.PutUint64(counter[:8], binary.LittleEndian.Uint64(counter[:8])+1) +} + +func (p *o5Phone) seal(plaintext []byte) []byte { + p.t.Helper() + nonce := p.writeNonce() + c, err := spsCipher(p.conf, nonce) + if err != nil { + p.t.Fatal(err) + } + out := c.Seal(nil, nonce, plaintext, nil) + bump(p.pdmCounter) + return out +} + +func (p *o5Phone) open(ciphertext []byte) []byte { + p.t.Helper() + nonce := p.readNonce() + c, err := spsCipher(p.conf, nonce) + if err != nil { + p.t.Fatal(err) + } + out, err := c.Open(nil, nonce, ciphertext, nil) + if err != nil { + p.t.Fatalf("the pod's message did not decrypt: %v", err) + } + bump(p.podCounter) + return out +} + +// transcript is O5KeyExchange.buildChannelBindingTranscript. +func (p *o5Phone) transcript() []byte { + out := []byte{0x01} + out = append(out, firmwareID...) + out = append(out, 0, 0, 0, 0) + out = append(out, p.public...) + out = append(out, p.podPublic...) + out = append(out, p.pdmCounter...) + out = append(out, p.podCounter...) + return out +} + +// podTranscript is O5KeyExchange.buildPodChannelBindingTranscript: the pod +// signed with its own counter one behind where the phone's now stands. +func (p *o5Phone) podTranscript() []byte { + adjusted := append([]byte(nil), p.podCounter...) + binary.LittleEndian.PutUint64(adjusted[:8], binary.LittleEndian.Uint64(adjusted[:8])-1) + + out := []byte{0x02} + out = append(out, 0, 0, 0, 0) + out = append(out, firmwareID...) + out = append(out, p.podPublic...) + out = append(out, p.public...) + out = append(out, adjusted...) + out = append(out, p.pdmCounter...) + return out +} + +// TestO5PairingAgainstPhone runs the whole exchange and checks that both +// sides end up with the same long-term key and that each side's signature +// verifies under the other's rules. +func TestO5PairingAgainstPhone(t *testing.T) { + myID := []byte{0x00, 0x2a, 0x1c, 0x6c} + podID := []byte{0x00, 0x2a, 0x1c, 0x6d} + + phone := newO5Phone(t, myID, podID) + pod, err := NewO5() + if err != nil { + t.Fatal(err) + } + + // SP1 + SP2 + if err := pod.ParseSP1SP2(phone.send([]string{sp1, sp2}, [][]byte{podID, make([]byte, 11)})); err != nil { + t.Fatalf("SP1SP2: %v", err) + } + if !bytes.Equal(pod.PodID(), podID) { + t.Errorf("the pod took id %x, want %x", pod.PodID(), podID) + } + if !bytes.Equal(pod.PdmID(), myID) { + t.Errorf("the pod read controller %x, want %x", pod.PdmID(), myID) + } + + // SPS0 both ways + if err := pod.ParseSPS0(phone.send([]string{o5sps0}, [][]byte{sps0Payload(true)})); err != nil { + t.Fatalf("SPS0: %v", err) + } + podSPS0, err := pod.GenerateSPS0() + if err != nil { + t.Fatal(err) + } + if err := checkSPS0(phone.read(podSPS0, o5sps0), false); err != nil { + t.Fatalf("the pod's SPS0: %v", err) + } + + // SPS1 both ways, then each side derives the keys + if err := pod.ParseSPS1(phone.send([]string{o5sps1}, [][]byte{append(append([]byte(nil), phone.public...), phone.nonce...)})); err != nil { + t.Fatalf("SPS1: %v", err) + } + podSPS1, err := pod.GenerateSPS1() + if err != nil { + t.Fatal(err) + } + phone.deriveFromPod(phone.read(podSPS1, o5sps1)) + + podLTK, err := pod.LTK() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(podLTK, phone.ltk) { + t.Fatalf("long-term keys differ:\n pod %x\n phone %x", podLTK, phone.ltk) + } + if !bytes.Equal(pod.conf, phone.conf) { + t.Fatalf("conf keys differ:\n pod %x\n phone %x", pod.conf, phone.conf) + } + + // SPS2.1: each side shows a certificate + if err := pod.ParseSPS21(phone.send([]string{o5sps21}, [][]byte{phone.seal(phone.certDER)})); err != nil { + t.Fatalf("SPS2.1: %v", err) + } + podSPS21, err := pod.GenerateSPS21() + if err != nil { + t.Fatal(err) + } + if got := phone.open(phone.read(podSPS21, o5sps21)); len(got) == 0 { + t.Fatal("the pod's SPS2.1 certificate was empty") + } + + // SPS2: each side signs the exchange. The transcript has to be kept: + // sealing the message advances the phone's nonce past it. + phoneTranscript := phone.transcript() + phoneSignature, err := signP256(phone.signing, phoneTranscript) + if err != nil { + t.Fatal(err) + } + sps2 := phone.seal(append(append([]byte(nil), phone.certDER...), phoneSignature...)) + if err := pod.ParseSPS2(phone.send([]string{o5sps2}, [][]byte{sps2})); err != nil { + t.Fatalf("SPS2: %v", err) + } + // The pod must have accepted the phone's signature and kept its key. + if !VerifyP256(pod.ControllerPublic(), phoneTranscript, phoneSignature) { + t.Error("the pod did not keep a controller key that verifies the phone's signature") + } + + podSPS2, err := pod.GenerateSPS2() + if err != nil { + t.Fatal(err) + } + plain := phone.open(phone.read(podSPS2, o5sps2)) + if len(plain) <= signatureSize { + t.Fatalf("the pod's SPS2 is %d bytes, too few", len(plain)) + } + podCert, podSignature := plain[:len(plain)-signatureSize], plain[len(plain)-signatureSize:] + podKey, err := publicKeyFromCert(podCert) + if err != nil { + t.Fatalf("the pod's certificate: %v", err) + } + if !VerifyP256(podKey, phone.podTranscript(), podSignature) { + t.Error("the pod's SPS2 signature does not verify over the transcript the phone rebuilds") + } + + // SP0,GP0 then P0 + last := message.NewMessage(message.MessageTypePairing, myID, podID) + last.Payload = []byte(o5sp0gp0) + if err := pod.ParseSP0GP0(last); err != nil { + t.Fatalf("SP0GP0: %v", err) + } + p0, err := pod.GenerateP0() + if err != nil { + t.Fatal(err) + } + if got := phone.read(p0, o5p0); !bytes.Equal(got, []byte{0xa5}) { + t.Errorf("P0 is %x, want a5", got) + } +} + +// TestO5SPS0Direction checks that each side's SPS0 names itself. +func TestO5SPS0Direction(t *testing.T) { + if err := checkSPS0(sps0Payload(true), true); err != nil { + t.Errorf("the phone's SPS0 should pass its own check: %v", err) + } + if err := checkSPS0(sps0Payload(true), false); err == nil { + t.Error("the phone's SPS0 should not pass as the pod's") + } +} diff --git a/pkg/pod/o5.go b/pkg/pod/o5.go new file mode 100644 index 0000000..286e42d --- /dev/null +++ b/pkg/pod/o5.go @@ -0,0 +1,164 @@ +package pod + +import ( + "github.com/avereha/pod/pkg/aid" + "github.com/avereha/pod/pkg/command" + "github.com/avereha/pod/pkg/message" + "github.com/avereha/pod/pkg/pair" + "github.com/avereha/pod/pkg/response" + + log "github.com/sirupsen/logrus" +) + +// startO5Activation pairs the pod as an Omnipod 5. The exchange runs in +// the same shape as the DASH one — the phone writes, the pod answers — but +// with two more rounds, in which each side shows a certificate and signs +// what has been said (OmnipodKit's O5LTKExchanger). +func (p *Pod) startO5Activation() { + o5, err := pair.NewO5() + if err != nil { + log.Fatalf("pkg pod; could not make the pod's pairing keys: %s", err) + } + + // Each step reads the phone's message and answers it. A reply of nil + // means the phone speaks next without the pod saying anything. + steps := []struct { + name string + parse func(*message.Message) error + reply func() (*message.Message, error) + }{ + {"SP1SP2", o5.ParseSP1SP2, nil}, + {"SPS0", o5.ParseSPS0, o5.GenerateSPS0}, + {"SPS1", o5.ParseSPS1, o5.GenerateSPS1}, + {"SPS2.1", o5.ParseSPS21, o5.GenerateSPS21}, + {"SPS2", o5.ParseSPS2, o5.GenerateSPS2}, + {"SP0GP0", o5.ParseSP0GP0, o5.GenerateP0}, + } + + for _, step := range steps { + msg, ok := p.readSetup() + if !ok { + return + } + if err := step.parse(msg); err != nil { + // As on the DASH path, a pairing the pod cannot follow ends + // the simulator; restart it and pair again. + log.Fatalf("pkg pod; error parsing %s: %s", step.name, err) + } + log.Infof("pkg pod; Received %s", step.name) + if step.reply == nil { + continue + } + reply, err := step.reply() + if err != nil { + log.Fatalf("pkg pod; could not answer %s: %s", step.name, err) + } + p.ble.WriteMessage(reply) + } + + ltk, err := o5.LTK() + if err != nil { + log.Fatalf("pkg pod; pairing produced no long-term key: %s", err) + } + + p.mtx.Lock() + p.state.LTK = ltk + p.state.Id = o5.PodID() + p.state.ControllerPublic = o5.ControllerPublic() + if pdmId := o5.PdmID(); len(pdmId) == 4 { + p.state.PdmId = uint32(pdmId[0])<<24 | uint32(pdmId[1])<<16 | uint32(pdmId[2])<<8 | uint32(pdmId[3]) + } + p.state.EapAkaSeq = 1 + pdmId := p.state.PdmId + if err := p.state.Save(); err != nil { + log.Errorf("pkg pod; could not save the pod state: %s", err) + } + p.mtx.Unlock() + + log.Infof("pkg pod; LTK %x", ltk) + p.ble.SetPdmId(pdmId) + + p.EapAka() +} + +// o5RequiresSignature says whether an Omnipod 5 refuses this command +// unless it arrived signed. A real pod insists on a signature for +// everything that starts or stops insulin +// (OmnipodKit's requiresType4Signing). +func o5RequiresSignature(t command.Type) bool { + switch t { + case command.PROGRAM_INSULIN, // 0x1a, which precedes basal, temp basal and bolus + command.DEACTIVATE, // 0x1c + command.STOP_DELIVERY: // 0x1f + return true + } + return false +} + +// checkO5Signature decides whether a command may be carried out. A +// command that needs a signature must carry one that verifies against the +// controller key the pod kept while pairing; anything else is refused the +// way a real pod refuses it. +// +// signingInput must be taken before the message is decrypted: the +// signature covers the header and the still-encrypted payload. +func (p *Pod) checkO5Signature(cmd command.Command, msgType message.MessageType, signature, signingInput []byte) response.Response { + if !p.isO5() || !o5RequiresSignature(cmd.GetType()) { + return nil + } + + name := command.CommandName[cmd.GetType()] + switch { + case msgType != message.MessageTypeEncryptedSigned || signature == nil: + log.Errorf("pkg pod; %s arrived unsigned; refusing it with error 0x%02x", name, response.ErrorInvalidCommand) + case !pair.VerifyP256(p.state.ControllerPublic, signingInput, signature): + log.Errorf("pkg pod; the signature on %s does not verify; refusing it with error 0x%02x", name, response.ErrorInvalidCommand) + default: + log.Infof("pkg pod; the signature on %s verifies", name) + return nil + } + + p.state.SignedRejected++ + return &response.ErrorResponse{ + Code: response.ErrorInvalidCommand, + FaultEvent: p.state.FaultEvent, + PodProgress: p.state.PodProgress, + } +} + +// handleAidCommand answers one of the Omnipod 5 setup commands the phone +// sends between pairing and setting the pod up. They go through the same +// encrypted transport as a command but are not command blocks, and the +// phone does not acknowledge the answer, so this consumes two nonces +// rather than three (OmnipodKit's sendO5AidCommand). +// +// It reports whether the exchange is still in step. +func (p *Pod) handleAidCommand(payload []byte, src, dst []byte, seq uint8) bool { + cmd, err := aid.Parse(payload) + if err != nil { + log.Errorf("pkg pod; could not read the AID command %x: %s", payload, err) + return false + } + log.Infof("pkg pod; AID %s", cmd) + + answer := p.aid.Respond(cmd) + if answer == nil { + log.Warnf("pkg pod; no answer for AID %s", cmd) + return false + } + log.Debugf("pkg pod; answering AID %s with %q", cmd.Key(), answer) + + p.state.MsgSeq++ + msg := message.NewMessage(message.MessageTypeEncrypted, dst, src) + msg.Payload = answer + msg.SequenceNumber = p.state.MsgSeq + msg.Ack = true + msg.AckNumber = seq + 1 + msg.Eqos = 1 + + if err := p.encryptAndSend(msg); err != nil { + log.Errorf("pkg pod; could not send the AID answer: %s", err) + return false + } + return true +} diff --git a/pkg/pod/o5_test.go b/pkg/pod/o5_test.go new file mode 100644 index 0000000..4a4e9bc --- /dev/null +++ b/pkg/pod/o5_test.go @@ -0,0 +1,250 @@ +package pod + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "testing" + + "github.com/avereha/pod/pkg/aid" + "github.com/avereha/pod/pkg/bluetooth" + "github.com/avereha/pod/pkg/command" + "github.com/avereha/pod/pkg/crc" + "github.com/avereha/pod/pkg/message" + "github.com/avereha/pod/pkg/response" +) + +// silentRadio is a pod radio that goes nowhere; these tests exercise what +// the pod decides, not what it writes. +type silentRadio struct{} + +func (silentRadio) NotifyCmd(bluetooth.Packet) error { return nil } +func (silentRadio) NotifyData(bluetooth.Packet) error { return nil } +func (silentRadio) Advertise([]byte, uint32) error { return nil } +func (silentRadio) AdvertiseState(bluetooth.AdvertState) error { return nil } +func (silentRadio) Disconnect() {} + +func testPod(t *testing.T, mode bluetooth.Mode) *Pod { + t.Helper() + ble, err := bluetooth.New(func(*bluetooth.Ble) (bluetooth.Radio, error) { return silentRadio{}, nil }, + bluetooth.ProfileFor(mode)) + if err != nil { + t.Fatal(err) + } + return &Pod{ + ble: ble, + state: NewFreshState(t.TempDir()+"/state.toml", mode), + aid: aid.NewStore(), + } +} + +// omnipodCommand builds the command block the phone sends inside an +// encrypted message: the S0.0= wrapper around an addressed, length-and- +// sequence prefixed body with its CRC16. +func omnipodCommand(t *testing.T, address []byte, seq uint8, body []byte) []byte { + t.Helper() + inner := append([]byte(nil), address...) + lsf := uint16(seq&0x0f)<<10 | uint16(len(body)) + inner = append(inner, byte(lsf>>8), byte(lsf)) + inner = append(inner, body...) + inner = append(inner, crc.CRC16(inner)...) + + out := append([]byte("S0.0="), byte(len(inner)>>8), byte(len(inner))) + out = append(out, inner...) + return append(out, []byte(",G0.0")...) +} + +func parseCommand(t *testing.T, body []byte) command.Command { + t.Helper() + cmd, err := command.Unmarshal(omnipodCommand(t, []byte{0x00, 0x2a, 0x1c, 0x6d}, 3, body)) + if err != nil { + t.Fatalf("building a command: %v", err) + } + return cmd +} + +// An Omnipod 5 refuses to deliver or stop insulin unless the command came +// signed by the controller it paired with, and answers error 0x2A when it +// does not — which is what OmnipodKit's ErrorResponseCode.o5InvalidCommand +// names. Anything else it takes as before. +func TestO5SignatureEnforcement(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + controllerPublic := make([]byte, 64) + key.PublicKey.X.FillBytes(controllerPublic[:32]) + key.PublicKey.Y.FillBytes(controllerPublic[32:]) + + signingInput := []byte("the header and the encrypted payload") + sign := func(k *ecdsa.PrivateKey, data []byte) []byte { + digest := sha256.Sum256(data) + r, s, err := ecdsa.Sign(rand.Reader, k, digest[:]) + if err != nil { + t.Fatal(err) + } + out := make([]byte, 64) + r.FillBytes(out[:32]) + s.FillBytes(out[32:]) + return out + } + + stranger, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + // 0x1c deactivate and 0x1f stop delivery must be signed; 0x0e get + // status need not be. + deactivate := []byte{0x1c, 0x00} + getStatus := []byte{0x0e, 0x00, 0x00} + + for _, tc := range []struct { + name string + body []byte + msgType message.MessageType + sig []byte + refused bool + }{ + {"a signed deactivate is carried out", deactivate, message.MessageTypeEncryptedSigned, sign(key, signingInput), false}, + {"an unsigned deactivate is refused", deactivate, message.MessageTypeEncrypted, nil, true}, + {"a deactivate signed by a stranger is refused", deactivate, message.MessageTypeEncryptedSigned, sign(stranger, signingInput), true}, + {"a deactivate with a damaged signature is refused", deactivate, message.MessageTypeEncryptedSigned, make([]byte, 64), true}, + {"an unsigned get status is carried out", getStatus, message.MessageTypeEncrypted, nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := testPod(t, bluetooth.ModeO5) + p.state.ControllerPublic = controllerPublic + + got := p.checkO5Signature(parseCommand(t, tc.body), tc.msgType, tc.sig, signingInput) + if tc.refused { + if got == nil { + t.Fatal("the command was carried out and should have been refused") + } + refusal, ok := got.(*response.ErrorResponse) + if !ok { + t.Fatalf("the pod answered with %T, want an error response", got) + } + if refusal.Code != response.ErrorInvalidCommand { + t.Errorf("the error code is 0x%02x, want 0x%02x", refusal.Code, response.ErrorInvalidCommand) + } + if p.state.SignedRejected != 1 { + t.Errorf("the pod counted %d refusals, want 1", p.state.SignedRejected) + } + // The refusal is a valid message block: type 06 and a length. + raw, err := refusal.Marshal() + if err != nil { + t.Fatal(err) + } + if len(raw) != 5 || raw[0] != 0x06 || raw[1] != 0x03 { + t.Errorf("the error response is %x, want 06 03 then three bytes", raw) + } + return + } + if got != nil { + t.Fatalf("the command was refused with %T and should have been carried out", got) + } + if p.state.SignedRejected != 0 { + t.Errorf("the pod counted %d refusals, want 0", p.state.SignedRejected) + } + }) + } +} + +// A DASH pod has no signatures at all, so the same unsigned commands must +// go through untouched. +func TestDashIgnoresSignatures(t *testing.T) { + p := testPod(t, bluetooth.ModeDash) + for _, body := range [][]byte{{0x1c, 0x00}, {0x1f, 0x05, 0x49, 0x4e, 0x53, 0x2e, 0x07}, {0x0e, 0x00, 0x00}} { + if got := p.checkO5Signature(parseCommand(t, body), message.MessageTypeEncrypted, nil, nil); got != nil { + t.Errorf("a DASH refused command 0x%02x", body[0]) + } + } + if p.state.SignedRejected != 0 { + t.Error("a DASH counted a signature refusal") + } +} + +// The commands that must be signed are the ones OmnipodKit signs +// (requiresType4Signing): everything that starts or stops insulin. +func TestWhichCommandsMustBeSigned(t *testing.T) { + mustSign := map[command.Type]bool{ + command.PROGRAM_INSULIN: true, // 0x1a, before basal, temp basal or bolus + command.DEACTIVATE: true, // 0x1c + command.STOP_DELIVERY: true, // 0x1f + } + for _, t2 := range []command.Type{ + command.SET_UNIQUE_ID, command.GET_VERSION, command.GET_STATUS, + command.SILENCE_ALERTS, command.PROGRAM_ALERTS, command.PROGRAM_INSULIN, + command.DEACTIVATE, command.PROGRAM_BEEPS, command.STOP_DELIVERY, + command.CNFG_DELIV_FLAG, + } { + if got := o5RequiresSignature(t2); got != mustSign[t2] { + t.Errorf("command 0x%02x (%s) requires a signature: %v, want %v", t2, command.CommandName[t2], got, mustSign[t2]) + } + } +} + +// The pod reports the product the phone expects for its mode; a mismatch +// makes OmnipodKit refuse the pod during setup. +func TestProductFollowsMode(t *testing.T) { + if got := testPod(t, bluetooth.ModeO5).product().ID; got != response.ProductIDOmnipod5 { + t.Errorf("an Omnipod 5 reports product 0x%02x, want 0x%02x", got, response.ProductIDOmnipod5) + } + if got := testPod(t, bluetooth.ModeDash).product().ID; got != response.ProductIDDash { + t.Errorf("a DASH reports product 0x%02x, want 0x%02x", got, response.ProductIDDash) + } +} + +// A state file written before the simulator could be an Omnipod 5 holds no +// mode, and that pod is a DASH. +func TestStateRemembersItsMode(t *testing.T) { + for _, tc := range []struct { + saved string + want bluetooth.Mode + }{ + {"", bluetooth.ModeDash}, + {"dash", bluetooth.ModeDash}, + {"o5", bluetooth.ModeO5}, + } { + s := &PODState{Mode: tc.saved} + if got := s.PodMode(); got != tc.want { + t.Errorf("a state holding %q is a %s pod, want %s", tc.saved, got, tc.want) + } + } +} + +// The controller id ends up where the status page and the advertisement +// read it from. +func TestPdmIdKept(t *testing.T) { + p := testPod(t, bluetooth.ModeO5) + p.state.PdmId = 0x002A1C6C + p.takeSnapshot() + if got := p.Status().Controller; got != "002A1C6C" { + t.Errorf("the status shows controller %q, want 002A1C6C", got) + } + if got := p.Status().PodType; got != "Omnipod 5" { + t.Errorf("the status shows pod type %q, want Omnipod 5", got) + } +} + +// A sanity check on the command builder these tests rely on: the sequence +// number and the body have to survive the round trip. +func TestCommandBuilderRoundTrip(t *testing.T) { + cmd := parseCommand(t, []byte{0x0e, 0x00, 0x00}) + if cmd.GetType() != command.GET_STATUS { + t.Errorf("the command came back as 0x%02x, want 0x0e", cmd.GetType()) + } + seq, id, err := cmd.GetHeaderData() + if err != nil { + t.Fatal(err) + } + if seq != 3 { + t.Errorf("the sequence is %d, want 3", seq) + } + if binary.BigEndian.Uint32(id) != 0x002A1C6D { + t.Errorf("the address is %x, want 002a1c6d", id) + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index a32bced..5cc1946 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -5,6 +5,7 @@ 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" @@ -32,6 +33,7 @@ type PodMsgBody struct { type Pod struct { ble *bluetooth.Ble state *PODState + aid *aid.Store mtx sync.Mutex webMessageHook func([]byte) @@ -49,6 +51,7 @@ func New(ble *bluetooth.Ble, state *PODState) *Pod { ret := &Pod{ ble: ble, state: state, + aid: aid.NewStore(), } ret.takeSnapshot() return ret @@ -83,6 +86,17 @@ func (p *Pod) StartAcceptingCommands() { log.Infof("pkg pod; Listening for commands") firstCmd, _ := p.ble.ReadCmd() log.Infof("pkg pod; got first command: as string: %s", firstCmd) + if pdmId, ok := bluetooth.O5PdmIdFromHello(firstCmd); ok { + log.Infof("pkg pod; HELLO from controller %08x", pdmId) + if p.isO5() { + // An Omnipod 5 puts its controller's id in the one service + // UUID it advertises, so the phone can find its own pod. + p.mtx.Lock() + p.state.PdmId = pdmId + p.mtx.Unlock() + p.ble.SetPdmId(pdmId) + } + } p.ble.StartMessageLoop() @@ -109,7 +123,22 @@ func (p *Pod) readSetup() (*message.Message, bool) { } } +// isO5 says whether this pod is simulating an Omnipod 5. +func (p *Pod) isO5() bool { return p.ble.Profile().Mode.IsO5() } + +// product is what the pod reports in its version responses. +func (p *Pod) product() response.Product { + if p.isO5() { + return response.Omnipod5Product() + } + return response.DashProduct() +} + func (p *Pod) StartActivation() { + if p.isO5() { + p.startO5Activation() + return + } pair := &pair.Pair{} msg, ok := p.readSetup() @@ -270,12 +299,35 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { // Lock mutex before we start using/modifying state p.mtx.Lock() + // A signed message's signature covers its header and its payload + // while that is still encrypted, so both have to be taken now: + // decrypting replaces the payload in place. + msgType, signature, signingInput := msg.Type, msg.Signature, msg.SigningInput() + 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++ + // An Omnipod 5 is sent its setup commands on this same transport + // before it is set up. They are not command blocks, and the phone + // does not acknowledge the answer. + if p.isO5() && aid.Looks(decrypted.Payload) { + ok := p.handleAidCommand(decrypted.Payload, msg.Source, msg.Destination, msg.SequenceNumber) + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + if !ok { + log.Errorf("pkg pod; could not answer the AID command — re-establishing session") + p.ble.ShutdownConnection() + go p.StartAcceptingCommands() + return + } + p.notifyStateChange() + continue + } + cmd, err := command.Unmarshal(decrypted.Payload) if err != nil { log.Fatalf("pkg pod; could not unmarshal command: %s", err) @@ -290,8 +342,8 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { data = decrypted.Payload n = len(data) log.Debugf("pkg pod; len = %d", n) - if n < 16 { - log.Fatalf("pkg pod; decrypted. Payload too short") + if n < 19 { + log.Fatalf("pkg pod; decrypted payload of %d bytes is too short to hold a command", n) } pMsg.MsgBodyCommand = data[13 : n-5] if data[13] == 0x1c { @@ -299,19 +351,34 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { } log.Tracef("pkg pod; command pod message body = %x", pMsg.MsgBodyCommand) - p.handleCommand(cmd) - var rsp response.Response - if cmd.IsResponseHardcoded() { - rsp, err = cmd.GetResponse() - if err != nil { - log.Fatalf("pkg pod; could not get command response: %s", err) - } + if refusal := p.checkO5Signature(cmd, msgType, signature, signingInput); refusal != nil { + // The command is not carried out: a real pod answers an + // unsigned insulin or cancel command with an error and leaves + // its delivery alone. + rsp = refusal } else { - rsp = p.getResponse(cmd) + p.handleCommand(cmd) + + if cmd.IsResponseHardcoded() { + rsp, err = cmd.GetResponse() + if err != nil { + log.Fatalf("pkg pod; could not get command response: %s", err) + } + // The phone checks the product id against the pod type the + // user chose, and decodes the lot number for the UI. + switch r := rsp.(type) { + case *response.VersionResponse: + r.Product = p.product() + case *response.SetUniqueID: + r.Product = p.product() + } + } else { + rsp = p.getResponse(cmd) + } } - if cmd.GetType() == command.SET_UNIQUE_ID { + if _, refused := rsp.(*response.ErrorResponse); !refused && cmd.GetType() == command.SET_UNIQUE_ID { // Set the unique ID log.Tracef("SET_UNIQUE_ID cmd.GetPayload() %x", cmd.GetPayload()) uniqueId := cmd.GetPayload() @@ -386,12 +453,24 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { } } +// encryptAndSend encrypts a message under the session key and writes it +// to the phone, advancing the nonce the way an answer does. +func (p *Pod) encryptAndSend(msg *message.Message) error { + out, err := encrypt.EncryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, msg) + if err != nil { + return err + } + p.state.NonceSeq++ + p.ble.WriteMessage(out) + return nil +} + func (p *Pod) makeGeneralStatusResponse() response.Response { log.Debugf("pkg pod; General status response LastProgSeqNum = %d", p.state.LastProgSeqNum) var now = time.Now() - return &response.GeneralStatusResponse { + return &response.GeneralStatusResponse{ LastProgSeqNum: p.state.LastProgSeqNum, Reservoir: p.state.Reservoir, Alerts: p.state.ActiveAlertSlots, @@ -410,7 +489,7 @@ func (p *Pod) makeDetailedStatusResponse() response.Response { var now = time.Now() - return &response.DetailedStatusResponse { + return &response.DetailedStatusResponse{ LastProgSeqNum: p.state.LastProgSeqNum, Reservoir: p.state.Reservoir, Alerts: p.state.ActiveAlertSlots, @@ -429,17 +508,17 @@ func (p *Pod) makeDetailedStatusResponse() response.Response { func (p *Pod) makeType1StatusResponse() response.Response { - return &response.Type1StatusResponse { - TriggeredAlerts: p.state.TriggerTimes, + return &response.Type1StatusResponse{ + TriggeredAlerts: p.state.TriggerTimes, } } func (p *Pod) makeType3StatusResponse() response.Response { - return &response.Type3StatusResponse { - FaultEvent: p.state.FaultEvent, - FaultEventTime: p.state.FaultTime, - MinutesActive: p.state.MinutesActive(), + return &response.Type3StatusResponse{ + FaultEvent: p.state.FaultEvent, + FaultEventTime: p.state.FaultTime, + MinutesActive: p.state.MinutesActive(), } } @@ -447,14 +526,14 @@ func (p *Pod) makeType5StatusResponse() response.Response { var activationTime = p.state.ActivationTime - return &response.Type5StatusResponse { - FaultEvent: p.state.FaultEvent, - FaultEventTime: p.state.FaultTime, - Year: uint8(activationTime.Year() - 2000), - Month: uint8(activationTime.Month()), - Day: uint8(activationTime.Day()), - Hour: uint8(activationTime.Hour()), - Minute: uint8(activationTime.Minute()), + return &response.Type5StatusResponse{ + FaultEvent: p.state.FaultEvent, + FaultEventTime: p.state.FaultTime, + Year: uint8(activationTime.Year() - 2000), + Month: uint8(activationTime.Month()), + Day: uint8(activationTime.Day()), + Hour: uint8(activationTime.Hour()), + Minute: uint8(activationTime.Minute()), } } @@ -462,7 +541,7 @@ func (p *Pod) getResponse(cmd command.Command) response.Response { var rsp response.Response getStatus, isStatusRequest := cmd.(*command.GetStatus) - if p.state.MinutesActive() >= 80 * 60 && p.state.FaultEvent == 0 { + if p.state.MinutesActive() >= 80*60 && p.state.FaultEvent == 0 { p.state.FaultEvent = 0x1C // 0x1C = exceeded maximum pod life of 80 hours } if !isStatusRequest || getStatus.RequestType == 0 || getStatus.RequestType == 7 { diff --git a/pkg/pod/state.go b/pkg/pod/state.go index 1a0dd8b..2b3f7a4 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/bluetooth" "github.com/avereha/pod/pkg/response" ) @@ -34,7 +35,7 @@ type PODState struct { FaultTime uint16 `toml:"fault_time"` Delivered uint16 `toml:"delivered"` - TriggerTimes [8]uint16 `toml:"trigger_times"` + TriggerTimes [8]uint16 `toml:"trigger_times"` // At some point these could be replaced with details // of each kind of delivery (volume, start time, schedule, etc) @@ -47,16 +48,45 @@ type PODState struct { // PeripheralID is the identifier the iOS Simulator transport gives the pod. PeripheralID string `toml:"peripheral_id"` + // Mode is the kind of pod being simulated, "dash" or "o5". It is kept + // with the state so that restarting the simulator does not change the + // pod the phone paired with. + Mode string `toml:"mode"` + + // PdmId is the controller that paired the pod. An Omnipod 5 + // advertises it, and the control panel shows it. + PdmId uint32 `toml:"pdm_id"` + + // ControllerPublic is the phone's signing key, taken from the + // certificate it showed while pairing. An Omnipod 5 checks the + // signature on every insulin, cancel and deactivate command against it. + ControllerPublic []byte `toml:"controller_public"` + + // SignedRejected counts the commands refused for want of a good + // signature. A real pod answers those with error 0x2A, and anything + // above zero here means the phone sent one the pod would not take. + SignedRejected uint32 `toml:"signed_rejected"` + Filename string } // NewFreshState is a new pod, not yet paired. -func NewFreshState(filename string) *PODState { +func NewFreshState(filename string, mode bluetooth.Mode) *PODState { return &PODState{ Reservoir: 150 / 0.05, ActivationTime: time.Now(), Filename: filename, + Mode: string(mode), + } +} + +// PodMode is the kind of pod this state belongs to. A state file written +// before the simulator could be anything else holds no mode and is a DASH. +func (p *PODState) PodMode() bluetooth.Mode { + if p.Mode == string(bluetooth.ModeO5) { + return bluetooth.ModeO5 } + return bluetooth.ModeDash } func NewState(filename string) (*PODState, error) { diff --git a/pkg/pod/status.go b/pkg/pod/status.go index 031b21c..632c36a 100644 --- a/pkg/pod/status.go +++ b/pkg/pod/status.go @@ -26,7 +26,16 @@ var progressNames = map[response.PodProgress]string{ // Status is the pod as the control panel shows it. type Status struct { - ID string `json:"id"` + ID string `json:"id"` + // Mode is "dash" or "o5" and PodType names it for a reader. + Mode string `json:"mode"` + PodType string `json:"podType"` + // Controller is the phone the pod paired with, which an Omnipod 5 + // also advertises. Empty until it has paired. + Controller string `json:"controller"` + // SignedRejected counts the commands refused for want of a good + // signature. Anything above zero wants looking into. + SignedRejected uint32 `json:"signedRejected"` Paired bool `json:"paired"` Connected bool `json:"connected"` Progress int `json:"progress"` @@ -80,8 +89,17 @@ func (p *Pod) Status() Status { if len(s.Id) == 4 { id = fmt.Sprintf("%08X", s.Id) } + controller := "" + if s.PdmId != 0 { + controller = fmt.Sprintf("%08X", s.PdmId) + } + mode := p.ble.Profile().Mode return Status{ ID: id, + Mode: string(mode), + PodType: mode.Description(), + Controller: controller, + SignedRejected: s.SignedRejected, Paired: s.LTK != nil, Connected: p.ble.IsConnected(), Progress: int(s.PodProgress), diff --git a/pkg/response/errorresponse.go b/pkg/response/errorresponse.go new file mode 100644 index 0000000..631246a --- /dev/null +++ b/pkg/response/errorresponse.go @@ -0,0 +1,19 @@ +package response + +// ErrorResponse is how a pod refuses a command it will not carry out: +// block type 06, the error code, any saved fault code, and the pod's +// progress (OmnipodKit's ErrorResponse). +type ErrorResponse struct { + Code uint8 + FaultEvent uint8 + PodProgress PodProgress +} + +// ErrorInvalidCommand is what an Omnipod 5 answers a command it cannot +// use, among them one that should have been signed and was not +// (ErrorResponseCode.o5InvalidCommand). +const ErrorInvalidCommand uint8 = 0x2A + +func (r *ErrorResponse) Marshal() ([]byte, error) { + return []byte{0x06, 0x03, r.Code, r.FaultEvent, byte(r.PodProgress)}, nil +} diff --git a/pkg/response/setuniqueidresponse.go b/pkg/response/setuniqueidresponse.go index 694ee21..627ded6 100644 --- a/pkg/response/setuniqueidresponse.go +++ b/pkg/response/setuniqueidresponse.go @@ -1,17 +1,21 @@ package response import ( + "encoding/binary" "encoding/hex" ) // This is the special case - sent with the 0x011B response to 0x03 message type SetUniqueID struct { - Seq uint16 + Seq uint16 + Product Product } func (r *SetUniqueID) Marshal() ([]byte, error) { response, _ := hex.DecodeString("011B13881008340A50040A00010300040308146DB10006E45100001091") - + product := r.Product.orDash() + response[productIDOffset1B] = product.ID + binary.BigEndian.PutUint32(response[lotOffset1B:], product.Lot) return response, nil } diff --git a/pkg/response/version_test.go b/pkg/response/version_test.go new file mode 100644 index 0000000..ea0d811 --- /dev/null +++ b/pkg/response/version_test.go @@ -0,0 +1,62 @@ +package response + +import ( + "encoding/binary" + "testing" +) + +// The phone refuses a pod whose version response reports a product other +// than the pod type the user picked, and decodes the lot number for the +// UI, so both have to follow the mode. +func TestVersionResponseProduct(t *testing.T) { + for _, tc := range []struct { + name string + product Product + wantID byte + wantLot uint32 + }{ + {"default is a DASH", Product{}, ProductIDDash, LotDash}, + {"dash", DashProduct(), ProductIDDash, LotDash}, + {"omnipod 5", Omnipod5Product(), ProductIDOmnipod5, LotOmnipod5}, + } { + t.Run(tc.name, func(t *testing.T) { + short, err := (&VersionResponse{Product: tc.product}).Marshal() + if err != nil { + t.Fatal(err) + } + if short[productIDOffset15] != tc.wantID { + t.Errorf("0x15 product id = %02x, want %02x", short[productIDOffset15], tc.wantID) + } + if got := binary.BigEndian.Uint32(short[lotOffset15:]); got != tc.wantLot { + t.Errorf("0x15 lot = %08x, want %08x", got, tc.wantLot) + } + + long, err := (&SetUniqueID{Product: tc.product}).Marshal() + if err != nil { + t.Fatal(err) + } + if long[productIDOffset1B] != tc.wantID { + t.Errorf("0x1B product id = %02x, want %02x", long[productIDOffset1B], tc.wantID) + } + if got := binary.BigEndian.Uint32(long[lotOffset1B:]); got != tc.wantLot { + t.Errorf("0x1B lot = %08x, want %08x", got, tc.wantLot) + } + }) + } +} + +// The lot numbers have to decode to the product code the phone shows, the +// way OmnipodKit's lotDecode reads them. +func TestLotProductCode(t *testing.T) { + for _, tc := range []struct { + lot uint32 + want uint32 + }{ + {LotDash, 0x04}, // D1, DASH U100 + {LotOmnipod5, 0x07}, // H1, Omnipod 5 U100 + } { + if got := (tc.lot >> 25) & 0x3f; got != tc.want { + t.Errorf("lot %08x has product number %02x, want %02x", tc.lot, got, tc.want) + } + } +} diff --git a/pkg/response/versionresponse.go b/pkg/response/versionresponse.go index ff81dfe..b0ee403 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -1,17 +1,62 @@ package response import ( + "encoding/binary" "encoding/hex" ) +// The version responses a pod returns to the two setup commands. Byte 8 of +// the 0x15 answer and byte 15 of the 0x1B one are the product id, which the +// phone checks against the pod type the user chose +// (OmnipodKit's VersionResponse and BlePodComms.setupPod). +const ( + // The product ids are OmnipodKit's PodType.PodModelType values. + ProductIDDash byte = 0x04 + ProductIDOmnipod5 byte = 0x05 + + productIDOffset15 = 8 + productIDOffset1B = 15 + lotOffset15 = 10 + lotOffset1B = 17 +) + +// Lot numbers whose product code decodes the way the phone expects for +// each kind of pod: bits 25-30 hold the product, 4 for a DASH U100 and 7 +// for an Omnipod 5 U100 (OmnipodKit's lotDecode). +const ( + LotDash uint32 = 0x08146DB1 // decodes as D1 + LotOmnipod5 uint32 = 0x0E146DB1 // decodes as H1 +) + +// Product is which pod the version responses report. The zero value is a +// DASH, which is what the simulator was before it could be anything else. +type Product struct { + ID byte + Lot uint32 +} + +// DashProduct and Omnipod5Product are the two the simulator serves. +func DashProduct() Product { return Product{ID: ProductIDDash, Lot: LotDash} } +func Omnipod5Product() Product { return Product{ID: ProductIDOmnipod5, Lot: LotOmnipod5} } + +func (p Product) orDash() Product { + if p.ID == 0 { + return DashProduct() + } + return p +} + // This is the special case - sent with the 0x0115 response to 0x07 message type VersionResponse struct { - Seq uint16 + Seq uint16 + Product Product } func (r *VersionResponse) Marshal() ([]byte, error) { response, _ := hex.DecodeString("0115040A00010300040208146DB10006E45100FFFFFFFF") - + product := r.Product.orDash() + response[productIDOffset15] = product.ID + binary.BigEndian.PutUint32(response[lotOffset15:], product.Lot) return response, nil } From e0435b1018702cdaa7a2fa71ce0fba3c4d842ab4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 17:05:13 -0500 Subject: [PATCH 2/8] Read the phone's acknowledgement of an AID answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmnipodKit acknowledges an Omnipod 5 setup command's answer exactly as it acknowledges any other response: sendO5AidCommand ends by incrementing its sequence and nonce and sending an empty ACK. The pod was not reading it, so the next time round the loop it took that ACK for a command, found no command in it and exited — which took its radio with it, leaving the app reporting that Bluetooth was gone rather than the message the pod could not read. So an AID exchange costs three nonces like any other, not two. The acknowledgement read is now one helper used by both paths. A command the pod cannot read no longer ends the process either. It hangs up and lets the phone set up a new session, so the failure shows up as the logged message that caused it instead of as a dead radio. --- pkg/aid/aid_test.go | 14 +++++++++ pkg/pod/pod.go | 71 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/pkg/aid/aid_test.go b/pkg/aid/aid_test.go index a386b2b..87b06f0 100644 --- a/pkg/aid/aid_test.go +++ b/pkg/aid/aid_test.go @@ -141,3 +141,17 @@ func ExampleCommand_String() { fmt.Println(cmd) // Output: set 3.9 = 38 (1 bytes) } + +// An AID answer is acknowledged by the phone exactly as any other +// response is (OmnipodKit's sendO5AidCommand ends by sending an ACK), so +// an exchange is: command in, answer out, acknowledgement in. A pod that +// expects only the first two reads the acknowledgement as a command, and +// an acknowledgement carries no payload, so it does not parse as one. +func TestAcknowledgementIsNotAnAIDCommand(t *testing.T) { + if Looks(nil) { + t.Error("an empty acknowledgement was taken for an AID command") + } + if Looks([]byte{}) { + t.Error("an empty acknowledgement was taken for an AID command") + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 5cc1946..886ba25 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -2,6 +2,7 @@ package pod import ( "encoding/json" + "fmt" "sync" "time" @@ -315,11 +316,32 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { // does not acknowledge the answer. if p.isO5() && aid.Looks(decrypted.Payload) { ok := p.handleAidCommand(decrypted.Payload, msg.Source, msg.Destination, msg.SequenceNumber) + if ok { + // The phone acknowledges an AID answer as it does any + // other response (sendO5AidCommand), and that + // acknowledgement carries a nonce of its own. + ack, reconnected := p.readResponseAck() + if ack == nil { + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + log.Infof("pkg pod; no AID answer ACK — re-establishing session") + if !reconnected { + p.ble.ShutdownConnection() + } + go p.StartAcceptingCommands() + return + } + if err := p.consumeAck(ack); err != nil { + log.Errorf("pkg pod; %s", err) + ok = false + } + } p.state.Save() p.takeSnapshot() p.mtx.Unlock() if !ok { - log.Errorf("pkg pod; could not answer the AID command — re-establishing session") + log.Errorf("pkg pod; could not complete the AID command — re-establishing session") p.ble.ShutdownConnection() go p.StartAcceptingCommands() return @@ -330,7 +352,18 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { cmd, err := command.Unmarshal(decrypted.Payload) if err != nil { - log.Fatalf("pkg pod; could not unmarshal command: %s", err) + // Exiting here would take the pod's radio with it, and the + // app would report its Bluetooth gone rather than the message + // the pod could not read. Hang up instead: the phone comes + // back and sets up a new session. + log.Errorf("pkg pod; could not read the command %x: %s", decrypted.Payload, err) + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + log.Infof("pkg pod; re-establishing the session") + p.ble.ShutdownConnection() + go p.StartAcceptingCommands() + return } cmdSeq, requestID, err := cmd.GetHeaderData() if err != nil { @@ -422,7 +455,7 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { p.ble.WriteMessage(msg) log.Debugf("pkg pod; reading response ACK. Nonce seq %d", p.state.NonceSeq) - msg, _, didReconnect = p.ble.ReadMessageWithTimeout(3 * time.Minute) + msg, didReconnect = p.readResponseAck() if msg == nil { // The phone never acknowledged; it will ask again on its next session. p.state.Save() @@ -436,13 +469,8 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { return } // TODO check for SEQ numbers here and the Ack flag - 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++ - if len(decrypted.Payload) != 0 { - log.Fatalf("pkg pod; this should be empty message with ACK header %s", spew.Sdump(msg)) + if err := p.consumeAck(msg); err != nil { + log.Fatalf("pkg pod; %s", err) } p.state.Save() p.takeSnapshot() @@ -453,6 +481,29 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { } } +// readResponseAck waits for the phone to acknowledge a response the pod +// has just sent. A nil message means the phone never answered, and the +// second value says whether it came back on a new connection. +func (p *Pod) readResponseAck() (*message.Message, bool) { + msg, _, didReconnect := p.ble.ReadMessageWithTimeout(3 * time.Minute) + return msg, didReconnect +} + +// consumeAck decrypts an acknowledgement and advances the nonce it used. +// An acknowledgement carries no payload; anything else means the pod and +// the phone no longer agree on where they are. +func (p *Pod) consumeAck(msg *message.Message) error { + decrypted, err := encrypt.DecryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, msg) + if err != nil { + return fmt.Errorf("could not decrypt the acknowledgement: %s", err) + } + p.state.NonceSeq++ + if len(decrypted.Payload) != 0 { + return fmt.Errorf("expected an empty acknowledgement, got %x", decrypted.Payload) + } + return nil +} + // encryptAndSend encrypts a message under the session key and writes it // to the phone, advancing the nonce the way an answer does. func (p *Pod) encryptAndSend(msg *message.Message) error { From 997e04215fcb161517946d2d05d42cd27ed6a30b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 17:46:27 -0500 Subject: [PATCH 3/8] Hold the connection while the pod is being activated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simulator hangs up on an idle link after three minutes, which mimics the DASH pods that drop a connection every few minutes so an iOS hook can use it as a heartbeat. That is a running pod's behaviour. A pod being activated was doing it too, in the middle of the user filling, priming, attaching and inserting it — all of which takes longer than three minutes and none of which a real pod interrupts. So only a pod that has finished activating drops an idle link. This also keeps the activation clear of a reconnect, which does not currently recover: the phone reconnects at the BLE level but no session is established on the new connection, and after an app relaunch it scans for the unpaired advertisement rather than the one its paired pod sends. --- pkg/pod/pod.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 886ba25..c88487e 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -282,6 +282,18 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { return } if didTimeout { + // A pod being activated holds its connection: the user is + // filling, priming, attaching and inserting it, which takes + // longer than this, and a real pod does not hang up partway + // through. Only a pod that is running drops an idle link, the + // way some DASH pods do every few minutes. + p.mtx.Lock() + activating := p.state.PodProgress < response.PodProgressRunningAbove50U + p.mtx.Unlock() + if activating { + log.Infof("pkg pod; idle while being activated — holding the connection") + continue + } p.ble.ShutdownConnection() go func() { p.StartAcceptingCommands() From 432b097d6dd0f8c4fff595aef7d486e3a4095250 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 18:50:56 -0500 Subject: [PATCH 4/8] Advertise an Omnipod 5 over Linux Bluetooth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pod refused -mode o5 on a Bluetooth adapter because the vendored paypal/gatt could not advertise the 128-bit service UUID an Omnipod 5 identifies itself by. It turns out it nearly could: AppendUUIDFit had the length-aware packing commented out, and what was left wrote every UUID into a 16-bit list whatever its length, so a 128-bit UUID went out mislabelled and no phone matched it. Restoring that packing needed its size accounting corrected as well. UUIDs of one length share a single field, so the two bytes of field header are paid once per list and not once per UUID; counting it per UUID made nine 16-bit UUIDs look 8 bytes too large for the packet and turned a DASH's complete list into an incomplete one. A second fix next to it: the branch that moves the name into the scan response declared a second AdvPacket that shadowed the one being built, so it filled in the scan response and then advertised the shadowed-away packet. It only showed once something did not fit, which is every advertisement carrying a 128-bit UUID — an Omnipod 5's fills 21 of the 31 bytes and leaves no room for the name. Both are marked in the vendored files, since `go mod vendor` overwrites them. The radio now takes its service and characteristic UUIDs from the pod profile rather than the DASH ones, so -mode o5 serves 1A7E2443 for data. No MTU work: the phone drives that exchange and the pod answers with its own maximum, which gatt already clamps to 256 — comfortably above the 247 a 244-byte packet needs. The pod logs what the two settled on instead, and says so loudly if an Omnipod 5 ends up below 247. --- pkg/bluetooth/advert_test.go | 128 ++++++++++++++++++ pkg/bluetooth/gatt_linux.go | 59 ++++++-- vendor/github.com/paypal/gatt/adv.go | 95 ++++++++----- vendor/github.com/paypal/gatt/device_linux.go | 14 +- 4 files changed, 242 insertions(+), 54 deletions(-) create mode 100644 pkg/bluetooth/advert_test.go diff --git a/pkg/bluetooth/advert_test.go b/pkg/bluetooth/advert_test.go new file mode 100644 index 0000000..62153ed --- /dev/null +++ b/pkg/bluetooth/advert_test.go @@ -0,0 +1,128 @@ +package bluetooth + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/paypal/gatt" +) + +// advFields splits an advertising packet into its (type, value) fields. +// advBytes is the packet's used portion; AdvPacket.Bytes returns a +// fixed-size array that cannot be sliced directly. +func advBytes(a *gatt.AdvPacket) []byte { + full := a.Bytes() + return append([]byte(nil), full[:a.Len()]...) +} + +func advFields(t *testing.T, b []byte) map[byte][]byte { + t.Helper() + out := map[byte][]byte{} + for i := 0; i < len(b); { + l := int(b[i]) + if l == 0 || i+1+l > len(b) { + break + } + out[b[i+1]] = b[i+2 : i+1+l] + i += 1 + l + } + return out +} + +const ( + fieldAllUUID16 = 0x03 + fieldAllUUID128 = 0x07 +) + +// An Omnipod 5 advertises one 128-bit service UUID. The vendored gatt wrote +// every UUID into a 16-bit list whatever its length, so the phone saw a run +// of nonsense 16-bit UUIDs and never matched its pod. +func TestAdvertisingPacksA128BitUUID(t *testing.T) { + u, err := gatt.ParseUUID(o5AdvertisementUUID(0x002A1C6C, false)) + if err != nil { + t.Fatal(err) + } + + a := &gatt.AdvPacket{} + a.AppendFlags(0x06) + a.AppendUUIDFit([]gatt.UUID{u}) + + fields := advFields(t, advBytes(a)) + got, ok := fields[fieldAllUUID128] + if !ok { + t.Fatalf("no 128-bit UUID list in %x (fields %v)", advBytes(a), keys(fields)) + } + if len(got) != 16 { + t.Fatalf("the 128-bit list holds %d bytes, want 16", len(got)) + } + if _, wrong := fields[fieldAllUUID16]; wrong { + t.Error("a 128-bit UUID was also written into the 16-bit list") + } + + // gatt keeps UUIDs little-endian on the wire, so the advertised bytes are + // the UUID reversed. + want := strings.ToUpper(hex.EncodeToString(reverseBytes(got))) + if want != strings.ToUpper(strings.ReplaceAll(o5AdvertisementUUID(0x002A1C6C, false), "-", "")) { + t.Errorf("advertised UUID is %s, want %s", want, o5AdvertisementUUID(0x002A1C6C, false)) + } +} + +// A DASH advertises nine 16-bit UUIDs, which must still go out as a 16-bit +// list and must still fit the 31-byte packet. +func TestAdvertisingStillPacksDash(t *testing.T) { + var uu []gatt.UUID + for _, u := range AdvertisedServices([]byte{0x00, 0x2a, 0x1c, 0x6d}, AdvertState{}) { + uu = append(uu, gatt.UUID16(u)) + } + + a := &gatt.AdvPacket{} + a.AppendFlags(0x06) + a.AppendUUIDFit(uu) + + fields := advFields(t, advBytes(a)) + got, ok := fields[fieldAllUUID16] + if !ok { + t.Fatalf("no 16-bit UUID list in %x (fields %v)", advBytes(a), keys(fields)) + } + if len(got) != 2*len(uu) { + t.Errorf("the 16-bit list holds %d bytes, want %d", len(got), 2*len(uu)) + } + if a.Len() > 31 { + t.Errorf("the advertisement is %d bytes, over the 31 a packet allows", a.Len()) + } +} + +// An advertisement carrying a 128-bit UUID has no room left for the pod's +// name, so the name belongs in the scan response and the packet must still +// fit. +func TestO5AdvertisementFitsWithoutTheName(t *testing.T) { + u, _ := gatt.ParseUUID(o5AdvertisementUUID(0x002A1C6C, false)) + a := &gatt.AdvPacket{} + a.AppendFlags(0x06) + a.AppendUUIDFit([]gatt.UUID{u}) + + if n := a.Len(); n > 31 { + t.Fatalf("the advertisement is %d bytes, over the 31 a packet allows", n) + } + if a.Len()+len(AdvertisedName)+2 < 31 { + t.Errorf("the name would fit after all (%d + %d), so the scan-response path is untested", + a.Len(), len(AdvertisedName)) + } +} + +func keys(m map[byte][]byte) []string { + var out []string + for k := range m { + out = append(out, hex.EncodeToString([]byte{k})) + } + return out +} + +func reverseBytes(b []byte) []byte { + out := make([]byte, len(b)) + for i := range b { + out[len(b)-1-i] = b[i] + } + return out +} diff --git a/pkg/bluetooth/gatt_linux.go b/pkg/bluetooth/gatt_linux.go index d4c188b..b06e43e 100644 --- a/pkg/bluetooth/gatt_linux.go +++ b/pkg/bluetooth/gatt_linux.go @@ -2,9 +2,9 @@ package bluetooth import ( "encoding/hex" - "errors" "fmt" "sync" + "time" "github.com/paypal/gatt" "github.com/paypal/gatt/linux/cmd" @@ -15,9 +15,11 @@ import ( type gattRadio struct { device gatt.Device central gatt.Central + profile Profile advertMtx sync.Mutex podId []byte + pdmId uint32 // the controller an Omnipod 5 advertises once paired state AdvertState cmdNotifier gatt.Notifier @@ -39,22 +41,20 @@ var DefaultServerOptions = []gatt.Option{ // NewGattRadio serves the pod from the first Bluetooth adapter. func NewGattRadio(b *Ble, podId []byte) (Radio, error) { - // An Omnipod 5 advertises one 128-bit service UUID and needs a - // 247-byte ATT MTU; the vendored gatt library advertises neither, so - // say so rather than advertise something no phone will answer. - if b.profile.Mode.IsO5() { - return nil, errors.New("-mode o5 needs -transport impossible: this Bluetooth stack advertises only 16-bit service UUIDs") - } d, err := gatt.NewDevice(DefaultServerOptions...) if err != nil { log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) } - r := &gattRadio{device: d, podId: podId} + r := &gattRadio{device: d, profile: b.profile, podId: podId} d.Handle( gatt.CentralConnected(func(c gatt.Central) { fmt.Println("pkg bluetooth; ** New connection from: ", c.ID()) r.central = c + // The phone drives the ATT MTU exchange and the pod answers with + // its own maximum; log what they settled on, since an Omnipod 5 + // packet needs 247 and a smaller one truncates pairing. + go r.reportMTU(c) b.Connected() }), gatt.CentralDisconnected(func(c gatt.Central) { @@ -68,9 +68,9 @@ func NewGattRadio(b *Ble, podId []byte) (Radio, error) { 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") + var serviceUUID = gatt.MustParseUUID(r.profile.ServiceUUID) + var cmdCharUUID = gatt.MustParseUUID(r.profile.CmdCharUUID) + var dataCharUUID = gatt.MustParseUUID(r.profile.DataCharUUID) s := gatt.NewService(serviceUUID) @@ -127,20 +127,49 @@ func NewGattRadio(b *Ble, podId []byte) (Radio, error) { func (r *gattRadio) advertise() error { r.advertMtx.Lock() - podId, state := r.podId, r.state + podId, pdmId, state := r.podId, r.pdmId, r.state r.advertMtx.Unlock() - // paypal/gatt advertises no manufacturer data. + + // paypal/gatt advertises no manufacturer data. A DASH advertises nine + // 16-bit service UUIDs; an Omnipod 5 advertises one 128-bit UUID, which + // fills the packet, so its name goes in the scan response. var uuids []gatt.UUID - for _, u := range AdvertisedServices(podId, state) { - uuids = append(uuids, gatt.UUID16(u)) + for _, s := range r.profile.AdvertisedServiceUUIDs(podId, pdmId, state) { + u, err := gatt.ParseUUID(s) + if err != nil { + return fmt.Errorf("could not parse the advertised UUID %s: %w", s, err) + } + uuids = append(uuids, u) } return r.device.AdvertiseNameAndServices(AdvertisedName, uuids) } +// reportMTU logs the ATT MTU once the phone has negotiated one. gatt starts +// a connection at the 23-byte default and raises it when the phone asks, so +// this waits briefly for the exchange. +func (r *gattRadio) reportMTU(c gatt.Central) { + const needed = 247 // an Omnipod 5 packet is 244 bytes, plus the 3-byte header + for i := 0; i < 20; i++ { + time.Sleep(100 * time.Millisecond) + if mtu := c.MTU(); mtu > 23 { + if r.profile.Mode.IsO5() && mtu < needed { + log.Warnf("pkg bluetooth; the phone negotiated an ATT MTU of %d, below the %d an Omnipod 5 packet needs; pairing will truncate", mtu, needed) + } else { + log.Infof("pkg bluetooth; the phone negotiated an ATT MTU of %d", mtu) + } + return + } + } + log.Warnf("pkg bluetooth; the phone never raised the ATT MTU above the 23-byte default") +} + // Advertise updates the advertisement; paypal/gatt needs no StopAdvertising first. func (r *gattRadio) Advertise(podId []byte, pdmId uint32) error { r.advertMtx.Lock() r.podId = podId + if pdmId != 0 { + r.pdmId = pdmId + } r.advertMtx.Unlock() err := r.advertise() if err != nil { diff --git a/vendor/github.com/paypal/gatt/adv.go b/vendor/github.com/paypal/gatt/adv.go index 73ce785..f35bd4e 100644 --- a/vendor/github.com/paypal/gatt/adv.go +++ b/vendor/github.com/paypal/gatt/adv.go @@ -197,44 +197,69 @@ func (a *AdvPacket) AppendManufacturerData(id uint16, b []byte) *AdvPacket { // AppendUUIDFit appends a BLE advertised service UUID // packet field if it fits in the packet, and reports whether the UUID fit. func (a *AdvPacket) AppendUUIDFit(uu []UUID) bool { - var b bytes.Buffer + // PATCHED for github.com/LoopKit/pod: the original wrote every UUID into + // one 16-bit list regardless of its length, so a 128-bit UUID went out + // mislabelled and no central matched it. An Omnipod 5 advertises a single + // 128-bit UUID, so the length-aware packing the upstream file left + // commented out is restored here, with its size accounting corrected: + // UUIDs of one length share a single field, so the two bytes of field + // header are paid once per list, not once per UUID. + // + // Restore this after `go mod vendor`. + var u16, u128 []UUID for _, u := range uu { - b.Write(u.b) + if u.Equal(attrGAPUUID) || u.Equal(attrGATTUUID) { + continue + } + switch u.Len() { + case 2: + u16 = append(u16, u) + case 16: + u128 = append(u128, u) + } } - a.AppendField(typeAllUUID16, b.Bytes()) - - // Iterate all UUIDs to see if they fit in the packet or not. - /* fit, l := true, len(a.b) - for _, u := range uu { - if u.Equal(attrGAPUUID) || u.Equal(attrGATTUUID) { - continue - } - l += 2 + u.Len() - if l > MaxEIRPacketLength { - fit = false - break - } + + // size is what the packet would take with the UUIDs still to be written. + size := func(n16, n128 int) int { + n := len(a.b) + if n16 > 0 { + n += 2 + 2*n16 } + if n128 > 0 { + n += 2 + 16*n128 + } + return n + } - // Append the UUIDs until they no longer fit. - for _, u := range uu { - if u.Equal(attrGAPUUID) || u.Equal(attrGATTUUID) { - continue - } - if len(a.b)+2+u.Len() > MaxEIRPacketLength { - break - } - switch l = u.Len(); { - case l == 2 && fit: - a.AppendField(typeAllUUID16, u.b) - case l == 16 && fit: - a.AppendField(typeAllUUID128, u.b) - case l == 2 && !fit: - a.AppendField(typeSomeUUID16, u.b) - case l == 16 && !fit: - a.AppendField(typeSomeUUID128, u.b) - } + // Drop UUIDs, longest first, until what is left fits. Anything dropped + // makes the lists incomplete. + fit := true + for size(len(u16), len(u128)) > MaxEIRPacketLength { + fit = false + if len(u128) > 0 { + u128 = u128[:len(u128)-1] + } else if len(u16) > 0 { + u16 = u16[:len(u16)-1] + } else { + break } - */ - return true + } + + appendList := func(list []UUID, complete, incomplete byte) { + if len(list) == 0 { + return + } + var b bytes.Buffer + for _, u := range list { + b.Write(u.b) + } + t := complete + if !fit { + t = incomplete + } + a.AppendField(t, b.Bytes()) + } + appendList(u16, typeAllUUID16, typeSomeUUID16) + appendList(u128, typeAllUUID128, typeSomeUUID128) + return fit } diff --git a/vendor/github.com/paypal/gatt/device_linux.go b/vendor/github.com/paypal/gatt/device_linux.go index f6f9fb1..5087fad 100644 --- a/vendor/github.com/paypal/gatt/device_linux.go +++ b/vendor/github.com/paypal/gatt/device_linux.go @@ -147,6 +147,12 @@ func (d *device) Advertise(a *AdvPacket) error { } func (d *device) AdvertiseNameAndServices(name string, uu []UUID) error { + // PATCHED for github.com/LoopKit/pod: the name-overflow branch declared a + // second `a` that shadowed this one, so it built the scan response and + // then advertised the shadowed-away packet. It only showed once something + // did not fit, which is every advertisement carrying a 128-bit UUID. + // + // Restore this after `go mod vendor`. a := &AdvPacket{} a.AppendFlags(flagGeneralDiscoverable | flagLEOnly) a.AppendUUIDFit(uu) @@ -155,11 +161,11 @@ func (d *device) AdvertiseNameAndServices(name string, uu []UUID) error { a.AppendName(name) d.scanResp = nil } else { - a := &AdvPacket{} - a.AppendName(name) + resp := &AdvPacket{} + resp.AppendName(name) d.scanResp = &cmd.LESetScanResponseData{ - ScanResponseDataLength: uint8(a.Len()), - ScanResponseData: a.Bytes(), + ScanResponseDataLength: uint8(resp.Len()), + ScanResponseData: resp.Bytes(), } } From 6eb3f092fb002b7df8f1a5ecd2a940e601178d5d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 19:58:09 -0500 Subject: [PATCH 5/8] Serve every pod from one published authority, and name it as a pod does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pod made itself a fresh self-signed certificate for each pairing, so there was nothing stable about it: an app could only ever fail to recognise a simulated pod, never identify one. Pods are now signed by a fixed authority whose key is in the repository on purpose, the way g7sim publishes its CA so Loop can mark a simulated G7 positively. It is a development marker and proves nothing about authenticity. That also makes the exchange faithful. A pod now shows the authority in SPS2.1 and its own leaf in SPS2, which is the shape the controller already uses, where SPS2.1 carries the intermediate and SPS2 the leaf. A real pod does the same: its SPS2.1 is Insulet's INS01PG1 and its SPS2 a leaf issued by it. The name follows a real pod too. One advertises itself as "AP FFFFFFFE 0E977731000A3AB7" — the controller it is paired to, then its lot and tid. The lot decodes as product 7, a U100 Omnipod 5, which is what the simulator already reports in its version responses, so the two now agree. A DASH keeps the name this simulator has always had. cmd/o5keypair -ca prints the authority for an app to embed. The chain test deliberately pins no validity date: a real pod's certificate was found nineteen months expired and pairing normally, so an authenticity check that honoured notAfter would flag every pod in the field. --- README.md | 21 +++++++ cmd/o5keypair/main.go | 28 +++++++++ pkg/bluetooth/advert_test.go | 33 ++++++++++ pkg/bluetooth/gatt_linux.go | 2 +- pkg/bluetooth/impossible.go | 5 +- pkg/bluetooth/profile.go | 19 ++++++ pkg/pair/o5.go | 57 +++++++++++++---- pkg/pair/simca.go | 89 +++++++++++++++++++++++++++ pkg/pair/simca_test.go | 105 ++++++++++++++++++++++++++++++++ pkg/response/versionresponse.go | 7 +++ 10 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 pkg/pair/simca.go create mode 100644 pkg/pair/simca_test.go diff --git a/README.md b/README.md index 0b57f87..5bfa991 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,27 @@ Xcode picks the file up on its own. Keep it out of git: it pairs with this simul with nothing else, because neither side validates the other's certificate chain — each only reads the public key out of the certificate to check the other's signature. +### Telling a simulated pod from a real one + +Every pod this simulator serves shows the same certificate authority in SPS2.1 +and its own certificate, signed by that authority, in SPS2 — the shape a real +pod uses, where SPS2.1 carries Insulet's intermediate and SPS2 the pod's own. + +The authority is fixed and its private key is in this repository on purpose. It +is a development marker, not a secret: an app that pins it can report a pod as +simulated rather than merely failing to recognise it, the way g7sim's published +CA lets Loop mark a simulated G7. It proves nothing about authenticity and must +never be treated as if it did. + +``` +go run ./cmd/o5keypair -ca # the CA, as hex to embed in an app +``` + +A real pod chains to `INS01PG1`, issued by `INS00PG1`, and its own certificate +may well be expired — one was found nineteen months past its notAfter and +pairing normally, because nothing checks. An authenticity check should verify +the chain and ignore validity dates. + ### Pair it with Loop Loop must come from LoopWorkspace `next-dev`, which pins the OmnipodKit that talks to a diff --git a/cmd/o5keypair/main.go b/cmd/o5keypair/main.go index 488bb50..eaed32a 100644 --- a/cmd/o5keypair/main.go +++ b/cmd/o5keypair/main.go @@ -28,6 +28,8 @@ import ( "os" "text/template" "time" + + "github.com/avereha/pod/pkg/pair" ) func main() { @@ -35,8 +37,14 @@ func main() { // two bits, the way an Omnipod 5 controller forms its own. id := flag.Uint64("id", 0x002A1C6C, "the controller id to issue the data for") asSwift := flag.Bool("swift", false, "write a Swift file to build into OmnipodKit instead of JSON") + showCA := flag.Bool("ca", false, "print the simulator's pod CA, which an app pins to recognise a simulated pod") flag.Parse() + if *showCA { + printCA() + return + } + if *id == 0 || *id > 0xFFFFFFFF { fail(fmt.Errorf("the controller id must be between 1 and 0xFFFFFFFF, not 0x%X", *id)) } @@ -60,6 +68,26 @@ func main() { fmt.Println(string(out)) } +// printCA writes the certificate authority every simulated pod chains to, in +// the form an app embeds to tell a simulated pod from a real one. It proves +// nothing about authenticity: the key is in this repository on purpose. +func printCA() { + der := pair.SimulatorCACertificate() + cert, err := x509.ParseCertificate(der) + if err != nil { + fail(err) + } + fmt.Fprintf(os.Stderr, "pod simulator CA: %s, %d bytes\n", cert.Subject.CommonName, len(der)) + h := hex.EncodeToString(der) + for i := 0; i < len(h); i += 64 { + end := i + 64 + if end > len(h) { + end = len(h) + } + fmt.Printf("\"%s\",\n", h[i:end]) + } +} + func fail(err error) { fmt.Fprintf(os.Stderr, "o5keypair: %s\n", err) os.Exit(1) diff --git a/pkg/bluetooth/advert_test.go b/pkg/bluetooth/advert_test.go index 62153ed..d3a6d03 100644 --- a/pkg/bluetooth/advert_test.go +++ b/pkg/bluetooth/advert_test.go @@ -126,3 +126,36 @@ func reverseBytes(b []byte) []byte { } return out } + +// A real Omnipod 5 names itself "AP ", captured from +// a pod as "AP FFFFFFFE 0E977731000A3AB7" while it was still unpaired. The +// simulator mirrors that shape so a pod looks like one in a scan. +func TestO5AdvertisedName(t *testing.T) { + o5 := ProfileFor(ModeO5) + + unpaired := o5.AdvertisedName(0) + if unpaired != "AP FFFFFFFE 0E146DB10006E451" { + t.Errorf("an unpaired pod is named %q", unpaired) + } + paired := o5.AdvertisedName(0x002A1C6C) + if paired != "AP 002A1C6C 0E146DB10006E451" { + t.Errorf("a paired pod is named %q", paired) + } + + // The name goes in the scan response, which is also 31 bytes: two of + // header plus the name itself. + if n := 2 + len(paired); n > 31 { + t.Errorf("the name needs %d bytes of the scan response, over 31", n) + } + + // The lot in the name has to decode as an Omnipod 5, as the real pod's + // 0E977731 does: bits 25-30 are the product, 7 for a U100 Omnipod 5. + if got := (0x0E146DB1 >> 25) & 0x3f; got != 7 { + t.Errorf("the advertised lot decodes as product %d, want 7", got) + } + + // A DASH keeps the name this simulator has always used. + if got := ProfileFor(ModeDash).AdvertisedName(0); got != AdvertisedName { + t.Errorf("a DASH is named %q, want %q", got, AdvertisedName) + } +} diff --git a/pkg/bluetooth/gatt_linux.go b/pkg/bluetooth/gatt_linux.go index b06e43e..49b6b3b 100644 --- a/pkg/bluetooth/gatt_linux.go +++ b/pkg/bluetooth/gatt_linux.go @@ -141,7 +141,7 @@ func (r *gattRadio) advertise() error { } uuids = append(uuids, u) } - return r.device.AdvertiseNameAndServices(AdvertisedName, uuids) + return r.device.AdvertiseNameAndServices(r.profile.AdvertisedName(pdmId), uuids) } // reportMTU logs the ATT MTU once the phone has negotiated one. gatt starts diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go index 947f5f1..6b6ab4c 100644 --- a/pkg/bluetooth/impossible.go +++ b/pkg/bluetooth/impossible.go @@ -288,9 +288,10 @@ func (r *simRadio) discovered() { return } uuids := r.profile.AdvertisedServiceUUIDs(podId, pdmId, state) - c.send(map[string]interface{}{"type": "didDiscover", "id": r.id, "name": AdvertisedName, "rssi": -60, + name := r.profile.AdvertisedName(pdmId) + c.send(map[string]interface{}{"type": "didDiscover", "id": r.id, "name": name, "rssi": -60, "adv": map[string]interface{}{ - "kCBAdvDataLocalName": AdvertisedName, + "kCBAdvDataLocalName": name, "kCBAdvDataIsConnectable": true, "kCBAdvDataServiceUUIDs": uuids, "kCBAdvDataManufacturerData": base64.StdEncoding.EncodeToString(state.ManufacturerData()), diff --git a/pkg/bluetooth/profile.go b/pkg/bluetooth/profile.go index a456a54..80b7863 100644 --- a/pkg/bluetooth/profile.go +++ b/pkg/bluetooth/profile.go @@ -3,6 +3,8 @@ package bluetooth import ( "encoding/binary" "fmt" + + "github.com/avereha/pod/pkg/response" ) // Mode is the kind of pod being simulated. It decides the BLE profile, the @@ -135,3 +137,20 @@ func O5PdmIdFromHello(cmd []byte) (uint32, bool) { } return binary.BigEndian.Uint32(cmd[3:7]), true } + +// AdvertisedName is the local name the pod puts in its advertisement, or in +// its scan response when a 128-bit service UUID has filled the advertisement. +// +// A real Omnipod 5 names itself "AP ", captured from +// a pod as "AP FFFFFFFE 0E977731000A3AB7" before it was paired. A DASH keeps +// the name this simulator has always used. +func (p Profile) AdvertisedName(pdmId uint32) string { + if !p.Mode.IsO5() { + return AdvertisedName + } + id := O5NotPairedPdmId + if pdmId != 0 { + id = pdmId + } + return fmt.Sprintf("AP %08X %08X%08X", id, response.LotOmnipod5, response.Tid) +} diff --git a/pkg/pair/o5.go b/pkg/pair/o5.go index dd9f108..18f4fad 100644 --- a/pkg/pair/o5.go +++ b/pkg/pair/o5.go @@ -70,9 +70,13 @@ type O5 struct { ltk []byte nonces *spsNonces - // identity is the key the pod's certificate carries and signs with. + // identity is the key the pod's leaf certificate carries and signs with. identity *ecdsa.PrivateKey - certDER []byte + // leafDER is the pod's own certificate, signed by the simulator's CA; + // caDER is that CA, which the pod shows in SPS2.1 so an app can follow + // the chain and tell a simulated pod from a real one. + leafDER []byte + caDER []byte // controllerPublic is the phone's signing key, read out of the // certificate it shows in SPS2. Signed commands are checked against it. @@ -90,7 +94,11 @@ func NewO5() (*O5, error) { if err != nil { return nil, fmt.Errorf("could not make the pod's signing key: %w", err) } - certDER, err := selfSignedCert(identity, "pod simulator pod") + if err := checkSimulatorCA(); err != nil { + return nil, err + } + caKey, caCert, caDER := simulatorCA() + leafDER, err := podCertificate(identity, caCert, caKey) if err != nil { return nil, err } @@ -103,7 +111,8 @@ func NewO5() (*O5, error) { podPublic: ecdhKey.PublicKey().Bytes()[1:], // drop the 04 prefix podNonce: nonce, identity: identity, - certDER: certDER, + leafDER: leafDER, + caDER: caDER, }, nil } @@ -209,9 +218,10 @@ func (c *O5) ParseSPS21(msg *message.Message) error { return nil } -// GenerateSPS21 shows the pod's certificate. +// GenerateSPS21 shows the authority the pod's certificate chains to, as the +// controller shows its intermediate here. func (c *O5) GenerateSPS21() (*message.Message, error) { - sealed, err := c.nonces.seal(c.conf, spsPodToPhone, c.certDER) + sealed, err := c.nonces.seal(c.conf, spsPodToPhone, c.caDER) if err != nil { return nil, fmt.Errorf("could not encrypt SPS2.1: %w", err) } @@ -262,7 +272,7 @@ func (c *O5) GenerateSPS2() (*message.Message, error) { if err != nil { return nil, err } - plain := append(append([]byte(nil), c.certDER...), signature...) + plain := append(append([]byte(nil), c.leafDER...), signature...) sealed, err := c.nonces.seal(c.conf, spsPodToPhone, plain) if err != nil { return nil, fmt.Errorf("could not encrypt SPS2: %w", err) @@ -511,10 +521,32 @@ func publicKeyFromCert(der []byte) ([]byte, error) { return out, nil } -// selfSignedCert makes the certificate a side shows during pairing. The +// podCertificate makes this pod's leaf, signed by the simulator's CA, so an +// app can follow the chain and tell a simulated pod from a real one. The // phone finds the public key by looking for the standard P-256 // SubjectPublicKeyInfo bytes, which this layout gives it // (O5CertificateStore.extractP256PublicKey). +func podCertificate(key *ecdsa.PrivateKey, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) ([]byte, error) { + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64)) + if err != nil { + return nil, err + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"pod simulator"}, + CommonName: "pod simulator pod", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + return x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) +} + +// selfSignedCert makes a standalone certificate. The controller-side key +// generator uses it; a pod's own certificate is signed by the simulator's CA +// instead, so that it chains. func selfSignedCert(key *ecdsa.PrivateKey, commonName string) ([]byte, error) { template := &x509.Certificate{ SerialNumber: big.NewInt(1), @@ -522,11 +554,10 @@ func selfSignedCert(key *ecdsa.PrivateKey, commonName string) ([]byte, error) { Organization: []string{"pod simulator"}, CommonName: commonName, }, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - IsCA: true, - BasicConstraintsValid: false, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + IsCA: true, } return x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) } diff --git a/pkg/pair/simca.go b/pkg/pair/simca.go new file mode 100644 index 0000000..4bb719c --- /dev/null +++ b/pkg/pair/simca.go @@ -0,0 +1,89 @@ +package pair + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/hex" + "fmt" + "math/big" +) + +// The simulator signs every pod it serves with one fixed certificate +// authority, so that an app can tell a simulated pod from a real one rather +// than merely failing to recognise it. +// +// This mirrors what g7sim does for the G7: its CA is published so OmnipodKit's +// counterpart can report "simulated" positively. The private key below is in +// the repository on purpose — it is a development marker, not a secret, and +// its presence is what makes a pod identifiable as this simulator's. It +// proves nothing about authenticity and must never be treated as if it did. +// +// A pod shows this certificate in SPS2.1 and its own leaf, signed by this key, +// in SPS2 — the same shape the controller uses, where SPS2.1 carries the +// intermediate and SPS2 the leaf. +const simulatorCAKeyHex = "4a960ae2864081cb8407b78c963971bfda81f46df6abe14733a22a740d4dd8da" + +var simulatorCACertHex = [...]string{ + "308201973082013da003020102020101300a06082a8648ce3d04030230333116", + "3014060355040a130d706f642073696d756c61746f7231193017060355040313", + "10706f642073696d756c61746f72204341301e170d3236303130313030303030", + "305a170d3436303130313030303030305a303331163014060355040a130d706f", + "642073696d756c61746f723119301706035504031310706f642073696d756c61", + "746f722043413059301306072a8648ce3d020106082a8648ce3d030107034200", + "0401653bc04b88ffebdc05d9d8bea3844edda6aa64241bf468cf85b503ce8108", + "5a1f0f974e0a47bff7a6c999bb31e9cf78a3d10fbd1078452fbe8d3072c97988", + "7ba3423040300e0603551d0f0101ff040403020284300f0603551d130101ff04", + "0530030101ff301d0603551d0e0416041470d1e1f42826b71f50905e2a54bf54", + "f78c984edd300a06082a8648ce3d0403020348003045022018e1132e0f591da2", + "90c4897ed63350d50b5c500efdb8b646b2bbded837100ce00221009152ff20d4", + "0f7ceb276180aa38a2d1a74911157b3c5f901ac79618effd8def38", +} + +// simulatorCA is the authority every simulated pod's certificate chains to. +var simulatorCA = func() (*ecdsa.PrivateKey, *x509.Certificate, []byte) { + scalar, err := hex.DecodeString(simulatorCAKeyHex) + if err != nil { + panic("pkg pair: the simulator CA key is not hex: " + err.Error()) + } + key := new(ecdsa.PrivateKey) + key.Curve = elliptic.P256() + key.D = new(big.Int).SetBytes(scalar) + key.PublicKey.X, key.PublicKey.Y = key.Curve.ScalarBaseMult(scalar) + + var joined string + for _, part := range simulatorCACertHex { + joined += part + } + der, err := hex.DecodeString(joined) + if err != nil { + panic("pkg pair: the simulator CA certificate is not hex: " + err.Error()) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + panic("pkg pair: the simulator CA certificate does not parse: " + err.Error()) + } + return key, cert, der +} + +// SimulatorCACertificate is the DER an app pins to recognise a pod served by +// this simulator. +func SimulatorCACertificate() []byte { + _, _, der := simulatorCA() + return append([]byte(nil), der...) +} + +// checkSimulatorCA makes sure the embedded key really is the one that signed +// the embedded certificate, so a bad edit fails loudly rather than producing +// pods nothing can verify. +func checkSimulatorCA() error { + key, cert, _ := simulatorCA() + pub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("the simulator CA certificate carries no P-256 key") + } + if pub.X.Cmp(key.PublicKey.X) != 0 || pub.Y.Cmp(key.PublicKey.Y) != 0 { + return fmt.Errorf("the simulator CA key does not match its certificate") + } + return nil +} diff --git a/pkg/pair/simca_test.go b/pkg/pair/simca_test.go new file mode 100644 index 0000000..a3dd9ed --- /dev/null +++ b/pkg/pair/simca_test.go @@ -0,0 +1,105 @@ +package pair + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "testing" +) + +func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +// The CA is fixed and in the repository so that an app can recognise a pod +// this simulator served, rather than merely failing to recognise it. A bad +// edit to either constant has to fail loudly. +func TestSimulatorCAIsSelfConsistent(t *testing.T) { + if err := checkSimulatorCA(); err != nil { + t.Fatalf("the embedded CA is inconsistent: %v", err) + } + _, cert, der := simulatorCA() + if !cert.IsCA { + t.Error("the simulator CA is not marked as a CA") + } + if cert.KeyUsage&x509.KeyUsageCertSign == 0 { + t.Error("the simulator CA cannot sign certificates") + } + if got := SimulatorCACertificate(); hex.EncodeToString(got) != hex.EncodeToString(der) { + t.Error("SimulatorCACertificate returned something other than the embedded DER") + } +} + +// Every pod this simulator serves must chain to that one CA, which is what +// lets an app report "simulated" positively. A real pod chains to Insulet's +// INS01PG1 instead. +func TestEveryPodChainsToTheSimulatorCA(t *testing.T) { + _, caCert, caDER := simulatorCA() + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(pemOf(caDER)) { + t.Fatal("could not build a pool from the simulator CA") + } + + var firstLeaf string + for i := 0; i < 3; i++ { + pod, err := NewO5() + if err != nil { + t.Fatal(err) + } + + // SPS2.1 shows the CA, SPS2 the leaf — as the controller sends its + // intermediate then its leaf. + if hex.EncodeToString(pod.caDER) != hex.EncodeToString(caDER) { + t.Fatal("the pod did not show the simulator CA in SPS2.1") + } + + leaf, err := x509.ParseCertificate(pod.leafDER) + if err != nil { + t.Fatalf("the pod's leaf does not parse: %v", err) + } + // The chain must verify. Expiry is deliberately not part of this: + // a real pod's leaf was found expired and still pairing, so an + // authenticity check must not depend on validity dates. + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, + CurrentTime: leaf.NotBefore.Add(1), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + t.Fatalf("the pod's leaf does not chain to the simulator CA: %v", err) + } + if err := leaf.CheckSignatureFrom(caCert); err != nil { + t.Errorf("the CA did not sign the pod's leaf: %v", err) + } + + // The key the pod signs SPS2 with is the one in its leaf. + leafKey, ok := leaf.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatal("the pod's leaf carries no P-256 key") + } + if leafKey.X.Cmp(pod.identity.PublicKey.X) != 0 || leafKey.Y.Cmp(pod.identity.PublicKey.Y) != 0 { + t.Error("the pod's leaf does not carry the key it signs with") + } + + // Each pod is its own, so a leaf never identifies the simulator by + // being identical to another's. + if i == 0 { + firstLeaf = hex.EncodeToString(pod.leafDER) + } else if hex.EncodeToString(pod.leafDER) == firstLeaf { + t.Error("two pods were served the same leaf certificate") + } + } +} + +func pemOf(der []byte) []byte { + out := []byte("-----BEGIN CERTIFICATE-----\n") + b64 := base64Encode(der) + for i := 0; i < len(b64); i += 64 { + end := i + 64 + if end > len(b64) { + end = len(b64) + } + out = append(out, b64[i:end]...) + out = append(out, '\n') + } + return append(out, []byte("-----END CERTIFICATE-----\n")...) +} diff --git a/pkg/response/versionresponse.go b/pkg/response/versionresponse.go index b0ee403..237355f 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -28,6 +28,10 @@ const ( LotOmnipod5 uint32 = 0x0E146DB1 // decodes as H1 ) +// Tid is the pod's sequence number within its lot, the second half of the +// identity an Omnipod 5 advertises in its name. +const Tid uint32 = 0x0006E451 + // Product is which pod the version responses report. The zero value is a // DASH, which is what the simulator was before it could be anything else. type Product struct { @@ -35,6 +39,9 @@ type Product struct { Lot uint32 } +// Tid is the sequence number that goes with this product's lot. +func (p Product) Tid() uint32 { return Tid } + // DashProduct and Omnipod5Product are the two the simulator serves. func DashProduct() Product { return Product{ID: ProductIDDash, Lot: LotDash} } func Omnipod5Product() Product { return Product{ID: ProductIDOmnipod5, Lot: LotOmnipod5} } From d24fba2b06e5b6322cd7b902f8bf61bc10ed559f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 23:20:25 -0500 Subject: [PATCH 6/8] Refuse a link too small to carry an Omnipod 5 packet, and let the adapter be named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Omnipod 5 packet is 244 bytes and needs a 247-byte ATT MTU. A phone asks for one that large only of a Bluetooth 4.2 or later radio and asks for 185 of anything older, so a Pi 3B's own 4.1 radio cannot serve one. The phone fragments a message into packets but never fragments a packet, so on a short link every write is silently truncated: pairing used to get through SP1/SP2, SPS0 and SPS1 — all of which fit — and then stall at SPS2.1, the first message that needs more than one packet, leaving the phone to report a timeout with nothing in the pod log to explain it. The pod now refuses such a link as soon as the phone has written, naming what to do about it, instead of warning at connect time and hanging later. Radio grows LinkMTU, which gatt answers from the central and ImpossiBLE answers 0 to, having no ATT MTU of its own, and Ble.MTUShortfall turns that into the refusal using the profile's own packet size, so a DASH computes 23 and never trips. -hci names the adapter to serve from, since a 4.2+ dongle in an older Pi will not be hci0 and gatt otherwise takes the first adapter with LE support. The README covers which boards work, how to check a radio and what to try. --- README.md | 60 +++++++++++++++++++++++-- main.go | 3 +- pkg/bluetooth/bluetooth.go | 26 +++++++++++ pkg/bluetooth/bluetooth_test.go | 46 ++++++++++++++++++++ pkg/bluetooth/gatt_linux.go | 77 +++++++++++++++++++++++---------- pkg/bluetooth/gatt_other.go | 2 +- pkg/bluetooth/impossible.go | 3 ++ pkg/bluetooth/o5_link_test.go | 1 + pkg/pod/o5.go | 11 ++++- pkg/pod/o5_test.go | 1 + pkg/pod/pod_test.go | 1 + 11 files changed, 201 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5bfa991..90db216 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ Fake pod implementation Requirements: 1. Version of iOS code (Loop app) that will interact with this simulator - Loop dev branch or FreeAPS freeaps_dev branch -2. Raspberry pi with Bluetooth BLE (using a pi3b or pi4 right now) +2. Raspberry pi with Bluetooth BLE (using a pi3b or pi4 right now). For `-mode o5` the + radio must be Bluetooth 4.2 or later, which a pi3b's is not; see + [An Omnipod 5 over real Bluetooth needs a 4.2+ radio](#an-omnipod-5-over-real-bluetooth-needs-a-42-radio) 3. The user must have sudo privilege on the pi 4. Install the go language on your device (search internet for procedure) @@ -118,7 +120,9 @@ file, so restarting the simulator does not change the pod the phone paired with; different `-mode` is then ignored with a warning, and `-fresh` starts a new pod. ``` -./pod -transport impossible -mode o5 -fresh +./pod -transport impossible -mode o5 -fresh # an app in the iOS Simulator +./pod -mode o5 -fresh # a phone, over a 4.2+ Bluetooth adapter +./pod -mode o5 -fresh -hci 1 # ... over the dongle on hci1 ``` An Omnipod 5 differs from a DASH in what it advertises and how it talks: @@ -141,8 +145,56 @@ An Omnipod 5 differs from a DASH in what it advertises and how it talks: The control panel shows the pod type and the controller alongside the rest. -`-mode o5` needs `-transport impossible`: the Linux Bluetooth stack here advertises only -16-bit service UUIDs and does not negotiate the 247-byte ATT MTU an Omnipod 5 packet needs. +### An Omnipod 5 over real Bluetooth needs a 4.2+ radio + +Either transport serves an Omnipod 5, but a real Bluetooth adapter has to be new enough. +An Omnipod 5 packet is 244 bytes and needs a 247-byte ATT MTU; a phone asks for one that +large only of a **Bluetooth 4.2 or later** radio, and asks for 185 of anything older. The +phone fragments a message into packets but never fragments a packet, so on a short link +every write is silently truncated. Pairing then fails at SPS2.1, the first message that +needs more than one packet. The pod refuses such a link rather than stall on it, and says +so in the log. + +This rules out a Pi 3B's or Zero W's own radio, and the Pi 3B+, 4 and 5 are fine: + +| Board | Bluetooth | Omnipod 5 over real Bluetooth | +| --- | --- | --- | +| Pi 3B, Zero W | 4.1 | no — needs a dongle | +| Pi 3B+ | 4.2 | yes | +| Pi 4, Pi 5 | 5.0 | yes | + +Check what you have, and what a phone settled on: + +``` +hciconfig hci0 version # "HCI Version: 4.1" is too old +sudo hcitool -i hci0 lefeatures # look for LE Data Packet Length Extension +``` + +The simulator logs the MTU as soon as the phone has negotiated one: + +``` +INFO pkg bluetooth; the phone negotiated an ATT MTU of 256 +ERROR pkg bluetooth; the phone negotiated an ATT MTU of 185, below the 247 an Omnipod 5 ... +``` + +On an older radio, a **Bluetooth 4.2+ USB dongle** is the fix. It will not be `hci0`, and +the simulator takes the first adapter with LE support unless told otherwise, so name it: + +``` +hciconfig # find the dongle, usually hci1 +./pod -mode o5 -fresh -hci 1 +``` + +On a 4.2+ radio that still settles on 185, telling the controller to offer a long data +length at connection setup is worth a try (the command does not exist before 4.2): + +``` +sudo hcitool -i hci0 cmd 0x08 0x0024 fb 00 48 03 # LE Write Suggested Default Data Length +``` + +Failing all of that, `-transport impossible` serves an app in the iOS Simulator over a +socket, which carries whole packets and has no ATT MTU, so the radio stops mattering — +see [Run against the iOS Simulator](#run-against-the-ios-simulator). ### Registration data for the app diff --git a/main.go b/main.go index 8a0540a..44056cf 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ func main() { var apiAddr = flag.String("api", ":8080", "address of the control panel and its API") var transport = flag.String("transport", "ble", "ble (a Linux Bluetooth adapter) or impossible (ImpossiBLE's socket, for an app in the iOS Simulator)") var socket = flag.String("socket", "/tmp/impossible.sock", "ImpossiBLE socket for -transport impossible") + var hci = flag.Int("hci", -1, "Bluetooth adapter to serve the pod from: 0 for hci0, -1 for the first with LE support. An Omnipod 5 needs a Bluetooth 4.2 or later radio, so name the dongle rather than a Pi 3B's own") var modeName = flag.String("mode", string(bluetooth.ModeDash), "the pod to simulate: dash or o5 (Omnipod 5)") flag.Parse() @@ -71,7 +72,7 @@ func main() { var attach func(*bluetooth.Ble) (bluetooth.Radio, error) switch *transport { case "ble": - attach = func(b *bluetooth.Ble) (bluetooth.Radio, error) { return bluetooth.NewGattRadio(b, state.Id) } + attach = func(b *bluetooth.Ble) (bluetooth.Radio, error) { return bluetooth.NewGattRadio(b, state.Id, *hci) } case "impossible": attach = func(b *bluetooth.Ble) (bluetooth.Radio, error) { return bluetooth.NewSimRadio(b, *socket, state.PeripheralID, state.Id) diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index e8c3adc..3961c41 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -37,6 +37,9 @@ type Radio interface { Advertise(podId []byte, pdmId uint32) error AdvertiseState(state AdvertState) error Disconnect() + // LinkMTU is the ATT MTU the phone negotiated, or 0 when the + // transport has none to report. + LinkMTU() int } const AdvertisedName = " :: Fake POD ::" @@ -338,6 +341,29 @@ func (b *Ble) ShutdownConnection() { b.radio.Disconnect() } +// MTUShortfall reports a link too small to carry a packet of this profile. +// The phone fragments a message into packets but does not fragment a packet, +// so a short link truncates every write silently, and pairing stalls on the +// first message that needs more than one packet. Returns nil when the link +// is big enough, and when the radio has no MTU to report. +func (b *Ble) MTUShortfall() error { + mtu := b.radio.LinkMTU() + if mtu == 0 { + return nil + } + needed := b.profile.Layout.MaxPayloadSize + 3 // the 3-byte ATT write header + if mtu >= needed { + return nil + } + return fmt.Errorf("the phone negotiated an ATT MTU of %d, below the %d an %s packet needs, "+ + "so its writes would be truncated. A phone asks for more only of a Bluetooth 4.2 or "+ + "later radio: check this one with `hciconfig hci0 version`, and if it is older, plug "+ + "in a 4.2+ dongle and name it with -hci (it will not be hci0). On a 4.2+ radio, "+ + "`sudo hcitool -i hci0 cmd 0x08 0x0024 fb 00 48 03` is worth a try. Or run the app in "+ + "the iOS Simulator over -transport impossible, which has no MTU", + mtu, needed, b.profile.Mode.Description()) +} + func (b *Ble) WriteMessage(message *message.Message) { b.messageOutput <- message } diff --git a/pkg/bluetooth/bluetooth_test.go b/pkg/bluetooth/bluetooth_test.go index 396e173..15b8a42 100644 --- a/pkg/bluetooth/bluetooth_test.go +++ b/pkg/bluetooth/bluetooth_test.go @@ -39,6 +39,7 @@ func (w *wire) NotifyData(p Packet) error { func (w *wire) Advertise([]byte, uint32) error { return nil } func (w *wire) AdvertiseState(AdvertState) error { return nil } func (w *wire) Disconnect() {} +func (w *wire) LinkMTU() int { return 0 } func newEnd(t *testing.T, w *wire) *Ble { b, err := New(func(*Ble) (Radio, error) { return w, nil }, ProfileFor(ModeDash)) @@ -104,3 +105,48 @@ func TestANewConnectionReadsOnlyItsOwnPackets(t *testing.T) { t.Fatalf("read payload % x, want % x", got.Payload, sent.Payload) } } + +// mtuRadio reports a fixed ATT MTU and goes nowhere else. +type mtuRadio struct{ mtu int } + +func (mtuRadio) NotifyCmd(Packet) error { return nil } +func (mtuRadio) NotifyData(Packet) error { return nil } +func (mtuRadio) Advertise([]byte, uint32) error { return nil } +func (mtuRadio) AdvertiseState(AdvertState) error { return nil } +func (mtuRadio) Disconnect() {} +func (r mtuRadio) LinkMTU() int { return r.mtu } + +// A phone fragments a message into packets but never fragments a packet, so +// a link that cannot carry one truncates every write silently. The pod +// refuses to pair over such a link rather than stall on the first message +// that needs more than one packet. +func TestMTUShortfall(t *testing.T) { + for _, c := range []struct { + name string + mode Mode + mtu int + short bool + }{ + {"what a Bluetooth 4.0 adapter got from an iPhone", ModeO5, 185, true}, + {"the 23-byte ATT default", ModeO5, 23, true}, + {"exactly a packet and its header", ModeO5, 247, false}, + {"what a Pi 4's own radio got from an iPhone 11", ModeO5, 256, false}, + {"a DASH packet fits the ATT default", ModeDash, 23, false}, + {"a radio with no MTU to report", ModeO5, 0, false}, + } { + t.Run(c.name, func(t *testing.T) { + radio := mtuRadio{c.mtu} + b, err := New(func(*Ble) (Radio, error) { return radio, nil }, ProfileFor(c.mode)) + if err != nil { + t.Fatal(err) + } + err = b.MTUShortfall() + if c.short && err == nil { + t.Fatalf("an MTU of %d carries no %s packet, but it was accepted", c.mtu, c.mode) + } + if !c.short && err != nil { + t.Fatalf("an MTU of %d was refused: %s", c.mtu, err) + } + }) + } +} diff --git a/pkg/bluetooth/gatt_linux.go b/pkg/bluetooth/gatt_linux.go index 49b6b3b..0f735a4 100644 --- a/pkg/bluetooth/gatt_linux.go +++ b/pkg/bluetooth/gatt_linux.go @@ -27,21 +27,32 @@ type gattRadio struct { dataNotifier gatt.Notifier dataNotifierMtx sync.Mutex + + mtuMtx sync.Mutex + linkMTU int // what reportMTU saw the phone settle on; 0 until it has } -var DefaultServerOptions = []gatt.Option{ - gatt.LnxMaxConnections(1), - gatt.LnxDeviceID(-1, true), - gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ - AdvertisingIntervalMin: 0x00f4, - AdvertisingIntervalMax: 0x00f4, - AdvertisingChannelMap: 0x7, - }), +// ServerOptions is how the pod serves itself from adapter hci, or from +// the first one with LE support when n is negative. +func ServerOptions(hci int) []gatt.Option { + return []gatt.Option{ + gatt.LnxMaxConnections(1), + gatt.LnxDeviceID(hci, true), + gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ + AdvertisingIntervalMin: 0x00f4, + AdvertisingIntervalMax: 0x00f4, + AdvertisingChannelMap: 0x7, + }), + } } -// NewGattRadio serves the pod from the first Bluetooth adapter. -func NewGattRadio(b *Ble, podId []byte) (Radio, error) { - d, err := gatt.NewDevice(DefaultServerOptions...) +// NewGattRadio serves the pod from Bluetooth adapter hci, or from the +// first one with LE support when hci is negative. Which adapter matters: +// an Omnipod 5 needs a 247-byte ATT MTU, which a phone asks for only of a +// Bluetooth 4.2 or later radio, so a Pi 3B's own 4.1 radio will not do and +// a dongle has to be named here rather than left to chance. +func NewGattRadio(b *Ble, podId []byte, hci int) (Radio, error) { + d, err := gatt.NewDevice(ServerOptions(hci)...) if err != nil { log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) } @@ -52,8 +63,9 @@ func NewGattRadio(b *Ble, podId []byte) (Radio, error) { fmt.Println("pkg bluetooth; ** New connection from: ", c.ID()) r.central = c // The phone drives the ATT MTU exchange and the pod answers with - // its own maximum; log what they settled on, since an Omnipod 5 + // its own maximum; record what they settled on, since an Omnipod 5 // packet needs 247 and a smaller one truncates pairing. + r.setLinkMTU(0) go r.reportMTU(c) b.Connected() }), @@ -144,25 +156,44 @@ func (r *gattRadio) advertise() error { return r.device.AdvertiseNameAndServices(r.profile.AdvertisedName(pdmId), uuids) } -// reportMTU logs the ATT MTU once the phone has negotiated one. gatt starts -// a connection at the 23-byte default and raises it when the phone asks, so -// this waits briefly for the exchange. +// reportMTU records and logs the ATT MTU once the phone has negotiated one. +// gatt starts a connection at the 23-byte default and raises it when the +// phone asks, so this waits briefly for the exchange. A link too small to +// carry a packet only fails later, when the pod refuses to pair over it, +// so say so here too: this is the first moment it is knowable. func (r *gattRadio) reportMTU(c gatt.Central) { - const needed = 247 // an Omnipod 5 packet is 244 bytes, plus the 3-byte header + needed := r.profile.Layout.MaxPayloadSize + 3 // the 3-byte ATT write header for i := 0; i < 20; i++ { time.Sleep(100 * time.Millisecond) - if mtu := c.MTU(); mtu > 23 { - if r.profile.Mode.IsO5() && mtu < needed { - log.Warnf("pkg bluetooth; the phone negotiated an ATT MTU of %d, below the %d an Omnipod 5 packet needs; pairing will truncate", mtu, needed) - } else { - log.Infof("pkg bluetooth; the phone negotiated an ATT MTU of %d", mtu) - } - return + mtu := c.MTU() + if mtu <= 23 { + continue + } + r.setLinkMTU(mtu) + if mtu < needed { + log.Errorf("pkg bluetooth; the phone negotiated an ATT MTU of %d, below the %d an %s packet needs; the pod will refuse to pair over it", mtu, needed, r.profile.Mode.Description()) + } else { + log.Infof("pkg bluetooth; the phone negotiated an ATT MTU of %d", mtu) } + return } + r.setLinkMTU(c.MTU()) log.Warnf("pkg bluetooth; the phone never raised the ATT MTU above the 23-byte default") } +func (r *gattRadio) setLinkMTU(mtu int) { + r.mtuMtx.Lock() + r.linkMTU = mtu + r.mtuMtx.Unlock() +} + +// LinkMTU is what the phone negotiated, or 0 before it has. +func (r *gattRadio) LinkMTU() int { + r.mtuMtx.Lock() + defer r.mtuMtx.Unlock() + return r.linkMTU +} + // Advertise updates the advertisement; paypal/gatt needs no StopAdvertising first. func (r *gattRadio) Advertise(podId []byte, pdmId uint32) error { r.advertMtx.Lock() diff --git a/pkg/bluetooth/gatt_other.go b/pkg/bluetooth/gatt_other.go index 6ab53dc..d70131e 100644 --- a/pkg/bluetooth/gatt_other.go +++ b/pkg/bluetooth/gatt_other.go @@ -5,6 +5,6 @@ package bluetooth import "errors" -func NewGattRadio(b *Ble, podId []byte) (Radio, error) { +func NewGattRadio(b *Ble, podId []byte, hci int) (Radio, error) { return nil, errors.New("the Bluetooth transport needs Linux; use -transport impossible for the iOS Simulator") } diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go index 6b6ab4c..0a142a6 100644 --- a/pkg/bluetooth/impossible.go +++ b/pkg/bluetooth/impossible.go @@ -397,6 +397,9 @@ func (r *simRadio) AdvertiseState(state AdvertState) error { return nil } +// LinkMTU is 0: ImpossiBLE carries whole packets, so it has no ATT MTU. +func (r *simRadio) LinkMTU() int { return 0 } + // Disconnect is the pod hanging up. func (r *simRadio) Disconnect() { r.mu.Lock() diff --git a/pkg/bluetooth/o5_link_test.go b/pkg/bluetooth/o5_link_test.go index 30946ea..f191d3d 100644 --- a/pkg/bluetooth/o5_link_test.go +++ b/pkg/bluetooth/o5_link_test.go @@ -32,6 +32,7 @@ func (w *phoneWire) NotifyData(p Packet) error { func (w *phoneWire) Advertise([]byte, uint32) error { return nil } func (w *phoneWire) AdvertiseState(AdvertState) error { return nil } func (w *phoneWire) Disconnect() {} +func (w *phoneWire) LinkMTU() int { return 0 } func (w *phoneWire) nextCmd(t *testing.T) Packet { t.Helper() diff --git a/pkg/pod/o5.go b/pkg/pod/o5.go index 2ec0c01..eee60f3 100644 --- a/pkg/pod/o5.go +++ b/pkg/pod/o5.go @@ -36,11 +36,20 @@ func (p *Pod) startO5Activation() { {"SP0GP0", o5.ParseSP0GP0, o5.GenerateP0}, } - for _, step := range steps { + for i, step := range steps { msg, ok := p.readSetup() if !ok { return } + if i == 0 { + // The phone has written, so its ATT MTU exchange is behind us. + // Refuse a link too small to carry a packet rather than stall on + // SPS2.1, the first message that needs more than one. + if err := p.ble.MTUShortfall(); err != nil { + p.startOver("%s", err) + return + } + } if err := step.parse(msg); err != nil { p.startOver("error parsing %s: %s", step.name, err) return diff --git a/pkg/pod/o5_test.go b/pkg/pod/o5_test.go index 4a4e9bc..83b7446 100644 --- a/pkg/pod/o5_test.go +++ b/pkg/pod/o5_test.go @@ -25,6 +25,7 @@ func (silentRadio) NotifyData(bluetooth.Packet) error { return nil } func (silentRadio) Advertise([]byte, uint32) error { return nil } func (silentRadio) AdvertiseState(bluetooth.AdvertState) error { return nil } func (silentRadio) Disconnect() {} +func (silentRadio) LinkMTU() int { return 0 } func testPod(t *testing.T, mode bluetooth.Mode) *Pod { t.Helper() diff --git a/pkg/pod/pod_test.go b/pkg/pod/pod_test.go index 80290ba..de5b670 100644 --- a/pkg/pod/pod_test.go +++ b/pkg/pod/pod_test.go @@ -44,6 +44,7 @@ func (r *radio) NotifyData(p bluetooth.Packet) error { func (r *radio) Advertise([]byte, uint32) error { return nil } func (r *radio) AdvertiseState(bluetooth.AdvertState) error { return nil } +func (r *radio) LinkMTU() int { return 0 } func (r *radio) Disconnect() { select { From fd487c13e7a68a2a83f8ea0d6739c88993ad65b3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 23:30:11 -0500 Subject: [PATCH 7/8] Judge a radio by the version it reports, not the board it is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boards both called a Pi 4 were found side by side, one pairing an Omnipod 5 and one failing at SPS2.1: one reports Cypress 5.0 revision 0x17e, the other Broadcom 4.1 revision 0x0. So the board model predicts nothing about the radio, and the reported version is what has to be measured. Such a gap has two causes needing opposite remedies — a genuinely older Broadcom part, or the Cypress one with firmware missing or outdated, which still comes up and works but without the 4.2 features a 247-byte ATT MTU depends on. Say how to tell them apart from dmesg, which names the part and whether the patch loaded, and give the remedy for each. --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 90db216..defd101 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Fake pod implementation Requirements: 1. Version of iOS code (Loop app) that will interact with this simulator - Loop dev branch or FreeAPS freeaps_dev branch 2. Raspberry pi with Bluetooth BLE (using a pi3b or pi4 right now). For `-mode o5` the - radio must be Bluetooth 4.2 or later, which a pi3b's is not; see + radio must report Bluetooth 4.2 or later, which not every pi does; see [An Omnipod 5 over real Bluetooth needs a 4.2+ radio](#an-omnipod-5-over-real-bluetooth-needs-a-42-radio) 3. The user must have sudo privilege on the pi 4. Install the go language on your device (search internet for procedure) @@ -155,21 +155,62 @@ every write is silently truncated. Pairing then fails at SPS2.1, the first messa needs more than one packet. The pod refuses such a link rather than stall on it, and says so in the log. -This rules out a Pi 3B's or Zero W's own radio, and the Pi 3B+, 4 and 5 are fine: +What matters is the version the controller *reports*, not the board it is on. Measure it; +do not infer it from the model. A Pi 3B's own radio reports 4.1 and will not do, a Pi 3B+, +4 or 5 is usually 4.2 or 5.0, but a Pi 4 has been seen reporting 4.1 — the Pi's Bluetooth +firmware decides, so two of the same board can differ. -| Board | Bluetooth | Omnipod 5 over real Bluetooth | -| --- | --- | --- | -| Pi 3B, Zero W | 4.1 | no — needs a dongle | -| Pi 3B+ | 4.2 | yes | -| Pi 4, Pi 5 | 5.0 | yes | +``` +cat /proc/device-tree/model # which board this is +hciconfig hci0 version # "HCI Version: 4.1" is too old; 4.2 or later is wanted +sudo hcitool -i hci0 lefeatures # look for LE Data Packet Length Extension +``` -Check what you have, and what a phone settled on: +If the board is supposed to be newer than the version it reports, find out which radio it +actually has before buying anything. Two boards both called a Pi 4 were seen reporting +differently, one pairing an Omnipod 5 and one failing at SPS2.1: ``` -hciconfig hci0 version # "HCI Version: 4.1" is too old -sudo hcitool -i hci0 lefeatures # look for LE Data Packet Length Extension +HCI Version: 5.0 (0x9) Revision: 0x17e Manufacturer: Cypress Semiconductor (305) # paired +HCI Version: 4.1 (0x7) Revision: 0x0 Manufacturer: Broadcom Corporation (15) # did not ``` +That gap has two possible causes and they need opposite remedies, so identify it rather +than guess. `dmesg` settles it, because it names the part and says whether the kernel's +firmware patch loaded: + +``` +cat /proc/device-tree/model; grep Revision /proc/cpuinfo +sudo dmesg | grep -iE "bcm|bluetooth|firmware" +``` + +* **`BCM43430A1`/`BCM43438`** is a Broadcom 4.1 radio — a Pi 3B or Zero W class part, whatever + the board is called. It cannot serve an Omnipod 5, so use a dongle or another board. +* **`BCM4345C0`** is the Cypress part in a 3B+, 4, 5 or CM4, and should report 4.2 or later. + If it reports less, or `dmesg` shows the patch failing to load, that is firmware: it comes + from the `bluez-firmware` package, and an older one carries an `.hcd` predating 4.2 + support. + +``` +ls -l /lib/firmware/brcm/BCM4345C0*.hcd +dpkg -s bluez-firmware | grep -i Version +sudo apt update && sudo apt full-upgrade && sudo reboot +hciconfig hci0 version +``` + +The kernel asks for a device-tree-specific name first, such as +`brcm/BCM4345C0.raspberrypi,4-model-b.hcd`, and only then the plain `brcm/BCM4345C0.hcd`. +If `dmesg` reports the specific name missing while the plain file is there, give it the +name it asked for: + +``` +sudo cp /lib/firmware/brcm/BCM4345C0.hcd \ + "/lib/firmware/brcm/BCM4345C0.raspberrypi,4-model-b.hcd" # the name from dmesg +``` + +`hciuart.service` is what attaches the adapter on a Pi, so check it is not disabled — the +instructions further down stop `bluetooth`, which is the daemon, not this. + The simulator logs the MTU as soon as the phone has negotiated one: ``` @@ -177,7 +218,7 @@ INFO pkg bluetooth; the phone negotiated an ATT MTU of 256 ERROR pkg bluetooth; the phone negotiated an ATT MTU of 185, below the 247 an Omnipod 5 ... ``` -On an older radio, a **Bluetooth 4.2+ USB dongle** is the fix. It will not be `hci0`, and +On a radio that still reports older than 4.2, a **Bluetooth 4.2+ USB dongle** is the fix. It will not be `hci0`, and the simulator takes the first adapter with LE support unless told otherwise, so name it: ``` From 87bc2dae6a4dd0e7114ba4b5bbdbddc2a2d321ac Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Oct 2026 23:49:11 -0500 Subject: [PATCH 8/8] Say where a Pi's Bluetooth firmware comes from when its distribution omits it A Pi 4 that could not pair an Omnipod 5 turned out to hold the same Cypress BCM4345C0 as one that could, running ROM because none of the four patch filenames the kernel tries was present: not an older radio, and not an outdated patch, but no patch at all. Its distribution was Ubuntu, which unlike Raspberry Pi OS does not carry the file, so the advice to update bluez-firmware was no use there. Name the dmesg lines that show a radio on ROM, say the file is distribution dependent, and give the one command that puts it in place anywhere. --- README.md | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index defd101..b0e4322 100644 --- a/README.md +++ b/README.md @@ -186,27 +186,23 @@ sudo dmesg | grep -iE "bcm|bluetooth|firmware" * **`BCM43430A1`/`BCM43438`** is a Broadcom 4.1 radio — a Pi 3B or Zero W class part, whatever the board is called. It cannot serve an Omnipod 5, so use a dongle or another board. -* **`BCM4345C0`** is the Cypress part in a 3B+, 4, 5 or CM4, and should report 4.2 or later. - If it reports less, or `dmesg` shows the patch failing to load, that is firmware: it comes - from the `bluez-firmware` package, and an older one carries an `.hcd` predating 4.2 - support. +* **`BCM4345C0`** is the Cypress part in a 3B+, 4, 5 or CM4, and reports 4.2 or later once + its firmware patch has loaded. `dmesg` saying `BCM: firmware Patch file not found` — or + `build 0000` in the line above it — means the radio is running ROM, which still works but + reports 4.1. The file is not on every distribution: Raspberry Pi OS ships it in + `bluez-firmware`, while Ubuntu's Pi images do not carry it at all. Fetch it and reboot: ``` -ls -l /lib/firmware/brcm/BCM4345C0*.hcd -dpkg -s bluez-firmware | grep -i Version -sudo apt update && sudo apt full-upgrade && sudo reboot -hciconfig hci0 version +sudo mkdir -p /lib/firmware/brcm +sudo curl -fsSL -o /lib/firmware/brcm/BCM4345C0.hcd \ + https://github.com/RPi-Distro/bluez-firmware/raw/master/broadcom/BCM4345C0.hcd +sudo reboot +hciconfig hci0 version # Cypress Semiconductor (305), 4.2 or later, revision not 0x0 ``` -The kernel asks for a device-tree-specific name first, such as -`brcm/BCM4345C0.raspberrypi,4-model-b.hcd`, and only then the plain `brcm/BCM4345C0.hcd`. -If `dmesg` reports the specific name missing while the plain file is there, give it the -name it asked for: - -``` -sudo cp /lib/firmware/brcm/BCM4345C0.hcd \ - "/lib/firmware/brcm/BCM4345C0.raspberrypi,4-model-b.hcd" # the name from dmesg -``` +The kernel tries a device-tree-specific name such as +`brcm/BCM4345C0.raspberrypi,4-model-b.hcd` before the plain one, so the plain file is +enough; `dmesg` lists every name it tried. `hciuart.service` is what attaches the adapter on a Pi, so check it is not disabled — the instructions further down stop `bluetooth`, which is the daemon, not this.