Skip to content

Ask before pairing with a pod whose certificate is not Insulet's - #202

Open
ps2 wants to merge 7 commits into
next-devfrom
o5-pod-authenticity
Open

ps2 wants to merge 7 commits into
next-devfrom
o5-pod-authenticity

Conversation

@ps2

@ps2 ps2 commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

An Omnipod 5 shows Insulet's pod intermediate in SPS2.1, its own certificate issued by
that intermediate in SPS2, and signs the channel-binding transcript with the key inside
it. Until now nothing looked at who issued any of it: o5validatePodSps2 checks the
pod's signature against whatever key its certificate carried, which proves the pod holds
that key and nothing about where the key came from.

O5PodIdentity answers the one remaining question — who signed this certificate — and
pairing now asks the user before going ahead with a pod whose certificate is not Insulet's.

public enum O5PodAuthenticity: String, Equatable { case genuine, simulated, unverified }
  • genuine — signed by Insulet's INS01PG1 (issued by INS00PG1), whose public key
    is pinned here, captured from a real Omnipod 5 on 2026-10-27, and the pod signed this
    exchange with its key.
  • simulated — signed by the CA that LoopKit/pod
    publishes for this purpose (go run ./cmd/o5keypair -ca).
  • unverified — anything else, including a certificate whose key did not sign the
    exchange. A real pod's certificate can be replayed, so the signature is what makes it
    the pod's.

Asking at pairing

Pairing is when a faked pod connection matters. If the pod's certificate is not
Insulet's, pairing stops right after the pod shows it, before the LTK is agreed, hangs
up, and asks:

Pod Certificate Not From Insulet
This pod's certificate was not issued by Insulet.

If you are pairing with a pod simulator, you can continue. If not, someone may be
intercepting communication with your pod: cancel, move to a new area with your pod,
and try again.

The pairing prompt for a pod whose certificate is not Insulet's

The prompt doesn't say "simulated" even when the certificate is the simulator's. That
key is public, so an attacker could use it too; all we can tell is that it isn't Insulet's.

  • Continue Pairing pairs again from scratch, and that one attempt goes ahead whatever
    the certificate. The simulator issues a new pod certificate on every pairing, so the
    acceptance can't be tied to one certificate.
  • Cancel leaves the pod untouched, since no key was agreed. Behind the prompt the
    pairing screen shows "Pod certificate is not from Insulet" with Retry, and no advice,
    because a simulator user expects this.

Once paired, the authenticity is recorded on PodState. Pod Details shows a
Certificate: Simulated pod row for the simulated case.

Pod Details with a simulated Omnipod 5

This follows G7SensorIdentity closely — Insulet and Dexcom use the same PKI vendor and
the same naming scheme (INS01PG1/INS00PG1 against DEX01PG2/DEX00PG1).

One deliberate divergence from the G7

G7SensorIdentity.chains(_:toAnchor:) uses SecTrustEvaluateWithError, which evaluates
validity dates. A real pod was found pairing with a certificate nineteen months past its
notAfter
— nothing in the pairing had ever looked, so nothing had noticed. Using
SecTrust here would have reported pods in the field as unverified.

SignedCertificate therefore splits the DER and checks the signature against the pinned
key directly, with no notion of time. The comment says so, to stop it being "fixed" back
into a bug.

What is not proven

The prompt makes these matter more than before. If either fails on a real pod, that pod
lands on .unverified and every user pairing one sees the prompt:

  • The genuine path has never run against a real pod. The certificate that would make
    a fixture was truncated by os_log (286 of 799 bytes), so only the simulated path is
    covered by a test. The pinned key is complete and the mechanism is proven by the
    simulated case, but a real pairing is what would confirm it — one line in the log:
    Pod certificate is genuine. The pod's SPS2 signature must also verify on a real pod
    (Pod SPS2 signature verification PASSED), which until now was only logged.
  • One pod, one intermediate. Its own certificate names it pod_test_cert with
    OU=AuthUsers, so it may be a sample rather than retail, and Dexcom's PG2 suffix
    suggests these intermediates have generations. Whether INS01PG1 issues every pod in
    the field is unknown.

A real Omnipod 5 pairing should confirm both before this ships.

Tests

