From 3b3ceb13c063cd49a50f3a6bd15f731c46a44bd4 Mon Sep 17 00:00:00 2001 From: laconicman Date: Sun, 4 Oct 2026 13:36:56 +0300 Subject: [PATCH 1/2] docs: instance ID on iOS, and which SIP server to run - Instance-ID: why +sip.instance matters for a client that reconnects after every wake-up, why pjsua's host-name default cannot be used on iOS, what went wrong in a production client (identifierForVendor and UserDefaults before first unlock, a racing config writer, a random fallback, an SBC refusing the canonical form), and a proposed design: a Keychain-stored device ID written once, hashed per app and account. Links the upstream change, pjsip/pjproject#5313. - Own-SIP-Server: Flexisip first (built-in APNs sender, bindings keyed on the instance ID; certificate-only, no Feature-Caps, AGPL), OpenSIPS second, Kamailio and Asterisk as lab subjects. - Tech-Debt OH-11 (no instance ID of our own) and two Roadmap pointers. --- docs/Instance-ID.md | 195 +++++++++++++++++++++++++++++++++++++++++ docs/Own-SIP-Server.md | 77 ++++++++++++++++ docs/Roadmap.md | 6 +- docs/Tech-Debt.md | 11 +++ 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 docs/Instance-ID.md create mode 100644 docs/Own-SIP-Server.md diff --git a/docs/Instance-ID.md b/docs/Instance-ID.md new file mode 100644 index 0000000..00d2bb4 --- /dev/null +++ b/docs/Instance-ID.md @@ -0,0 +1,195 @@ +# Instance ID — `+sip.instance` on iOS + +**Status: proposed, 2026-10-02; upstream shape decided 2026-10-04 (§2.1).** Two decisions are +still open (§6). Nothing here is built in Offhook: +neither Offhook nor `swift-pjsua` sets `rfc5626_instance_id` today +([OH-11](./Tech-Debt.md#oh-11--no-instance-id-of-our-own--open)). + +Written from the experience of a production iOS softphone on the same pjsua stack, and from the +research behind [pjsip/pjproject#5292](https://github.com/pjsip/pjproject/issues/5292). + +--- + +## 1. Why the instance ID matters + +`+sip.instance` is a Contact header parameter naming the device (RFC 5626 §4.1). It is what lets +a registrar recognise that a REGISTER from a new connection is the *same* phone, which is the +normal case for a mobile client that reconnects after every wake-up. + +| Registrar | With an instance ID and no `reg-id` | +|---|---| +| Kamailio | the binding is matched on it; the same instance from a new address updates the old binding | +| Flexisip | the binding is keyed on it alone; it is the first of its `unique-id-parameters` | +| IMS (3GPP TS 24.229) | mandatory for a UE with an IMEI; `reg-id` only with multiple registrations | +| OpenSIPS | stored, not used for matching | +| Asterisk, FreeSWITCH | never read | + +Without it (or with a wrong one) those that key on it accumulate stale bindings, or let one +device replace another. + +## 2. What pjsua does on its own + +- If `rfc5626_instance_id` is empty, pjsua generates + `urn:uuid:00000000-0000-0000-0000-0000XXXXXXXX` from a 32-bit hash of the host name. On iOS the + host name is not the device's (the production client saw `localhost`), so phones would share + one instance ID. It must be set by the app. +- pjsua sends the parameter only together with `reg-id`, while SIP outbound is wanted (TCP/TLS), + and drops both when the registrar does not confirm outbound. Sending it regardless is the + subject of the upstream issue above. + +### 2.1 The upstream change, and why it is narrow + +The change we propose ([pjsip/pjproject#5313](https://github.com/pjsip/pjproject/pull/5313)) sends +an instance ID **the application configured** in every REGISTER, with or without outbound, and +logs it once per account (`Acc N: instance ID … is sent in every +REGISTER`). The generated one stays outbound-only, as since 2010. + +A first version added an account switch that also widened the generated ID. Review caught what +that means: two pjsua processes on one host, or any two iPhones, would advertise the same instance +ID everywhere, and a registrar keying on it would let one replace the other. Narrowing to the +configured ID removes the error class instead of documenting it. + +pjsua cannot fix its default itself, in a minor release or otherwise. Explored with DeepWiki +([consult](https://deepwiki.com/search/pjsua-generates-a-default-sip_cfb1d46e-d9d2-41d2-95b2-88d8fb50aeb1?mode=deep)), +claims checked against source: + +| Option | Verdict | +|---|---| +| Keep the generated ID outbound-only; send only a configured one wider | done | +| Document it next to `rfc5626_instance_id` | done, three lines | +| Log the instance ID | done for a configured one only; most users never set it | +| Change how the default is generated (random, machine ID, a proper 128-bit UUID) | major release at best: every deployment's wire value changes; no machine ID exists on iOS or Android; a random one per process is not persistent | +| Return the effective ID through an API | not now, see below | + +pjlib has no persistent store, and the stable identifiers on phones live above C. liblinphone +solves it with its own config: a random UUID generated once and kept as `[misc] uuid` (`uuid=0` +turns `+sip.instance` off). sofia-sip sends no instance ID unless the application gives one. +A read-back API would be the pjsua counterpart of liblinphone's model only if pjsua first +generated something worth keeping (a random UUID); the application would then persist it and +pass it back. That may be the remedy for the persistence problem, but it is a design of its own, +not part of this change. DeepWiki said the generated ID is already readable through +`pjsua_acc_get_config()`; it is not, it lives only in a private field of the account. + +## 3. What the value has to be + +RFC 5626 §4.1: a URN, "persistent across power cycles of the device", that "MUST NOT change as the +device moves from one network to another", and preferably a UUID URN. The RFC's own advice for a +soft phone is to generate a UUID when first installed and keep it in persistent storage. + +So the requirement is **persistent and unique per installed UA**. It is not "derived from a +hardware identifier". + +## 4. iOS: every obvious source can be missing + +| Source | Failure | +|---|---| +| `UIDevice.identifierForVendor` | `nil` "after the device has been restarted but before the user has unlocked the device" (Apple). Changes when all of the vendor's apps are removed and one is reinstalled. Identical for every app of the vendor on the device, so two of our apps on one AOR would share it | +| `UserDefaults` | its file is `completeUntilFirstUserAuthentication`. Before first unlock a read returns `nil`, indistinguishable from "never stored", and the empty state stays cached for the life of the process (Apple DTS, [forums thread 15685](https://developer.apple.com/forums/thread/15685)) | +| Keychain, `AfterFirstUnlock…` | also unreadable before first unlock, but the failure is an error distinct from "not found" | +| A file with `FileProtectionType.none` | readable at any time; the only store that is | +| `UIDevice.name`, the host name | the name is the generic `"iPhone"` since iOS 16 unless the app holds a special entitlement, and the host name was `localhost` in production. Neither is unique or stable. Not candidates | + +A VoIP push can launch the app in exactly that window: restarted, not yet unlocked. + +### 4.1 What went wrong in production + +1. **`identifierForVendor`, cached in `UserDefaults`, with `""` as the fallback.** Before first + unlock both were unavailable and the client registered with an empty instance ID. +2. **A second writer.** An asynchronous copy-modify-write of the whole account config raced the + code that set the instance ID, and sometimes wrote back a snapshot without it. pjsua then fell + back to its host-name default. The instance ID has to be set in one place, on the path that + builds the account config, not patched in afterwards. +3. **A stored UUID, seeded from `identifierForVendor`, with a random UUID as the fallback.** Better, + but the fallback breaks determinism: a launch before first unlock reads nothing, falls through + to a random value and uses it for the life of that process. The instance ID is then sometimes + the device's and sometimes a stranger's. +4. **Length or format.** One deployment's SBC (believed to be OpenSIPS-based, lightly customised) + did not accept the canonical form from iOS and did accept shorter ones: + + | Client | `+sip.instance` value | URN length | Result | + |---|---|---|---| + | Android | `` | 25 | accepted | + | iOS, shortened | `` | 35 | accepted | + | iOS, canonical | `` | 45 | not accepted | + + Three things differ between the accepted and the refused forms: length, hyphens and case. Which + one mattered was never isolated. Neither accepted form is a valid UUID URN; registrars that + treat the value as opaque do not care, and Kamailio, Flexisip and OpenSIPS all do. + +Android never had the first three problems: `ANDROID_ID` is available at any time. + +## 5. Proposed design + +**D-INST-1. One stored device ID, written once, never re-derived.** On the first launch that +finds none, the app stores a UUID in the Keychain beside the SIP secrets, with the same class +they already use (`kSecAttrAccessibleAfterFirstUnlock`, see [Credentials](./Credentials.md) +D-CRED-1), in its `ThisDeviceOnly` variant so that a restore onto another device does not clone it. +The UUID is `identifierForVendor` if the system has one at that moment, otherwise random. The +system is not asked again. + +**D-INST-2. "Unreadable" is not "absent".** A new value is generated only when the store answers +"not found" *and* the device is unlocked. Any other failure means wait. No random fallback, no +empty value, no registration without an instance ID. + +**D-INST-3. The wait is the one we already have.** Before first unlock the SIP secret is +unreadable too, so no REGISTER can be sent in that window whatever the instance ID does. Both +become readable at the same moment. The instance ID adds no pause of its own. + +**D-INST-4. The wire value is a hash of the stored device ID, per app and per account.** +`+sip.instance` carries a version-5 UUID computed from the stored device ID, the bundle identifier +and the AOR, printed in canonical lower case. It is as stable as the stored ID, is a valid UUID +URN, differs between two of our apps on one phone, and cannot be linked across providers. + +*Why.* It meets §3 exactly and removes the first three failures in §4.1. Seeding from +`identifierForVendor` keeps what is useful about a device ID: one value for the backend, the logs +and SIP, and a second source if the Keychain item is ever lost while the vendor identifier +survives. The first launch is started by the user, so the device is unlocked and the identifier +is there; nothing has to wait for it. D-INST-4 removes what is harmful about it: sent as is, two +apps of one vendor on one AOR would share an instance ID and take each other's binding on a +registrar that keys on it. + +*Rejected.* Deriving the value on every launch (§4.1 items 1 and 3). Storing it in `UserDefaults` +(cannot tell locked from empty). A `FileProtectionType.none` file: it would make the instance ID +readable before first unlock, which buys nothing while the secret is not. + +### 5.1 What the user sees before first unlock + +A VoIP push in that window must still be reported to CallKit. The call can ring, but answering it +cannot register or accept the INVITE until the device has been unlocked once. The app has to fail +that answer cleanly and say why, not hang. An outgoing call does not arise there: the app cannot +be opened. After the first unlock none of this applies until the next restart. + +Avoiding it would mean keeping the SIP secret readable before first unlock, i.e. unprotected at +rest. Not proposed. + +### 5.2 What the engine needs + +`swift-pjsua` does not expose `rfc5626_instance_id`. It needs an account-level instance ID that the +app supplies. With the upstream change (§2.1) that ID is then sent in every REGISTER, with or +without outbound, and the pjsua log shows it once per account. + +### 5.3 To verify on a device + +- What a Keychain query for a *missing* `AfterFirstUnlock` item returns before first unlock. If it + is "not found", D-INST-2's "and the device is unlocked" is what keeps it safe. +- Which of length, hyphens and case the SBC in §4.1 item 4 objects to + (`urn:uuid:` + canonical lower-case, upper-case, 32 hex without hyphens, base32). + +## 6. Open decisions + +1. **Seed from `identifierForVendor` (D-INST-1), or always random?** Proposed: seed. Random is + the simpler rule and loses only the second source and the match with the vendor identifier. + If the wire value were ever sent unhashed, random would be the safer seed. +2. **Hashed (D-INST-4), or the stored ID in full?** Proposed: hashed. In full is simpler to + correlate by eye in a trace and is what a backend that already knows the device ID can match + without computing anything; such a backend can compute the same hash. +3. **A short form.** A per-account override for a deployment that needs a shorter or differently + shaped value (§4.1 item 4), off by default because it is not a UUID URN. Not needed until a + provider asks for it. + +## See Also + +- [Credentials](./Credentials.md) · [Push-vs-Active-Socket](./Push-vs-Active-Socket.md) · + [Roadmap](./Roadmap.md) · [Tech-Debt](./Tech-Debt.md) +- RFC 5626 §4.1 and §6; RFC 5627; 3GPP TS 24.229 §5.1.1.2.1 +- [pjsip/pjproject#5292](https://github.com/pjsip/pjproject/issues/5292) diff --git a/docs/Own-SIP-Server.md b/docs/Own-SIP-Server.md new file mode 100644 index 0000000..4615794 --- /dev/null +++ b/docs/Own-SIP-Server.md @@ -0,0 +1,77 @@ +# Own SIP server — which one, and why + +**Status: research, 2026-10-04.** Builds on [SIP-Test-Infrastructure](./SIP-Test-Infrastructure.md) +§3 (push mechanics, verified July 2026) and §5 (self-hosting); this note records the choice. + +## 1. What Offhook needs from a server + +1. **VoIP push to our own bundle** (RFC 8599 parameters in the Contact, APNs PushKit pushes). + Public servers hold their own Apple credentials, so this alone requires running one. +2. **A registrar that recognises a reconnecting phone** by its instance ID + ([Instance-ID](./Instance-ID.md)), so wake-ups do not leave stale bindings behind. +3. TLS, a lab setup on the Mac and a small public host, and a licence we can live with. + +## 2. Candidates + +| | Flexisip | OpenSIPS 3.1+ | Kamailio | Asterisk | +|---|---|---|---|---| +| Sends the APNs push itself | **yes** (HTTP/2) | no: raises `E_UL_CONTACT_REFRESH`; you write the sender (`rest_client`) | no | no | +| RFC 8599 signalling | reads `pn-provider`/`pn-prid`/`pn-param`; **no** `Feature-Caps: +sip.pns` | complete, incl. `Feature-Caps` and `pn_ct_match_params` | none; DIY in script (`tsilo`, `t_suspend`) | none | +| APNs auth | **certificate only**, one PEM per app and mode | whatever your sender does (`.p8` token) | yours | — | +| Holds the INVITE until the push re-registers the phone | `fork-late` | `lookup()` returns 2, `pn_refresh_timeout` | `tsilo`, bounded by the transaction timer | — | +| `+sip.instance` without `reg-id` | binding keyed on it | stored, not used for matching | binding matched on (instance, reg-id) | ignored | +| Minimal lab | registrar with in-memory DB, file or no auth; Redis optional | registrar + usrloc + event route | registrar + usrloc, push is all script | `res_pjsip` | +| Licence | AGPL-3.0-or-later (commercial licence sold separately) | GPL-2.0 | GPL-2.0 | GPL-2.0 | +| Runs as | Linux; Belledonne publishes packages and images (check the current source) | Linux packages; images maintained by the project | Linux packages; official image | Linux packages | + +None of the four is in Homebrew (checked 2026-10-04); the lab runs Linux containers on the Mac. + +## 3. Decision + +**Flexisip first.** It is the only one that turns a REGISTER with push parameters into an APNs +VoIP push with nothing else to write, it parks the INVITE until the phone comes back, and it is the +registrar that keys bindings on `+sip.instance`, which is exactly the behaviour Offhook's instance +ID is for. It is also what our integration tests already register to (`sip.linphone.org`). + +What it costs: +- **A certificate, not a key.** Flexisip reads a VoIP Services certificate per bundle and mode; + there is no `.p8` token auth in its source. The certificate has to be exported and renewed. +- **RFC 8599 is not complete.** No `Feature-Caps: +sip.pns` in the 200, so the app must not wait + for it before trusting push. +- **AGPL.** Fine for running it unmodified; offering a modified version over the network obliges + us to offer its source. + +**OpenSIPS second**, for when `.p8` auth or standard `Feature-Caps` matter, or when a registrar +sits in front of infrastructure we do not control (`mid_registrar`). Its RFC 8599 state machine +is done; the APNs request (one HTTP/2 POST with a JWT) is ours. The OpenSIPS tree ships a +reference config for it (`modules/registrar/test/opensips.cfg`). + +**Kamailio and Asterisk** stay in the lab as measurement subjects: Kamailio because most +deployments run it and its instance matching differs (it compares `reg-id` too); Asterisk as the +control that ignores the instance ID, and as a PBX for feature tests. + +## 4. Testing push without Apple + +Flexisip can send every push as an HTTP request to a URL of our choice +(`module::PushNotification/external-push-uri` with `$type`, `$token`, `$app-id`, `$call-id` +placeholders). Pointed at a local sink, that verifies the whole server side (push trigger, INVITE +parking, fork on re-REGISTER, no push while the connection is alive) before any Apple credential +exists. `flexisip_pusher` then tests the server-to-APNs leg on its own. + +## 5. Push parameters as a second device key + +With push, the token also identifies a device: Flexisip removes an older contact with identical +push parameters, and OpenSIPS can match bindings on them (`pn_ct_match_params`). It is not a +replacement for the instance ID: the token changes when iOS rotates it and differs between +sandbox and production builds, while the instance ID does not. + +## Sources + +- DeepWiki: [Flexisip push](https://deepwiki.com/search/answer-from-source-on-current_0c585d42-0210-49a6-9145-87cc3c4f3a59?mode=deep), + [OpenSIPS and Kamailio push](https://deepwiki.com/search/for-each-of-opensips-and-kamai_3acb493a-c2b0-4fd3-bed0-e134a0c873f2?mode=deep), + [Flexisip registrar](https://deepwiki.com/search/answer-from-source-on-current_a2992c7a-23cf-4abd-9d43-e36dd472ef5d?mode=deep). + Index pins were 5 to 8 months old; the Flexisip registrar behaviour and Kamailio's instance + matching were re-checked against current source. DeepWiki's claim that Kamailio ships in + Homebrew is wrong. +- [SIP-Test-Infrastructure](./SIP-Test-Infrastructure.md) §3, §5 · [Instance-ID](./Instance-ID.md) · + [Push-vs-Active-Socket](./Push-vs-Active-Socket.md) diff --git a/docs/Roadmap.md b/docs/Roadmap.md index d4fb6eb..b99ae55 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -20,7 +20,11 @@ See the smoke procedure in [README](../README.md) and endpoints in [OH-3](./Tech-Debt.md)). - **PushKit / hybrid VoIP push** (RFC 8599) — persisted-connection + push with no double ring. End-to-end push needs a self-hosted Flexisip/OpenSIPS with our APNs key - ([SIP-Test-Infrastructure](./SIP-Test-Infrastructure.md) §3). + ([SIP-Test-Infrastructure](./SIP-Test-Infrastructure.md) §3); Flexisip first, see + [Own-SIP-Server](./Own-SIP-Server.md). +- **A stored instance ID** (`+sip.instance`) before push: a client that reconnects after every + wake-up is recognisable to the registrar only by it ([Instance-ID](./Instance-ID.md), + [OH-11](./Tech-Debt.md)). ## Later — debug tooling & the Swiss-knife diff --git a/docs/Tech-Debt.md b/docs/Tech-Debt.md index 294df1c..b22ac14 100644 --- a/docs/Tech-Debt.md +++ b/docs/Tech-Debt.md @@ -190,6 +190,17 @@ The user sees a running call timer, a "connected" label, and silence. [OH-4](#oh-4--no-stunice-configuration--deferred); upstream note: `../../swift-pjsua/Upstream/no-transport-death-notification-for-established-calls.md`. +### OH-11 — no instance ID of our own · *open* + +Neither Offhook nor `swift-pjsua` sets `rfc5626_instance_id`, so pjsua's host-name-derived default +is what a registrar sees, and only while SIP outbound is offered. + +- **Cost.** On iOS that default is the same on every phone. A registrar that keys bindings on the + instance ID (Kamailio, Flexisip) cannot tell two of our devices apart on one AOR, and one that + never confirms outbound loses the ID on the first refresh. +- **Discharge.** [Instance-ID](./Instance-ID.md): an app-supplied, stored instance ID exposed + through the engine's account config. Wanted before push ([Roadmap](./Roadmap.md): Next). + ## See Also - [Design](./Design.md) · [Roadmap](./Roadmap.md) · [Prior-Art](./Prior-Art.md) From a8ce3d9db49484d2e8586854f9bbeb095ec91dd4 Mon Sep 17 00:00:00 2001 From: laconicman Date: Sun, 4 Oct 2026 13:38:19 +0300 Subject: [PATCH 2/2] docs: mark pre-unlock launch by a VoIP push as unverified (U4) --- docs/Instance-ID.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/Instance-ID.md b/docs/Instance-ID.md index 00d2bb4..6a9ec3e 100644 --- a/docs/Instance-ID.md +++ b/docs/Instance-ID.md @@ -89,7 +89,9 @@ hardware identifier". | A file with `FileProtectionType.none` | readable at any time; the only store that is | | `UIDevice.name`, the host name | the name is the generic `"iPhone"` since iOS 16 unless the app holds a special entitlement, and the host name was `localhost` in production. Neither is unique or stable. Not candidates | -A VoIP push can launch the app in exactly that window: restarted, not yet unlocked. +Whether a VoIP push launches the app in exactly that window (restarted, not yet unlocked) is +still an open device test ([Push-vs-Active-Socket](./Push-vs-Active-Socket.md) U4). Any +background launch there, including that one, has to cope with the failures above. ### 4.1 What went wrong in production @@ -154,8 +156,10 @@ readable before first unlock, which buys nothing while the secret is not. ### 5.1 What the user sees before first unlock -A VoIP push in that window must still be reported to CallKit. The call can ring, but answering it -cannot register or accept the INVITE until the device has been unlocked once. The app has to fail +*Assumes iOS launches the app for a VoIP push before first unlock, which is unverified +([Push-vs-Active-Socket](./Push-vs-Active-Socket.md) U4).* If it does, the push must still be +reported to CallKit. The call can ring, but answering it cannot register or accept the INVITE +until the device has been unlocked once. The app has to fail that answer cleanly and say why, not hang. An outgoing call does not arise there: the app cannot be opened. After the first unlock none of this applies until the next restart. @@ -170,6 +174,8 @@ without outbound, and the pjsua log shows it once per account. ### 5.3 To verify on a device +- Whether iOS launches the app for a VoIP push before first unlock at all (U4 in + [Push-vs-Active-Socket](./Push-vs-Active-Socket.md)); §5.1 depends on it. - What a Keychain query for a *missing* `AfterFirstUnlock` item returns before first unlock. If it is "not found", D-INST-2's "and the device is unlocked" is what keeps it safe. - Which of length, hyphens and case the SBC in §4.1 item 4 objects to