Skip to content

Commit 56ee48a

Browse files
yewreekaclaude
andauthored
Handle pairing join requests in the Notification Service Extension (#984)
A pairing join request now reaches the user even when the app isn't running: the NSE detects PairingJoinRequestContent in both push paths (the welcome push that delivers the brand-new pairing DM, and encrypted message pushes for resends), verifies the slug against this device's own identity key, and shows "<device> is requesting to pair". The verified request is stashed in app-group defaults (PendingPairRequestStore) and the app presents the initiator PIN sheet from the stash on its next activation; the stash doubles as the NSE's dedupe record against the joiner's 5s resend cadence. Verification logic is extracted into PairingJoinRequestDetector, shared with the main stream's fast path, with a pure core covered by unit tests (self-signed slug accepted; foreign-key, expired, self-sender, wrong inbox, and garbage slugs rejected). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce3928e commit 56ee48a

9 files changed

Lines changed: 574 additions & 44 deletions

File tree

Convos/Conversations List/ConversationsViewModel.swift

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,15 @@ extension ConversationsViewModel {
10361036

10371037
private func presentIncomingPairingSheet(joinerInboxId: String, deviceName: String) {
10381038
QAEvent.emit(.pairing, "incoming_request_surfaced", ["joinerInboxId": joinerInboxId])
1039+
// The NSE stashes for pushes that arrive while the app is
1040+
// foregrounded too; presenting from the stream path must discard
1041+
// that stash (and any delivered banners), or the next activation
1042+
// would re-present a ghost PIN sheet for an already-handled
1043+
// request.
1044+
_ = PendingPairRequestStore.consumePending(
1045+
appGroup: ConfigManager.shared.currentEnvironment.appGroupIdentifier
1046+
)
1047+
removeDeliveredPairingNotifications()
10391048
incomingPairingPresentedAt = Date()
10401049
incomingPairingRequest = PairingSheetViewModel(
10411050
pairingService: DeferredInitiatorPairingService(session: session),
@@ -1045,24 +1054,60 @@ extension ConversationsViewModel {
10451054
}
10461055

10471056
private func presentPendingIncomingPairRequestIfNeeded() {
1048-
guard let pending = pendingIncomingPairRequest else { return }
1049-
pendingIncomingPairRequest = nil
1050-
UNUserNotificationCenter.current().removeDeliveredNotifications(
1051-
withIdentifiers: [Constant.incomingPairingNotificationId]
1052-
)
1057+
// Bail before consuming anything when a pairing flow is already
1058+
// on screen: a second initiator coordinator would race PIN
1059+
// generation for the same joiner. Consuming first and bailing
1060+
// after would destructively drop the stash (the app-group read
1061+
// removes it), losing the request if the joiner has stopped
1062+
// re-sending (killed/backgrounded). Leave it stashed so the next
1063+
// activation, once the active flow ends, can present it.
1064+
guard PairingSheetViewModel.active == nil, incomingPairingRequest == nil else { return }
1065+
// Two stash sources: in-memory (request arrived while this
1066+
// process was backgrounded) and the app-group store written by
1067+
// the NSE (request arrived while the app wasn't running at all).
1068+
var pending: PendingIncomingPairRequest?
1069+
if let inMemory = pendingIncomingPairRequest {
1070+
pendingIncomingPairRequest = nil
1071+
pending = inMemory
1072+
} else if let stashed = PendingPairRequestStore.consumePending(
1073+
appGroup: ConfigManager.shared.currentEnvironment.appGroupIdentifier
1074+
) {
1075+
pending = PendingIncomingPairRequest(
1076+
joinerInboxId: stashed.joinerInboxId,
1077+
deviceName: stashed.deviceName,
1078+
receivedAt: stashed.receivedAt
1079+
)
1080+
}
1081+
guard let pending else { return }
1082+
removeDeliveredPairingNotifications()
10531083
// The joiner re-sends its request for the length of the pairing
10541084
// window; past that the handshake would just expire on send, so
10551085
// stale stashes are dropped instead of surfaced.
10561086
guard Date().timeIntervalSince(pending.receivedAt) < Constant.foundDevicePairingWindow else { return }
1057-
guard PairingSheetViewModel.active == nil, incomingPairingRequest == nil else { return }
10581087
presentIncomingPairingSheet(joinerInboxId: pending.joinerInboxId, deviceName: pending.deviceName)
10591088
}
10601089

1090+
/// Removes every delivered "is requesting to pair" banner: the app's
1091+
/// own local one (fixed request identifier) and any the NSE produced
1092+
/// for remote pushes, whose request identifiers are system-assigned
1093+
/// and only findable via the shared pairing thread identifier.
1094+
private func removeDeliveredPairingNotifications() {
1095+
let center = UNUserNotificationCenter.current()
1096+
center.getDeliveredNotifications { notifications in
1097+
let pairingIds = notifications
1098+
.filter { $0.request.content.threadIdentifier == PairingNotificationThread.identifier }
1099+
.map(\.request.identifier)
1100+
let ids = pairingIds + [Constant.incomingPairingNotificationId]
1101+
center.removeDeliveredNotifications(withIdentifiers: ids)
1102+
}
1103+
}
1104+
10611105
private func scheduleIncomingPairingNotification(deviceName: String) {
10621106
let content = UNMutableNotificationContent()
10631107
content.title = "Pair new device"
10641108
content.body = "\"\(deviceName)\" is requesting to pair"
10651109
content.sound = .default
1110+
content.threadIdentifier = PairingNotificationThread.identifier
10661111
let request = UNNotificationRequest(
10671112
identifier: Constant.incomingPairingNotificationId,
10681113
content: content,
@@ -1086,11 +1131,18 @@ extension ConversationsViewModel {
10861131
pendingPairDevice = nil
10871132
}
10881133

1089-
/// Sheet-dismissal hook for the auto-surfaced initiator flow.
1134+
/// Sheet-dismissal hook for the auto-surfaced initiator flow. Also
1135+
/// discards any stash the NSE wrote for resends that landed while
1136+
/// the sheet was up - the request was handled either way, and a
1137+
/// leftover stash would re-present a ghost sheet on next activation.
10901138
func dismissIncomingPairingRequest() {
10911139
incomingPairingRequest?.triggerCancel()
10921140
incomingPairingRequest = nil
10931141
incomingPairingPresentedAt = nil
1142+
_ = PendingPairRequestStore.consumePending(
1143+
appGroup: ConfigManager.shared.currentEnvironment.appGroupIdentifier
1144+
)
1145+
removeDeliveredPairingNotifications()
10941146
}
10951147

10961148
private enum Constant {

ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ extension MessagingService {
133133
// Sync all conversations - this will fetch any groups we've been added to
134134
_ = try await client.conversationsProvider.syncAllConversations(consentStates: [.unknown])
135135

136+
// The pairing DM is a brand-new conversation, so a pairing join
137+
// request's first delivery is this welcome push. Detect it before
138+
// anything else can return - the joiner only waits a few minutes,
139+
// and with no topic subscription for the new DM there may be no
140+
// second push. Detection has no side effects; the invite
141+
// processing below still runs either way.
142+
let pairingRequest = await detectRecentPairingJoinRequest(client: client)
143+
136144
// Case 1: Process join requests (others accepting our invites)
137145
let joinRequestOutcomes = await joinRequestsManager.processJoinRequestOutcomes(since: lastProcessed, client: client)
138146
await handleJoinRequestOutcomesForPush(
@@ -142,6 +150,19 @@ extension MessagingService {
142150
context: "welcome"
143151
)
144152

153+
if let pairingRequest {
154+
// Only let a genuinely surfaced pairing request short-circuit.
155+
// The scan spans all recent DMs, not just this push's topic, so
156+
// a deduped duplicate (.droppedMessage) might belong to an
157+
// earlier push - returning it here would suppress a join-result
158+
// or new-group notification this same push legitimately carries.
159+
// On a duplicate, fall through to those checks instead.
160+
let notification = pairingRequestNotification(pairingRequest, userInfo: userInfo)
161+
if !notification.isDroppedMessage {
162+
return notification
163+
}
164+
}
165+
145166
if let result = joinRequestOutcomes.compactMap(\.result).first {
146167
setLastWelcomeProcessed(processTime, for: client.inboxId)
147168

@@ -335,6 +356,14 @@ extension MessagingService {
335356
return .droppedMessage
336357
}
337358

359+
// A pairing join request (the joiner re-sends every few
360+
// seconds while connecting) - surface "<device> is requesting
361+
// to pair" instead of feeding it to the invite flow.
362+
if let identity = try? identityStore.loadSync(),
363+
let pairingRequest = PairingJoinRequestDetector.verifiedJoinRequest(in: decodedMessage, identity: identity) {
364+
return pairingRequestNotification(pairingRequest, userInfo: userInfo)
365+
}
366+
338367
// DMs are only used for join requests (invite acceptance flow)
339368
// When someone accepts an invite, they send the signed invite back via DM
340369
// This allows us to add them to the group conversation they were invited to
@@ -1249,3 +1278,93 @@ extension MessagingService {
12491278
}
12501279
}
12511280
}
1281+
1282+
// MARK: - Pairing Join Requests
1283+
1284+
extension MessagingService {
1285+
/// Scans recently created unknown-consent DMs for a verified pairing
1286+
/// join request (see `PairingJoinRequestDetector` for the security
1287+
/// model). Used by the welcome-push path, where the request's content
1288+
/// isn't in the payload - the pairing DM was just synced from the
1289+
/// network. Bounded to a handful of fresh DMs and their latest
1290+
/// messages so it stays cheap inside the NSE's time budget.
1291+
func detectRecentPairingJoinRequest(client: any XMTPClientProvider) async -> VerifiedPairingJoinRequest? {
1292+
guard let identity = try? identityStore.loadSync() else { return nil }
1293+
let cutoff = Date().addingTimeInterval(-PairingPushConstant.requestWindow)
1294+
let cutoffNs = Int64(cutoff.timeIntervalSince1970 * 1_000_000_000)
1295+
let dms: [Dm]
1296+
do {
1297+
dms = try client.conversationsProvider.listDms(
1298+
createdAfterNs: cutoffNs,
1299+
createdBeforeNs: nil,
1300+
lastActivityBeforeNs: nil,
1301+
lastActivityAfterNs: nil,
1302+
limit: PairingPushConstant.maxScannedDms,
1303+
consentStates: [.unknown],
1304+
orderBy: .createdAt
1305+
)
1306+
} catch {
1307+
Log.warning("NSE: failed to list DMs for pairing scan: \(error)")
1308+
return nil
1309+
}
1310+
for dm in dms {
1311+
let messages = (try? await dm.messages(limit: PairingPushConstant.maxScannedMessagesPerDm)) ?? []
1312+
for message in messages {
1313+
if let request = PairingJoinRequestDetector.verifiedJoinRequest(in: message, identity: identity) {
1314+
return request
1315+
}
1316+
}
1317+
}
1318+
return nil
1319+
}
1320+
1321+
/// Builds the "<device> is requesting to pair" notification and
1322+
/// stashes the request for the main app to present on next activation
1323+
/// (`PendingPairRequestStore`). The stash doubles as the dedupe
1324+
/// record: the joiner re-sends its request every few seconds, and one
1325+
/// banner per burst is plenty.
1326+
func pairingRequestNotification(
1327+
_ request: VerifiedPairingJoinRequest,
1328+
userInfo: [AnyHashable: Any]
1329+
) -> DecodedNotificationContent {
1330+
let appGroup = environment.appGroupIdentifier
1331+
if let existing = PendingPairRequestStore.pending(appGroup: appGroup),
1332+
existing.joinerInboxId == request.joinerInboxId,
1333+
Date().timeIntervalSince(existing.receivedAt) < PairingPushConstant.dedupeWindow {
1334+
Log.debug("NSE: suppressing duplicate pairing request notification")
1335+
return .droppedMessage
1336+
}
1337+
PendingPairRequestStore.setPending(
1338+
.init(
1339+
joinerInboxId: request.joinerInboxId,
1340+
deviceName: request.deviceName,
1341+
receivedAt: Date()
1342+
),
1343+
appGroup: appGroup
1344+
)
1345+
Log.info("NSE: surfacing pairing join request from \(request.joinerInboxId)")
1346+
// The conversationId becomes the notification's threadIdentifier.
1347+
// Stamping the fixed pairing thread lets the system collapse a
1348+
// resend burst's banners and lets the app's activation cleanup
1349+
// find and remove NSE-posted ones (whose request identifiers are
1350+
// system-assigned and unknowable here).
1351+
return .init(
1352+
title: "Pair new device",
1353+
body: "\"\(request.deviceName)\" is requesting to pair",
1354+
conversationId: PairingNotificationThread.identifier,
1355+
userInfo: userInfo
1356+
)
1357+
}
1358+
1359+
private enum PairingPushConstant {
1360+
/// How far back a DM can have been created and still be scanned;
1361+
/// matches the joiner's resend window.
1362+
static let requestWindow: TimeInterval = 300
1363+
/// One banner per request burst: the joiner re-sends every ~5s,
1364+
/// and each NSE invocation is a fresh process, so dedupe lives in
1365+
/// the app-group stash rather than memory.
1366+
static let dedupeWindow: TimeInterval = 60
1367+
static let maxScannedDms: Int = 10
1368+
static let maxScannedMessagesPerDm: Int = 5
1369+
}
1370+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Foundation
2+
@preconcurrency import XMTPiOS
3+
4+
/// A pairing join request that passed self-signature verification.
5+
public struct VerifiedPairingJoinRequest: Sendable, Equatable {
6+
public let joinerInboxId: String
7+
public let deviceName: String
8+
public let slug: String
9+
10+
public init(joinerInboxId: String, deviceName: String, slug: String) {
11+
self.joinerInboxId = joinerInboxId
12+
self.deviceName = deviceName
13+
self.slug = slug
14+
}
15+
}
16+
17+
/// Shared verification for pairing join requests that arrive outside an
18+
/// active pairing session - on the main message stream (`StreamProcessor`)
19+
/// or in a push notification (the NSE's welcome and message paths).
20+
///
21+
/// Only requests whose embedded invite slug is signed by this inbox's own
22+
/// identity key are surfaced: the iCloud-discovery joiner mints its slug
23+
/// from the synced keychain backup and the QR flow signs its slug locally,
24+
/// so both carry our signature, while a forged request can't - producing a
25+
/// valid slug requires the private key. The address comparison anchors
26+
/// that verification to our key: the slug's inboxId and address fields are
27+
/// attacker-choosable, but the signature only ever recovers to the
28+
/// signer's own address.
29+
public enum PairingJoinRequestDetector {
30+
/// Returns the verified join request carried by `message`, or nil when
31+
/// the message isn't a pairing join request or fails verification.
32+
/// Decodes via the codec directly so it works on clients that never
33+
/// registered the pairing codecs (the NSE's).
34+
public static func verifiedJoinRequest(
35+
in message: DecodedMessage,
36+
identity: KeychainIdentity
37+
) -> VerifiedPairingJoinRequest? {
38+
guard let typeId = try? message.encodedContent.type.typeID,
39+
typeId == ContentTypePairingJoinRequest.typeID,
40+
let content = try? PairingJoinRequestCodec().decode(content: message.encodedContent) else {
41+
return nil
42+
}
43+
guard verify(slug: content.slug, senderInboxId: message.senderInboxId, identity: identity) else {
44+
return nil
45+
}
46+
return VerifiedPairingJoinRequest(
47+
joinerInboxId: message.senderInboxId,
48+
deviceName: content.deviceName,
49+
slug: content.slug
50+
)
51+
}
52+
53+
/// Pure verification core, separated from `DecodedMessage` extraction
54+
/// so it can be unit tested with minted slugs. True when `slug` is a
55+
/// valid, unexpired invite signed by `identity`'s own key and the
56+
/// sender isn't this inbox itself.
57+
public static func verify(
58+
slug: String,
59+
senderInboxId: String,
60+
identity: KeychainIdentity
61+
) -> Bool {
62+
guard senderInboxId != identity.inboxId else { return false }
63+
guard let invite = try? PairingInvite.fromURLSafeSlug(slug) else { return false }
64+
return invite.initiatorInboxId == identity.inboxId
65+
&& invite.initiatorAddress.lowercased() == identity.keys.privateKey.walletAddress.lowercased()
66+
}
67+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import Foundation
2+
3+
/// Hands a verified pairing join request from the Notification Service
4+
/// Extension to the main app. The NSE can't present the pairing sheet, so
5+
/// it stashes the request here (and shows a "<device> is requesting to
6+
/// pair" banner); the app consumes the stash on its next activation and
7+
/// presents the initiator flow. Also acts as the NSE's notification
8+
/// dedupe: the joiner re-sends its request every few seconds, and one
9+
/// banner per request burst is plenty.
10+
///
11+
/// Storage is app-group UserDefaults so both processes see it, mirroring
12+
/// `PairedDeviceNameStore`. UserDefaults is process-safe.
13+
public enum PendingPairRequestStore {
14+
/// Display metadata only - deliberately no invite slug. A valid
15+
/// unexpired slug is a bearer credential (it passes
16+
/// `PairingJoinRequestDetector.verify` from any inbox), and the
17+
/// app-group plist is plaintext and included in device backups; the
18+
/// consumer only needs the joiner identity and a name to present
19+
/// the PIN sheet.
20+
public struct Pending: Codable, Sendable, Equatable {
21+
public let joinerInboxId: String
22+
public let deviceName: String
23+
public let receivedAt: Date
24+
25+
public init(joinerInboxId: String, deviceName: String, receivedAt: Date) {
26+
self.joinerInboxId = joinerInboxId
27+
self.deviceName = deviceName
28+
self.receivedAt = receivedAt
29+
}
30+
}
31+
32+
private static let pendingKey: String = "convos.pairing.pendingJoinRequest.v1"
33+
34+
private static func defaults(for appGroup: String) -> UserDefaults {
35+
guard let suite = UserDefaults(suiteName: appGroup) else {
36+
// Falling back means the NSE and the app each write their own
37+
// standard defaults and the handoff silently never happens -
38+
// make that observable.
39+
Log.warning("PendingPairRequestStore: app-group suite unavailable, falling back to standard defaults")
40+
return .standard
41+
}
42+
return suite
43+
}
44+
45+
/// Stashes the request, replacing any previous one.
46+
public static func setPending(_ pending: Pending, appGroup: String) {
47+
guard let data = try? JSONEncoder().encode(pending) else { return }
48+
defaults(for: appGroup).set(data, forKey: pendingKey)
49+
}
50+
51+
/// Reads without clearing. The NSE uses this for dedupe decisions.
52+
public static func pending(appGroup: String) -> Pending? {
53+
guard let data = defaults(for: appGroup).data(forKey: pendingKey) else { return nil }
54+
return try? JSONDecoder().decode(Pending.self, from: data)
55+
}
56+
57+
/// Reads and clears. The app calls this on activation, and also when
58+
/// the stream path presents a request directly - the NSE stashes for
59+
/// pushes that arrive while the app is foregrounded too, and a stash
60+
/// left behind after the flow was handled would re-present a ghost
61+
/// PIN sheet on the next activation.
62+
public static func consumePending(appGroup: String) -> Pending? {
63+
let defs = defaults(for: appGroup)
64+
guard let data = defs.data(forKey: pendingKey) else { return nil }
65+
defs.removeObject(forKey: pendingKey)
66+
return try? JSONDecoder().decode(Pending.self, from: data)
67+
}
68+
}
69+
70+
/// Thread identifier stamped on every "is requesting to pair"
71+
/// notification - the NSE's remote ones (via
72+
/// `DecodedNotificationContent.conversationId`, which the extension maps
73+
/// to `threadIdentifier`) and the app's local one - so the system
74+
/// collapses a resend burst into one thread and the app's activation
75+
/// cleanup can remove NSE-posted banners whose request identifiers are
76+
/// system-assigned.
77+
public enum PairingNotificationThread {
78+
public static let identifier: String = "incoming-pairing-request"
79+
}

0 commit comments

Comments
 (0)