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
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ extension MobilePairingFailureCategory {
case .unsupportedRoute:
return L10n.string(
"mobile.pairing.secureRouteRequired",
defaultValue: "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer."
defaultValue: "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR."
)
case .noSupportedRoute:
return L10n.string(
Expand Down Expand Up @@ -373,7 +373,7 @@ extension MobilePairingFailureCategory {
case .tailscaleUnavailable:
return L10n.string(
"mobile.pairing.guidance.tailscaleUnavailable",
defaultValue: "Open Tailscale on both devices, confirm they use the same network, then scan a fresh Pair iPhone code from the Mac."
defaultValue: "Open Tailscale on both devices, then scan a fresh Mac pairing QR or enter its numeric Tailscale IP and port."
)
case .hostUnreachable, .dnsFailed, .handshakeTimedOut:
return L10n.string(
Expand Down Expand Up @@ -416,7 +416,7 @@ extension MobilePairingFailureCategory {
case .ticketExpired, .unsupportedRoute, .noSupportedRoute:
return L10n.string(
"mobile.pairing.guidance.rescanFresh",
defaultValue: "On cmux 0.64.17, open Pair iPhone. On newer versions, open Tailscale Pairing. Then scan a fresh QR or link."
defaultValue: "Open Tailscale Pairing on the Mac and scan a fresh QR, or enter the Mac's numeric Tailscale IP and port."
)
case .unrecognizedVersion:
return L10n.string(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import Foundation

@MainActor
extension MobileShellComposite {
/// Manual tickets are local placeholders, not authenticated Mac
/// identities. The real device id is learned from the host-status
/// response after the exact route has authenticated.
nonisolated static func isSyntheticManualDeviceID(_ rawValue: String) -> Bool {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
return value == "manual-ticket-request" || value.hasPrefix("manual-")
}

nonisolated static func boundedPairingRequestTimeoutNanoseconds(
runtime: any MobileSyncRuntime,
attemptStartedAt: Date
Expand Down Expand Up @@ -49,6 +57,9 @@ extension MobileShellComposite {
) async throws -> CmxAttachTicket {
let directRoute = try Self.manualHostRoute(host: host, port: port)
let displayName = name.isEmpty ? host : name
// Non-loopback callers supply an exact user-entry capability to the
// subsequent `connect` call. This helper intentionally mints only a
// route-scoped synthetic ticket and never broadens bearer authority.
if MobileShellRouteAuthPolicy.routeAllowsStackAuth(directRoute) {
do {
let ticket = try await requestManualAttachTicket(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2561,18 +2561,99 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return
}

let directRoute = try? Self.manualHostRoute(
guard let directRoute = try? Self.manualHostRoute(
host: normalizedHost,
port: port
)
let sameRouteProbeClient: MobileCoreRPCClient? = directRoute.flatMap { route in
) else {
if recordsPairingAttempt {
recordAppEvent(.pairingStarted)
recordAppEvent(.pairingFailed, failure: .protocolViolation)
}
connectionError = L10n.string(
"mobile.addDevice.invalidHost",
defaultValue: "Enter a host or IP address, without spaces or URL paths."
)
connectionErrorGuidance = nil
connectionState = .disconnected
macConnectionStatus = .unavailable
clearRemoteConnectionContext()
analytics.capture("ios_pairing_failed", [
"method": .string("manual"),
"reason": .string("invalid_host"),
"failure_phase": .string("validation"),
"is_first_pair": .bool(!hasKnownPairedMac),
])
return
}

let isLoopbackRoute = MobileShellRouteAuthPolicy.routeIsLoopback(directRoute)
if MobileShellRouteAuthPolicy.ticketRejectsLoopbackRoutes(
[directRoute],
isPhysicalDevice: Self.isPhysicalDevice
) {
if recordsPairingAttempt {
recordAppEvent(.pairingStarted)
recordAppEvent(.pairingFailed, failure: .unsupportedRoute)
}
connectionError = L10n.string(
"mobile.pairing.loopbackRejected",
defaultValue: "This device cannot connect to the Mac through localhost. Scan the Mac's Tailscale pairing QR or enter its numeric Tailscale IP."
)
connectionErrorGuidance = nil
connectionState = .disconnected
macConnectionStatus = .unavailable
clearRemoteConnectionContext()
analytics.capture("ios_pairing_failed", [
"method": .string("manual"),
"reason": .string("loopback_rejected"),
"failure_phase": .string("validation"),
"is_first_pair": .bool(!hasKnownPairedMac),
])
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A fresh manual attempt is an explicit user action, so a numeric
// Tailscale address can receive the same exact-destination capability
// as a scanned/pasted QR route. MagicDNS, LAN, and arbitrary names do
// not provide a stable peer proof and must fail before any TCP dial.
let userTailscalePairingAuthorization: CmxUserTailscalePairingAuthorization?
if recordsPairingAttempt && !isLoopbackRoute {
userTailscalePairingAuthorization = try? CmxUserTailscalePairingAuthorization(
host: normalizedHost,
port: port
)
guard userTailscalePairingAuthorization != nil else {
recordAppEvent(.pairingStarted)
recordAppEvent(.pairingFailed, failure: .unsupportedRoute)
connectionError = L10n.string(
"mobile.addDevice.tailscaleNumericRequired",
defaultValue: "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported."
)
connectionErrorGuidance = nil
connectionState = .disconnected
macConnectionStatus = .unavailable
clearRemoteConnectionContext()
analytics.capture("ios_pairing_failed", [
"method": .string("manual"),
"reason": .string("unsupported_route"),
"failure_phase": .string("validation"),
"is_first_pair": .bool(!hasKnownPairedMac),
])
return
}
} else {
userTailscalePairingAuthorization = nil
}

let sameRouteProbeClient: MobileCoreRPCClient? = {
let route = directRoute
guard remoteClient?.sharesPhysicalTransportRoute(
with: route
) == true else {
return nil
}
return remoteClient
}
}()
if sameRouteProbeClient == nil {
activeRoute = directRoute
}
Expand All @@ -2590,7 +2671,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
// Fast offline preflight: fail immediately instead of stacking
// per-route timeouts into the opaque ~60s blob.
let manualRoutes = directRoute.map { [$0] } ?? []
let manualRoutes = [directRoute]
if sameRouteProbeClient == nil {
guard await failPairingIfOffline(
attemptID: attemptID,
Expand Down Expand Up @@ -2618,7 +2699,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
let noThrowFailure = try await connect(
ticket: ticket,
allowsStackAuthFallback: true,
// The generic Stack-bearer fallback remains loopback-only.
// Numeric Tailscale pairing uses the explicit authorization
// mode below, which is independent of this fallback flag.
allowsStackAuthFallback: isLoopbackRoute,
userTailscalePairingAuthorizations: userTailscalePairingAuthorization.map { [$0] } ?? [],
pairedMacDeviceID: pairedMacDeviceID,
instanceTagExpectation: instanceTagExpectation,
ifStillCurrent: ifStillCurrent
Expand Down Expand Up @@ -9231,7 +9316,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let ticketMacDeviceID = ticket.macDeviceID
.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedMacDeviceID = pairedMacDeviceID
?? (ticketMacDeviceID.isEmpty ? nil : ticketMacDeviceID)
?? (ticketMacDeviceID.isEmpty
|| Self.isSyntheticManualDeviceID(ticketMacDeviceID)
? nil
: ticketMacDeviceID)
let previousForegroundKeyBeforeConnect = foregroundOrRecoveryMacKey
let currentFocusedConnection: MacConnection? =
foregroundMacDeviceID.flatMap { macID in
Expand Down Expand Up @@ -9260,10 +9348,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// pins no addresses (automatic, or a legacy pairing without an Iroh
// identity).
let directOnlyDialCandidates = directOnlyDialCandidates
?? irohMethodPinnedDialCandidates(
forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID,
instanceTag: instanceTagExpectation.expectedTag
)
?? (userTailscalePairingAuthorizations.isEmpty
? irohMethodPinnedDialCandidates(
forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID,
instanceTag: instanceTagExpectation.expectedTag
)
: nil)
let supportedRoutes = supportedRoutes(
for: ticket,
supportedKinds: supportedKinds,
Expand Down Expand Up @@ -9652,7 +9742,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
let ticketDeviceID = ticket.macDeviceID
.trimmingCharacters(in: .whitespacesAndNewlines)
let expectedDeviceID = pairedMacDeviceID ?? (ticketDeviceID.isEmpty ? nil : ticketDeviceID)
let expectedDeviceID = pairedMacDeviceID
?? (ticketDeviceID.isEmpty
|| Self.isSyntheticManualDeviceID(ticketDeviceID)
? nil
: ticketDeviceID)
if await adoptWouldConflictWithStoredInstanceAuthority(
expectation: instanceTagExpectation,
reportedInstanceTag: reportedInstanceTag,
Expand Down Expand Up @@ -10020,6 +10114,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
supportedKinds.contains(route.kind)
}
}
// An explicit QR/manual entry is itself the authorization event. Keep
// the dial on the exact numeric Tailscale destination it named even
// when the app-wide method is Automatic, Iroh, or Tailscale Only.
// `directOnly` is reserved for an already-paired Direct connection and
// must remain the stronger, Iroh-only constraint.
if !directOnly, !userTailscalePairingAuthorizations.isEmpty {
return supportedRoutes.filter { route in
Self.userTailscalePairingAuthorization(
for: route,
authorizations: userTailscalePairingAuthorizations
) != nil
}
}

// The explicit Tailscale method is strict: only authorized Tailscale
// destinations may be dialed, and an unavailable route leaves the app
// disconnected instead of silently switching to Iroh. The method is
Expand Down Expand Up @@ -10135,7 +10243,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
_ ticket: CmxAttachTicket,
adoptingReportedDeviceID reportedDeviceID: String?
) -> CmxAttachTicket {
guard ticket.macDeviceID.isEmpty,
guard (ticket.macDeviceID.isEmpty
|| Self.isSyntheticManualDeviceID(ticket.macDeviceID)),
let reportedDeviceID = reportedDeviceID?
.trimmingCharacters(in: .whitespacesAndNewlines),
!reportedDeviceID.isEmpty,
Expand Down
Loading