Skip to content

Ring the iPhone through Noodle Push when Noodle is closed (part of #35) - #130

Merged
pdparchitect merged 3 commits into
mainfrom
theo/push-calls-clients
Oct 21, 2026
Merged

pdparchitect merged 3 commits into
mainfrom
theo/push-calls-clients

Conversation

@noodle-theo

@noodle-theo noodle-theo Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Part of #35: PRs 2–4 of the design. A bot's call now rings the iPhone through Noodle Push (live at push.usenoodle.app, #124) when Noodle Mobile is closed or away from its Hub. While the app is open and connected, the in-app ring handles the call as before.

Commits

1. Carry call grants for Noodle Push over the Hub link (Push/, Shared/HubLink)

  • PushWire moves into its own package, Push/Wire. Depending on the Push package itself made SwiftPM resolve Hummingbird, APNSwift and NIO for every app; now HubLink pulls in only swift-crypto. The server code is unchanged: the Dockerfile copies Wire/, the vectors still pass, and PushWireTests still runs with swift test --package-path Push. Merging touches Push/**, so push.yml redeploys the same server.
  • New callPush(LinkCallPush) request: a CallGrant the phone signed for this Hub's key, plus a 32-byte call key for this Hub only. A request with no grant stops the Hub ringing the phone.
  • LinkStatus.ringsThroughPush is the capability flag (same pattern as keepsAnnotations). callPushExpiresAt tells the device what grant the Hub holds; nil means it holds none, so the device registers again.
  • LinkCallPushCaller is the encrypted caller sent in the ring's blob: bot ID, name, conversation and the Hub's own end time. It is AES-GCM with the call key, with the call ID as associated data, so a blob can't be reused for another call.
  • The Hub checks each grant: signed by the device that sent it, naming this Hub, unexpired, and no longer than 15 days.
  • New HubPairing helpers sign registrations and grants with that pairing's device key. Each Hub gets its own sealed token and call key, so Hubs still can't tell they share a phone.

2. Ring devices away from the Hub through Noodle Push (HubCore, Noodle and Hub changelogs)

  • HubLinkService handles each callRinging (from HubBots.ringCall). It rings every owner device that has a grant and no live subscription, using the same rule as unread-reply notifications.
  • CallPusher signs a PushRingRequest with the Hub key and posts it to /v1/ring. It waits 10 s and does not retry.
  • It drops the device's grant on unregistered, registrationExpired or grantExpired, or on rejected with reason badSealedToken, badGrant or grantTooLong. Any other refusal is only logged.
  • After a Hub-wide rate limit (addressLimit or hubLimit) it sends no rings for 60 s.
  • Grants and call keys are stored on HubDevice.callPush. The field is new and optional, and older files read it as none, so no migration or milestone is needed. access.json is now written 0600.
  • PersonalHub uses the same path, so calls from bots on the Mac (This Mac as a Hub, Ring the iPhone for calls from bots on this Mac #122) also reach a closed phone.

3. Ring Noodle Mobile through PushKit when it is closed or away (Mobile)

  • Registration:
    • PKPushRegistry(.voIP) starts at launch, but only in the build with bundle ID com.pdparchitect.noodle.mobile, on phones with CallKit.
    • The phone registers its VoIP token with Push once per Hub, signed with that pairing's key.
    • It sends each Hub a 14-day grant and a call key. Call keys are kept in the Keychain as AfterFirstUnlockThisDeviceOnly.
    • The grant is renewed on connect or check-in once less than 7 days remain. The phone registers again when the token or environment changes, when the sealed token is older than 60 days, or when the Hub dropped its grant.
    • When iOS takes the token back, the phone tells each Hub to stop.
  • Incoming push: reported to CallKit synchronously, before the delegate returns, with no networking first.
    • On iOS 26.4+ the mustReport metadata is honoured.
    • The phone reads the payload's hub hash and checks it against the Hubs it has joined, read straight from their folders.
    • It decrypts the caller with that Hub's call key and rings until the earlier of Push's and the Hub's end times.
    • A push from an unknown Hub, or one that is unreadable or late, is reported as "Noodle" and ended at once.
  • While it rings: the phone subscribes to that Hub and stops ringing on callStopped. Answer uses startCall(answering:) and gives up after 6 s with a local "Missed call from …" notification. Decline uses declineCall.
  • CallKit: the app now has one CXProvider, shared through per-Hub PhoneCallLines. A call reported from a push and the same call ringing in the app are therefore one call.
  • voip was already in UIBackgroundModes and aps-environment was already set, so no entitlement change was needed.

Security notes

  • A security review of this branch came back "merge after fixes" with no holes found. All its fixes are in:
    • the Hub-side hook is on;
    • the phone sends a new grant when its call key is missing;
    • Keychain save failures stop the grant;
    • keys are deleted only for Hub folders that are really gone;
    • registration and grant back-off: no new grant within 15 min of the last, no new registration within an hour of a fresh one, and 15 min after a failure;
    • grants are dropped only for grant-specific refusals;
    • a 60 s Hub-wide pause after rate limiting;
    • the Hub's own end time is sealed in the caller;
    • access.json is 0600.
  • Raw VoIP tokens travel only between the phone and Push. Hubs keep the sealed token inside the grant, with an expiry the phone signed (14 days; Push caps grants at 15).
  • Neither Push nor Apple can read the caller. Hub logs say only what Push answered: outcome, HTTP and APNs status, and Push's short reason. A test checks that the sealed token, signatures, keys, blob, bot name and IDs never appear in them.
  • The phone never trusts a push on its own. The Hub hash must match a Hub it has joined, and the caller must open with that Hub's key for that call ID.
  • Follow-up, not in this PR: LinkIdentity keys, both the device key and the Hub key, are files rather than Keychain items, so they travel with backups. This predates this work.

Tests (local, Xcode 27.1)

  • swift test --package-path Shared/HubLink: all pass, including 5 new LinkCallPushTests:
    • grants and registrations from a pairing match PushWire's vectors byte for byte, and Push's own vector signature verifies on them;
    • request and status JSON round-trip, and older Hubs read as not ringing;
    • the Hub-side grant check;
    • the caller blob opens only with its key and for its call.
  • swift test --package-path Push: 33 pass (6 PushWire, 27 PushCore).
  • HubCore CallPushTests, using a fake Push endpoint over real QUIC pairing:
    • only the away device is rung, and the device that follows the Hub is not;
    • the request signature verifies with PushWire's own verifier;
    • the blob decrypts to the caller with the Hub's end time;
    • 429 keeps the grant, and 410 drops it and shows nil in the device's status;
    • the drop rules;
    • grants signed for another Hub or by another device are refused, and a stop request clears the grant;
    • log redaction;
    • the 60 s rate-limit pause;
    • access.json is 0600.
    • PersonalHubTests checks This Mac reports ringsThroughPush. All 353 HubCoreTests pass, and so does Tests/ReleaseAutomation.
    • Test-first: with the ringAway hook switched off, the three ringing tests failed. With it on, they pass.
  • Mobile CallPushTests:
    • when to register, grant or stop, including the storm cases;
    • against a fake Hub over QUIC: the full flow (register signed with the pairing key, grant for that Hub, renew without registering, register again after the Hub drops the grant, stop when the token goes, key removed with the Hub);
    • a Hub that keeps refusing costs 1 registration and 4 grant attempts an hour;
    • push decisions: known Hub, unknown Hub, late, wrong call, garbage, missing key, the Hub's earlier end time;
    • the APNs environment taken from the provisioning profile;
    • Noodle Dev does not listen.
    • Not run on this Mac: scripts/mobile.sh test stops at signing because this account has no Apple developer account or certificate. The app and its tests do compile for iOS unsigned (xcodebuild build-for-testing CODE_SIGNING_ALLOWED=NO). CI test-mobile runs them.

Device test (Petko's iPhone, needs a build with the release bundle ID)

  • Close the app from the app switcher, then have a bot call. The phone rings on the call screen with the bot's name.
  • Answer works, including from the lock screen without unlocking, and connects the call in its conversation.
  • Decline stops the ring on the Mac and leaves a Declined call card.
  • Answer on the Mac instead: the phone stops ringing.
  • Away on mobile data with the app closed: the phone rings and Answer connects through the router port mapping.
  • With the Hub unreachable after the ring starts, Answer fails after about 6 s with the "Missed call" notification.
  • With This Mac as a Hub, a call from a Mac bot rings the closed phone.
  • Sandbox vs production: TestFlight registers production. A build installed from Xcode registers sandbox (read from aps-environment in the embedded profile), but only if it uses the release bundle ID.

Limitations

  • Noodle Dev (…mobile.local) cannot be rung. Push's VoIP topic is fixed to com.pdparchitect.noodle.mobile.voip, and clients may not choose topics. A Dev build therefore doesn't register for VoIP. To test sandbox, build Debug with MOBILE_APP_BUNDLE_ID=com.pdparchitect.noodle.mobile. Otherwise test with TestFlight in production.
  • A pushed call answered while the app is in the background shows on the iOS call screen only. The in-app call bar isn't shown for it.
  • Ringing a phone that is away still depends on the Hub being reachable from outside, through the router port mapping or a manual address. Without that the phone rings but can't answer, and the missed-call notification says so.
  • CallKit isn't available in mainland China, so no VoIP registration happens there.
  • There is no immediate revocation beyond unpairing. A grant the Hub already holds stays valid until it expires (at most 15 days) unless the device sends a stop request.
  • I haven't run this on a device. The device checklist above is the first real end-to-end test.

noodle-theo Bot added 3 commits October 21, 2026 20:57
PushWire moves into its own package, Push/Wire, so HubLink can depend on it
with nothing but swift-crypto; depending on the Push package itself pulled
Hummingbird and APNSwift into every app's package resolution. The server
builds the same code (the Dockerfile copies Wire/), and its vectors are
unchanged.

HubLink gains the callPush request (a CallGrant the phone signed for this
Hub's key plus a per-Hub call key), LinkStatus.ringsThroughPush and
callPushExpiresAt, the encrypted caller (LinkCallPushCaller, AES-GCM with the
call key, bound to the call ID, with the Hub's own end of the ring), the
Hub-side grant check, and HubPairing helpers that sign registrations and
grants with the key the device paired with that Hub.
HubLinkService rings, on each callRinging, the owner's devices that gave a
grant and are not following the Hub, signing a PushRingRequest with the Hub
key. Grants Push says can never ring again (unregistered, registrationExpired,
grantExpired, or rejected as badSealedToken, badGrant or grantTooLong) are
dropped, and the device sees that in its status; a Hub-wide rate limit
pauses rings for a minute. Logs name the outcome only.

Grants and call keys are kept on HubDevice; the access file is written 0600.
Personal Hubs (This Mac as a Hub, #122) use the same path, so calls from bots
on the Mac push too.
The release build registers for VoIP pushes at launch, registers its token
with Noodle Push per Hub (signed with that pairing's key) and sends each Hub
a 14-day grant and a call key kept in the keychain, renewing it under 7 days
left, with back-off so a refusing Hub or stale status causes no storm.

A push is reported to CallKit before the delegate returns; a push from a Hub
the phone is not in, unreadable, or too late is reported and ended. A ringing
pushed call follows its Hub for callStopped; Answer connects within 6 s or
leaves a missed-call notification; Decline goes to the Hub.

CallKit now has one provider for the app, shared by every Hub's calls through
PhoneCallLine, so a call a push reported and the same call ringing in-app are
one call.
@noodle-theo
noodle-theo Bot force-pushed the theo/push-calls-clients branch from 2d7304b to 09058f6 Compare October 21, 2026 20:50
@pdparchitect
pdparchitect merged commit 66dd365 into main Oct 21, 2026
62 of 64 checks passed
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.

1 participant