Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions docs/Instance-ID.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# 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 |

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

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 | `<urn:uuid:` + 16 lower-case hex (`ANDROID_ID`) + `>` | 25 | accepted |
| iOS, shortened | `<urn:uuid:` + 26 lower-case base32 (the UUID's 128 bits) + `>` | 35 | accepted |
| iOS, canonical | `<urn:uuid:` + `uuidString` (36, upper-case, hyphens) + `>` | 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

*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.

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

- 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
(`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)
77 changes: 77 additions & 0 deletions docs/Own-SIP-Server.md
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion docs/Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions docs/Tech-Debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down