diff --git a/README.md b/README.md index 6ed05a2..b0e4322 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ 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) +2. Raspberry pi with Bluetooth BLE (using a pi3b or pi4 right now). For `-mode o5` the + 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) @@ -64,6 +68,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 +113,183 @@ 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 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: + +* 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. + +### 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. + +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. + +``` +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 +``` + +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: + +``` +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 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: + +``` +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 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. + +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 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: + +``` +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 + +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. + +### 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 +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..eaed32a --- /dev/null +++ b/cmd/o5keypair/main.go @@ -0,0 +1,210 @@ +// 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" + + "github.com/avereha/pod/pkg/pair" +) + +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") + 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)) + } + 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)) +} + +// 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) +} + +// 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..44056cf 100644 --- a/main.go +++ b/main.go @@ -20,9 +20,16 @@ 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() + mode, err := bluetooth.ParseMode(*modeName) + if err != nil { + log.Fatalf("%s", err) + } + if *traceLevel { log.SetLevel(log.TraceLevel) } else if *infoLevel { @@ -32,21 +39,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() } @@ -56,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) @@ -64,7 +80,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..87b06f0 --- /dev/null +++ b/pkg/aid/aid_test.go @@ -0,0 +1,157 @@ +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) +} + +// 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/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/advert_test.go b/pkg/bluetooth/advert_test.go new file mode 100644 index 0000000..d3a6d03 --- /dev/null +++ b/pkg/bluetooth/advert_test.go @@ -0,0 +1,161 @@ +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 +} + +// 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/bluetooth.go b/pkg/bluetooth/bluetooth.go index b46aa24..3961c41 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "errors" "fmt" - "hash/crc32" "sync" "time" @@ -24,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: @@ -31,9 +34,12 @@ 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() + // LinkMTU is the ATT MTU the phone negotiated, or 0 when the + // transport has none to report. + LinkMTU() int } const AdvertisedName = " :: Fake POD ::" @@ -120,6 +126,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 @@ -129,8 +139,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), @@ -222,9 +233,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) @@ -309,11 +341,43 @@ 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 } 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: @@ -331,6 +395,12 @@ func (b *Ble) loop(stop chan bool, in *input) { 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 @@ -345,6 +415,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 + } } } } @@ -373,180 +457,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 len(cmd) == 0 || cmd[0] != expected[0] { - return fmt.Errorf("expected command: %x. received command: %x", 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 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..15b8a42 100644 --- a/pkg/bluetooth/bluetooth_test.go +++ b/pkg/bluetooth/bluetooth_test.go @@ -36,12 +36,13 @@ 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 (w *wire) LinkMTU() int { return 0 } 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) } @@ -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 8456ffc..0f735a4 100644 --- a/pkg/bluetooth/gatt_linux.go +++ b/pkg/bluetooth/gatt_linux.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "sync" + "time" "github.com/paypal/gatt" "github.com/paypal/gatt/linux/cmd" @@ -14,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 @@ -24,30 +27,46 @@ 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) } - 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; 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() }), gatt.CentralDisconnected(func(c gatt.Central) { @@ -61,9 +80,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) @@ -120,20 +139,68 @@ 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) + return r.device.AdvertiseNameAndServices(r.profile.AdvertisedName(pdmId), uuids) +} + +// 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) { + needed := r.profile.Layout.MaxPayloadSize + 3 // the 3-byte ATT write header + for i := 0; i < 20; i++ { + time.Sleep(100 * time.Millisecond) + 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) error { +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/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 75a8755..0a142a6 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,18 +282,16 @@ 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)) - } - c.send(map[string]interface{}{"type": "didDiscover", "id": r.id, "name": AdvertisedName, "rssi": -60, + uuids := r.profile.AdvertisedServiceUUIDs(podId, pdmId, state) + 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()), @@ -312,7 +308,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 +319,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 +338,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 +350,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 +371,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 @@ -398,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/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..f191d3d --- /dev/null +++ b/pkg/bluetooth/o5_link_test.go @@ -0,0 +1,225 @@ +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) LinkMTU() int { return 0 } + +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..80b7863 --- /dev/null +++ b/pkg/bluetooth/profile.go @@ -0,0 +1,156 @@ +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 +// 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 +} + +// 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/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/eap/eap_test.go b/pkg/eap/eap_test.go index 79c199e..b85f263 100644 --- a/pkg/eap/eap_test.go +++ b/pkg/eap/eap_test.go @@ -1,6 +1,8 @@ package eap import ( + "encoding/hex" + "github.com/wmnsk/milenage" "testing" "github.com/google/go-cmp/cmp" @@ -86,3 +88,67 @@ func TestEapAka_MarshalUnmarshal(t *testing.T) { }) } } + +// challengeAutn builds the AUTN a controller sends for a given sequence +// number, the way OmnipodKit's SessionEstablisher does: SQN^AK, the AMF, then +// MAC-A over the lot. +func challengeAutn(t *testing.T, k, rand []byte, sqn uint64, amf uint16) []byte { + t.Helper() + op, _ := hex.DecodeString("cdc202d5123e20f62b6d676ac72cb318") + _, _, _, ak, err := milenage.New(k, op, rand, 0, 0).F2345() + if err != nil { + t.Fatal(err) + } + mac, err := milenage.New(k, op, rand, sqn, amf).F1() + if err != nil { + t.Fatal(err) + } + autn := make([]byte, 16) + for i := 0; i < 6; i++ { + autn[i] = byte(sqn>>(8*uint(5-i))) ^ ak[i] + } + autn[6], autn[7] = byte(amf>>8), byte(amf) + copy(autn[8:], mac) + return autn +} + +// A pod stores the sequence number of the last session it accepted, and +// CheckSqn only accepts one past it. Pairing therefore has to leave that at 0, +// because the controller's first session uses SQN 1: storing 1 makes the very +// first session look stale and the pod answers a synchronization failure +// instead of pairing. Both activation paths depend on this. +func TestCheckSqnAcceptsTheFirstSessionAfterPairing(t *testing.T) { + k, _ := hex.DecodeString("00112233445566778899aabbccddeeff") + rand, _ := hex.DecodeString("0102030405060708090a0b0c0d0e0f10") + const amf = uint16(47545) + + for _, tc := range []struct { + name string + stored uint64 + sqn uint64 + wantNew bool + }{ + {"a freshly paired pod accepts the first session", 0, 1, true}, + {"storing 1 at pairing would refuse the first session", 1, 1, false}, + {"a later session is accepted", 1, 2, true}, + {"a replayed session is refused", 5, 5, false}, + {"an older session is refused", 5, 3, false}, + } { + t.Run(tc.name, func(t *testing.T) { + e := NewEapAkaChallenge(k, tc.stored) + e.rand = rand + e.autn = challengeAutn(t, k, rand, tc.sqn, amf) + + fresh, err := e.CheckSqn() + if err != nil { + t.Fatalf("CheckSqn: %v", err) + } + if fresh != tc.wantNew { + t.Errorf("stored %d, challenge SQN %d: fresh=%v, want %v", tc.stored, tc.sqn, fresh, tc.wantNew) + } + if e.Sqn != tc.sqn { + t.Errorf("CheckSqn read SQN %d, want %d", e.Sqn, tc.sqn) + } + }) + } +} 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..18f4fad --- /dev/null +++ b/pkg/pair/o5.go @@ -0,0 +1,563 @@ +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 leaf certificate carries and signs with. + identity *ecdsa.PrivateKey + // 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. + 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) + } + if err := checkSimulatorCA(); err != nil { + return nil, err + } + caKey, caCert, caDER := simulatorCA() + leafDER, err := podCertificate(identity, caCert, caKey) + 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, + leafDER: leafDER, + caDER: caDER, + }, 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 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.caDER) + 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.leafDER...), 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 +} + +// 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), + 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, + } + 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/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/pod/o5.go b/pkg/pod/o5.go new file mode 100644 index 0000000..eee60f3 --- /dev/null +++ b/pkg/pod/o5.go @@ -0,0 +1,175 @@ +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 { + p.startOver("could not make the pod's pairing keys: %s", err) + return + } + + // 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 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 + } + log.Infof("pkg pod; Received %s", step.name) + if step.reply == nil { + continue + } + reply, err := step.reply() + if err != nil { + p.startOver("could not answer %s: %s", step.name, err) + return + } + p.ble.WriteMessage(reply) + } + + ltk, err := o5.LTK() + if err != nil { + p.startOver("pairing produced no long-term key: %s", err) + return + } + + 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 = 0 // the controller's first session uses SQN 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..83b7446 --- /dev/null +++ b/pkg/pod/o5_test.go @@ -0,0 +1,251 @@ +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 (silentRadio) LinkMTU() int { return 0 } + +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 69659ca..f9391b6 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -6,6 +6,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" @@ -33,6 +34,7 @@ type PodMsgBody struct { type Pod struct { ble *bluetooth.Ble state *PODState + aid *aid.Store mtx sync.Mutex webMessageHook func([]byte) @@ -50,6 +52,7 @@ func New(ble *bluetooth.Ble, state *PODState) *Pod { ret := &Pod{ ble: ble, state: state, + aid: aid.NewStore(), } ret.takeSnapshot() return ret @@ -84,6 +87,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() @@ -110,6 +124,17 @@ 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() +} + // startOver hangs up on a phone whose message the pod can't use. The phone // reconnects and sets up a new session. func (p *Pod) startOver(format string, args ...interface{}) { @@ -119,6 +144,10 @@ func (p *Pod) startOver(format string, args ...interface{}) { } func (p *Pod) StartActivation() { + if p.isO5() { + p.startO5Activation() + return + } pair := &pair.Pair{} msg, ok := p.readSetup() @@ -286,6 +315,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() @@ -304,6 +345,11 @@ 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 { p.mtx.Unlock() @@ -312,6 +358,45 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { } 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 their + // payloads are short ASCII — "G3.12" is five bytes — so this has to + // come before the command-length guard below. + if p.isO5() && aid.Looks(decrypted.Payload) { + if !p.handleAidCommand(decrypted.Payload, msg.Source, msg.Destination, msg.SequenceNumber) { + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + p.startOver("could not answer the AID command") + return + } + // 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 + } + err := p.consumeAck(ack) + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + if err != nil { + p.startOver("could not read the AID answer ACK: %s", err) + return + } + p.notifyStateChange() + continue + } + if len(decrypted.Payload) < 16 { p.mtx.Unlock() p.startOver("decrypted payload too short: %x", decrypted.Payload) @@ -341,19 +426,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() @@ -397,7 +497,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() @@ -411,13 +511,7 @@ 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 { - p.state.NonceSeq++ - if len(decrypted.Payload) != 0 { - err = fmt.Errorf("not an empty ACK: %s", spew.Sdump(msg)) - } - } + err = p.consumeAck(msg) p.state.Save() p.takeSnapshot() p.mtx.Unlock() @@ -431,12 +525,47 @@ 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 { + 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, @@ -455,7 +584,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, @@ -474,17 +603,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(), } } @@ -492,14 +621,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()), } } @@ -507,7 +636,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/pod_test.go b/pkg/pod/pod_test.go index 6ed83ee..de5b670 100644 --- a/pkg/pod/pod_test.go +++ b/pkg/pod/pod_test.go @@ -42,8 +42,9 @@ func (r *radio) NotifyData(p bluetooth.Packet) error { return nil } -func (r *radio) Advertise([]byte) error { return nil } +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 { @@ -53,7 +54,7 @@ func (r *radio) Disconnect() { } func newBle(t *testing.T, r *radio) *bluetooth.Ble { - b, err := bluetooth.New(func(*bluetooth.Ble) (bluetooth.Radio, error) { return r, nil }) + b, err := bluetooth.New(func(*bluetooth.Ble) (bluetooth.Radio, error) { return r, nil }, bluetooth.ProfileFor(bluetooth.ModeDash)) if err != nil { t.Fatal(err) } @@ -67,7 +68,7 @@ func TestAGarbledSessionMessageHangsUp(t *testing.T) { toPod.peer = podBle toPhone.peer = phone - state := NewFreshState(filepath.Join(t.TempDir(), "state.toml")) + state := NewFreshState(filepath.Join(t.TempDir(), "state.toml"), bluetooth.ModeDash) state.LTK = make([]byte, 16) p := New(podBle, state) @@ -140,7 +141,7 @@ func TestAStaleSqnGetsASynchronizationFailure(t *testing.T) { toPhone.peer = phone ltk := bytes.Repeat([]byte{0x5a}, 16) - state := NewFreshState(filepath.Join(t.TempDir(), "state.toml")) + state := NewFreshState(filepath.Join(t.TempDir(), "state.toml"), bluetooth.ModeDash) state.LTK = ltk state.EapAkaSeq = 40 p := New(podBle, state) @@ -208,7 +209,7 @@ func TestABadAutnHangsUp(t *testing.T) { toPod.peer = podBle toPhone.peer = phone - state := NewFreshState(filepath.Join(t.TempDir(), "state.toml")) + state := NewFreshState(filepath.Join(t.TempDir(), "state.toml"), bluetooth.ModeDash) state.LTK = bytes.Repeat([]byte{0x5a}, 16) p := New(podBle, state) 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..237355f 100644 --- a/pkg/response/versionresponse.go +++ b/pkg/response/versionresponse.go @@ -1,17 +1,69 @@ 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 +) + +// 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 { + ID byte + 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} } + +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 } 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(), } }