OmniTests/O5/O5PodIdentityTests — 6 tests, passing: a real simulator-issued certificate
classifies as .simulated and not as genuine; tampering with the signed body drops it to
.unverified; empty, malformed, truncated and random input are .unverified and do not
crash; the DER split finds the signed body and the signature; both pinned anchors are the
shapes they claim to be. All 197 OmniTests pass.

In the iOS Simulator against the pod simulator (with LoopKit/pod#27, which keeps an
unpaired Omnipod 5 pairable after an abandoned exchange):

  • pairing showed the prompt
  • Cancel, then Retry, found the pod again and showed the prompt again
  • Continue Pairing paired and primed the pod
  • the existing "cancel Pod setup" confirmation still works

The branch also merges current next-dev (#206, the LoopKit
import), which it needs to build against current LoopKit.

An Omnipod 5 shows Insulet's pod intermediate in SPS2.1, its own certificate
issued by that intermediate in SPS2, and signs the channel-binding transcript
with the key inside it. Until now nothing looked at who issued any of it: the
pod's signature was checked against whatever key its certificate carried, which
proves the pod holds that key and nothing about where the key came from.

O5PodIdentity answers the one remaining question — who signed this certificate
— and records the answer on the pod so settings can show it. It pins two
things: the public key of Insulet's INS01PG1, taken from a real pod, and the
certificate authority the LoopKit/pod simulator publishes for exactly this
purpose. A pod signed by the first is genuine, by the second simulated, and
anything else unverified.

This is a marker, not a gate. It never blocks a pairing, and an unverified pod
is treated like a genuine one, so an older pod, a different intermediate, or a
certificate we cannot parse is never falsely flagged. The simulator's key is
public, so "simulated" errs only toward saying something is simulated, never
the other way for a real pod. Settings shows a row for the simulated case
alone, as the G7 does.

It deliberately does not use SecTrust, which G7SensorIdentity does. SecTrust
evaluates validity dates, and a real pod was found pairing with a certificate
nineteen months past its notAfter — nothing in the pairing had ever looked, so
nothing had noticed. Honouring expiry here would have reported pods in the
field as unverified. SignedCertificate splits the DER instead and checks the
signature against the pinned key, with no notion of time.

Two things are unproven. The genuine path has never run against a real pod:
the certificate that would make a fixture was truncated in the log it came
from, so only the simulated path is covered by a test. And one pod has been
seen, whose own certificate names it pod_test_cert, so whether INS01PG1 issues
every pod in the field is unknown.
@ps2
ps2 marked this pull request as ready for review October 28, 2026 01:11
It sat just above the Configuration section, which on an iPhone meant
scrolling past the pod image and its delivery figures to find out the pod
is not real. A warning nobody scrolls to is not a warning. It goes at the
top of the settings list now, above the pod itself.
@marionbarker

Copy link
Copy Markdown
Contributor

Test

This is a screenshot of the message when using an rPi. I think Pete plans to modify this.

IMG_8023

ps2 added 2 commits October 28, 2026 18:30
A simulated pod isn't a danger to anyone, so it doesn't need a warning at
the top of the pod's settings. Pod Details now has a Certificate row,
"Simulated pod", shown only for a pod whose certificate comes from the pod
simulator. Warning about a pod that isn't genuine belongs in pairing, if
anywhere.
A pod whose certificate is not Insulet's may be a simulator, or someone
intercepting the pairing. Stop the key exchange once the pod has shown its
certificate, before the LTK is agreed, hang up, and ask. Continue Pairing
pairs again from scratch and lets that attempt through; Cancel leaves the
pod untouched so the user can move to a new area and try again.

A certificate now only counts as genuine or simulated when the pod also signed
the exchange with its key, since a real pod's certificate can be replayed.
@ps2 ps2 changed the title Say whether a pod's certificate is Insulet's Ask before pairing with a pod whose certificate is not Insulet's Nov 1, 2026
@marionbarker

Copy link
Copy Markdown
Contributor

Comment

Is this really a valid concern where we need to check a certificate is truly from Insulet?

As someone who uses the rPi all the time for testing, do we really want to add another question to answer while testing.

Unless we really need to add this, I'd prefer we not complicate matters. But if we need to go forward, there were some issues Claude found when reviewing.

Claude Review

I asked Claude (Opus 5.5) to review and here is the response.

Review of #202

I read the five commits this PR adds on top of next-dev. I did not build the project or run the tests.

The approach holds up. Classification only counts when the pod's transcript signature verifies, and the transcript covers both ephemeral public keys and both nonces. That means a replayed real-pod certificate can't come out as "genuine". Stopping before the LTK is agreed is the right place for the check. Two issues are worth fixing before merge; the rest are smaller.

Should fix

1. The "accept" flag can carry over to a later, unrelated pairing

acceptsNonInsuletCertificate is only cleared inside pairPod, right after O5LTKExchanger is built. It stays true if the user taps Continue Pairing and the retry then fails before that point. That happens if:

  • connectToNewPod / discoverPods fails (no pod found, too many pods, timeout)
  • O5LTKExchanger.init throws (certificate store problem)
  • the user backs out of setup after tapping Continue

BlePodComms lives as long as the pump manager, so the next pod change, possibly days later, would skip the prompt. A defer at the top of pairPod doesn't cover discovery failures. It's sturdier to stop storing the flag at all and pass it as a parameter (pairAndPrime(acceptingNonInsuletCertificate:) → blePairAndSetupPod → pairPod). That way it can only apply to the attempt the user just approved.

2. A single pinned intermediate key, taken from one pod, risks false alarms on real pods

insuletPodAnchorKey is the INS01PG1 key captured on 2026-10-27. If other lots or later pods chain to a different intermediate (INS01PG2, a rotated key, and so on), genuine pods will trigger a warning that suggests interception. That teaches users to tap through it.

There's also a cost question. After an abort at SPS2 and disconnectFromDevice, does a real pod accept a fresh HELLO/SP1 on the next connection? The simulator obviously does. If a real pod doesn't, a false positive wastes the pod.

Suggestions:

  • Make the anchor a set of keys, and pin the root (INS00PG1) if its key can be obtained.
  • When the result is not .genuine, log the leaf's issuer DN and authority key ID, plus the SPS2.1 intermediate's key, at default level. Then a new intermediate can be identified from a user's log.
  • Test the abort-and-retry path once against a real pod, for example by temporarily removing the pinned key.

Smaller points

  • Stale comments in o5validatePodSps2. The bad-signature branch still says "Don't throw — continue pairing and log for debugging". An invalid signature now leads to .unverified and the throw. Also confirm that transcript verification is reliable across pod firmware versions, since any mismatch now becomes a security warning.
  • An accepted unverified pod isn't shown anywhere. Pod Details only shows the Certificate row for .simulated. Because .unverified isn't persisted, a pod the user chose to accept looks the same as a pod paired before this change. Consider persisting the value for every O5 pairing and showing "Not verified" for accepted pods.
  • No test with a genuine certificate. All tests use the simulator. One test with a real pod's leaf certificate checked against insuletPodAnchorKey would catch a bad hex string in the pinned key.
  • Outdated test comment. testUnplaceableCertificatesAreUnverified says unverified is "treated like a genuine pod", which is no longer true at pairing time.
  • Debug output. PodState.debugDescription doesn't include podAuthenticity. Adding it would make issue reports easier to triage.
  • DER parser. Fine for this job: it's bounds-checked, rejects indefinite lengths, and anything it can't parse falls through to .unverified. It doesn't check that signatureAlgorithm is ecdsa-with-SHA256, but a SHA-384 certificate would just fail verification and land in .unverified, which is safe.
  • New strings. The new alert and Pod Details strings aren't in Localizable.xcstrings yet. That's probably fine if the build regenerates the file, as in earlier PRs.

Looks good

  • The disconnect on .podCertificateNotGenuine is correct.
  • The error-to-alert path in PairPodViewModel is correct.
  • podState is still nil at the throw, so the retry goes back through connectToNewPod and starts a fresh discovery.

@ps2

ps2 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Is this needed? I believe it's a real vulnerability with an easy fix, that doesn't impact the normal user in every day use.

If it it becomes too much to click through the dialog on pairing as a tester, we could add a "don't show this again" button, or something like that.

…enuine pod showed

- The user's choice to pair with a non-Insulet certificate is now passed down
  with the pairing attempt (pairAndPrime → blePairAndSetupPod → pairPod)
  instead of being stored on BlePodComms. A stored flag survived any attempt
  that failed before the certificate check, such as a failed discovery, and
  would have let a later pairing skip the prompt.
- Insulet anchors are a list, so a new pod intermediate can be added.
- When a pod's certificate isn't genuine, log the SPS2.1 intermediate's key
  and whether the pod's certificate is signed by it, at default level, so a
  new Insulet intermediate can be identified from a user's log.
- Save the result for every O5 pairing. Pod Details shows "Not from Insulet"
  for a pod the user chose to pair with; pods paired before this change, and
  DASH pods, show nothing.
- Add podAuthenticity to PodState's debug description.
- Test that every anchor parses as a P-256 key.
- Correct comments that still described an unverified pod as treated like a
  genuine one, and the bad-signature comment that said pairing continues.
@ps2

ps2 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks Marion. I checked each point against the code. 56d340b addresses them:

Should fix

  1. Accept flag carrying over: you were right. The flag was stored on BlePodComms and cleared only once O5LTKExchanger was built, so a retry that failed earlier (discovery, exchanger init, backing out) left it set for the next pod. The choice is no longer stored. It's passed down with the attempt: PairPodViewModel → pairAndPrimePod(acceptingNonInsuletCertificate:) → pairAndPrime → blePairAndSetupPod → pairPod. It can only apply to the attempt the user just approved, and an auto-retry asks again.
  2. A single pinned intermediate:
    • The anchors are now a list (insuletPodAnchorKeys), so a new intermediate is a one-line addition.
    • When a pod isn't genuine, the pairing log records at default level whether the signature was valid, the SPS2.1 intermediate's public key, and whether the pod's certificate is signed by that key. If a real pod turns up on a new Insulet intermediate, its key can be taken straight from the log.
    • I don't have INS00PG1's key, so the root isn't pinned yet.
    • Agreed that a real-pod test is needed before merge, including whether a real pod accepts a fresh pairing after the abort. That's still outstanding.

Smaller points

  • The stale bad-signature comment is corrected, and so are the comments in PodState and the tests that still described an unverified pod as treated like a genuine one.
  • The result is now saved for every O5 pairing. Pod Details shows "Not from Insulet" for a pod the user chose to pair with. Pods paired before this change, and DASH pods, show nothing.
  • podAuthenticity is in PodState.debugDescription.
  • Genuine-certificate test: I don't have a real pod's leaf certificate to hand. In its place, a test checks that every anchor parses as a P-256 public key, which catches a mistyped hex digit in the pinned key.
  • Strings: the new strings will be picked up with the usual Localizable update.

OmniTests pass (197, 3 skipped) on this branch, which has the latest next-dev merged in.

@ps2

ps2 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Tested on a real Omnipod 5 pod. Both builds were next-dev with OmnipodKit at 56d340b, installed on an iPhone 16 Pro.

1. Abort, then pair again with the same pod. I used a local-only debug build that runs the certificate check and then treats every pod as not from Insulet. The "Not from Insulet" alert appeared and I tapped Cancel. The pod kept advertising its unpaired ID (…FFFFFFFE00) afterwards, so stopping before the LTK step left it unpaired.

2. Genuine path, with the PR exactly as pushed. I installed this build over the debug one and paired the same pod. There was no alert. It paired, primed and finished setup, so the pinned INS01PG1 key matches this pod.

Both real-pod checks this PR was waiting on now pass for this pod.

@marionbarker

marionbarker commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Test

✅ successful test
This test is to review what I will see when I pair with a non-Insulet device.

Configuration

Use a Trio instance because my two test phones are busy with other Loop tests.

Trio, dev, commit 685a0d829

Prepare a patch from OmnipodKit PR 202 and apply that to the Trio OmnipodKit submodule

Test Details

build onto SE 3rd gen iOS 27.2 phone with Nightscout as CGM and OmnipodKit as the pump manager

  • before building, deactivate an o5 rPi simulated Pod

Pair an Omnipod 5 rPi Pod with this build:

  • Got the pairing warning when pairing with the rPi
  • Once priming and insertion completes, the Omnipod 5 UI screen is unchanged
    • The original concept of a warning on the screen is gone
    • Pod Details shows
      • Pod Type: Omnipod 5
      • Certificate: Simulated pod
      • Device Name: pod simulator

Repeat this for DASH rPi Pod

  • Because no certificate is required for DASH pod, there is no warning
  • Nominal pair, prime, insertion
    • Pod Details shows
      • Pod Type: Omnipod DASH
      • Device Name: pod simulator

Note the Device Name change:

There may be code somewhere that expects the device name to be what it used to be.

Check LoopFollow code:
It searches for name == " :: Fake POD ::" as part of the Background Refresh setting in LoopFollow.
We can certainly let the LoopFollow developer know to add "pod simulator" as a device name and they already need to update the names for Omnipod 5 to be complete. So this is not a deal breaker. Just pointing out that it is different.

@marionbarker

Copy link
Copy Markdown
Contributor

Test Eros Pod

✅ successful pair, prime, insert of Eros pod

Configuration: Trio / Eros

Same Trio build as previous comment
L71091
Manufacture date 2020-10-02

✅ successful pair, prime, insert of Eros pod

Pod Details:

  • Pod Type: Omnipod Classic
  • Address: 0x1f044004
  • Lot: L71091
  • etc

Configuration: Loop / Omnipod 5

The SE 3rd gen Loop instance has the test fix for dexcom-share-client-swift and the test PR for additional days of carbohydrates display for LoopWorkspace next-dev commit, 1321af1

It is connected to an actual Omnipod 5 pod and is using Dexcom Share as a CGM
Modify the OmnipodKit submodule using the patch for this PR and build onto the phone.

Evidently the Certificate is not reported if the Omnipod 5 Pod was not paired with this code.
The Certificate can be found under Omnipod 5 support and tapping the upper right corner.

@marionbarker marionbarker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve with caveat that it's not clear why we are changing the old " :: Fake POD ::" name. I think that comes from the pod code, not here anyway.

Tested with an Eros pod - not issues.

@marionbarker

Copy link
Copy Markdown
Contributor

Final Claude review

Verdict: approve. Both blocking issues from the first review are fixed. Most of the smaller points are addressed too. What remains is one real-pod test that should happen before release, plus two minor follow-ups.

Providing the suggested tests, but not sure these need to be done and may have already been tested.

Before release (not blocking merge)

  1. Test abort-and-retry once on a real pod. One way is to temporarily empty insuletPodAnchorKeys in a dev build. This confirms that a genuine pod accepts a fresh HELLO/SP1 after the SPS2 abort and disconnect, so a false positive costs a retry rather than a pod. It also checks the new log line against real data.
  2. Add a genuine-certificate test. Use one real pod's leaf certificate (from a pairing log) and assert .genuine. This exercises the full path that real pods take. Today only the simulator path is tested end to end.

@itsmojo

itsmojo commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

I think that the text for pairing popup should be simplified to leave out the details about someone may be intercepting communication as I still believe this is just a theoretical possibility. Also, if we are going to be doing certificate checking, won’t be also be checking its validity period? I’d suggest changing the bold pop up text to a more general “Invalid Pod Certificate” and have a single explanatory text sentence of “The pod certificate is not a valid certificate issued by Insulet.” which would also cover the case of an expired certificate w/o having to go into details, would be fully expected (and not overly obnoxious) when pairing with the O5 pod sim, and wouldn’t be unnecessarily alarming.

I would really want to avoid having different mechanisms in OmnipodKit for dealing with pod simulators for different pod types. I have tried to minimize the differences between pod types in OmnipodKit whenever possible, but this PR introduces some unnecessary differences just the O5 pod sim that I’d rather not include. The Dash method for pod sims is to avoid using any special code in OmnipodKit and just have the pod sim itself just use alternate human identifiable strings that show up in Pod Details display in Device Name (which I’d like to extend to things like lot #, seq #, and firmware #’s) for the user to see. I’d like to keep using this approach for O5 even if it is doing certificate verification. This approach requires zero extra code to support &/or state to maintain for the pod sim. This PR currently adds a new persistent O5 specific PodState state that is only used to have an alternative O5 Pod Details display for O5 pod sims that I really don’t want to have as it is inconsistent with the existing dash pod sim model. Therefore, I believe that all the changes in this PR to PodDetailsView, PodState, OmnipodSettingsViewModel should not be included.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants