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
@@ -0,0 +1,48 @@
import Foundation

/// Encodes the full-key v1 pairing payload required by released iOS clients
/// that predate the compact ticket and bare-route grammars.
public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
/// The compatibility payload is non-authorizing, so its synthetic expiry
/// only prevents historical decoders from rejecting a displayed code.
private static let compatibilityExpiry = Date(timeIntervalSince1970: 4_102_444_800)

/// Creates the stateless compatibility encoder.
public init() {}

/// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the
/// ticket has no Tailscale route to disclose.
public func encode(_ ticket: CmxAttachTicket) throws -> URL? {
let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale }
guard !tailscaleRoutes.isEmpty else { return nil }

let legacyTicket = try CmxAttachTicket(
version: ticket.version,
workspaceID: ticket.workspaceID,
terminalID: ticket.terminalID,
macDeviceID: ticket.macDeviceID,
macDisplayName: ticket.macDisplayName,
macUserEmail: nil,
macUserID: ticket.macUserID,
macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion,
macAppVersion: ticket.macAppVersion,
macAppBuild: ticket.macAppBuild,
routes: tailscaleRoutes,
expiresAt: Self.compatibilityExpiry,
authToken: nil
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let payload = base64URLEncode(try encoder.encode(legacyTicket))
return URL(
string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)"
)
}

private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation
import Testing
@testable import CMUXMobileCore

@Suite struct CmxLegacyPrivateNetworkPairingCodeTests {
@Test func encodesTokenlessTailscaleOnlyFullKeyPayload() throws {
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.0.5", port: 58_465),
priority: 10
)
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "a", count: 64)
),
pathHints: []
),
priority: 0
)
let sourceExpiry = Date(timeIntervalSince1970: 1_800_000_000)
let ticket = try CmxAttachTicket(
version: CmxAttachTicket.currentVersion,
workspaceID: "",
terminalID: nil,
macDeviceID: "mac-1",
macDisplayName: "Mac",
macUserEmail: "private@example.com",
macUserID: "opaque-user-id",
macPairingCompatibilityVersion: 1,
macAppVersion: "1.0",
macAppBuild: "100",
routes: [iroh, tailscale],
expiresAt: sourceExpiry,
authToken: "secret"
)

let encodedURL = try CmxLegacyPrivateNetworkPairingCode().encode(ticket)
let url = try #require(encodedURL)
let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
let encoded = try #require(
components.queryItems?.first(where: { $0.name == "payload" })?.value
)
let data = try #require(Self.decodeBase64URL(encoded))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(CmxAttachTicket.self, from: data)

#expect(decoded.routes == [tailscale])
#expect(decoded.authToken == nil)
#expect(decoded.macUserEmail == nil)
#expect(decoded.macUserID == "opaque-user-id")
#expect(try #require(decoded.expiresAt) > sourceExpiry.addingTimeInterval(365 * 24 * 60 * 60))
}

@Test func returnsNilWithoutTailscaleRoute() throws {
let ticket = try CmxAttachTicket(
version: CmxAttachTicket.currentVersion,
workspaceID: "",
terminalID: nil,
macDeviceID: "mac-1",
macDisplayName: "Mac",
macUserEmail: nil,
macUserID: "opaque-user-id",
routes: [
try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "b", count: 64)
),
pathHints: []
),
priority: 0
),
],
expiresAt: nil,
authToken: nil
)

#expect(try CmxLegacyPrivateNetworkPairingCode().encode(ticket) == nil)
}

private static func decodeBase64URL(_ value: String) -> Data? {
var normalized = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
normalized += String(repeating: "=", count: (4 - normalized.count % 4) % 4)
return Data(base64Encoded: normalized)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
let deviceId = device.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !deviceId.isEmpty else { return nil }
let instances = (device.instances ?? []).map { instance in
RegistryAppInstance(
tag: instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? instance.tag! : "default",
let tag = instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines)
return RegistryAppInstance(
tag: tag?.isEmpty == false ? tag! : "default",
routes: (instance.routes ?? []).compactMap(\.value),
lastSeenAt: Self.parseTimestamp(instance.lastSeenAt)
)
Expand Down Expand Up @@ -279,66 +279,40 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
return .distantPast
}

/// Return authoritative routes for a matching device from one decoded
/// registry snapshot. A scoped client selects its exact Mac app-instance
/// tag; an unscoped client accepts routes only when exactly one instance on
/// that physical device advertises any. Returns `nil` when ownership cannot
/// be proven.
static func routes(
forMacDeviceID macDeviceID: String,
pairedMacInstanceTag: String? = nil,
in devices: [RegistryDevice]
) -> [CmxAttachRoute]? {
guard case .unique(let routes) = DeviceRegistryRouteIndex(devices: devices).resolve(
macDeviceID: macDeviceID,
instanceTag: pairedMacInstanceTag
) else { return nil }
return routes
}

/// Decode the `/api/devices` list response and return authoritative routes
/// for the matching device. A scoped client selects its resolved Mac
/// app-instance tag; unscoped builds require one sole route-advertising
/// instance. Returns `nil` when that ownership cannot be proven.
///
/// Each route is decoded *failably* and individually: a malformed or
/// unknown-kind route from any instance (even another Mac's) is skipped
/// rather than failing the whole response. This keeps one bad sibling row
/// from disabling registry refresh for every Mac, and makes old clients
/// forward-compatible when a newer build advertises a route kind they cannot
/// decode.
/// for the matching device. Each route is decoded *failably* and
/// individually by ``parseDeviceList(in:)``: a malformed or unknown-kind
/// route from any instance is skipped rather than failing the whole response.
/// This keeps one bad sibling row from disabling registry refresh for every
/// Mac and makes old clients forward-compatible with new route kinds.
static func routes(
forMacDeviceID macDeviceID: String,
pairedMacInstanceTag: String? = nil,
in data: Data
) -> [CmxAttachRoute]? {
// Decode each route element through an optional wrapper so a single bad
// element decodes to `nil` and is dropped, never throwing for the array.
struct FailableRoute: Decodable {
let value: CmxAttachRoute?
init(from decoder: Decoder) throws {
value = try? CmxAttachRoute(from: decoder)
}
}
struct Instance: Decodable {
let tag: String?
let routes: [FailableRoute]
}
struct Device: Decodable {
let deviceId: String
let instances: [Instance]
}
struct ListResponse: Decodable {
let devices: [Device]
}
guard let decoded = try? JSONDecoder().decode(ListResponse.self, from: data) else {
return nil
}
let target = macDeviceID.lowercased()
guard let device = decoded.devices.first(where: { $0.deviceId.lowercased() == target }) else {
return nil
}
let candidates: [Instance]
if let pairedMacInstanceTag {
// Route authority is an exact Mac-instance identity resolved at app
// composition. Another tag becoming the device's sole live instance
// must not redirect this build's persisted reconnect route.
candidates = device.instances.filter {
$0.tag?.trimmingCharacters(in: .whitespacesAndNewlines) == pairedMacInstanceTag
}
} else {
// Stable/unscoped storage has no tag ownership to prove. Keep the
// existing safe fallback: accept routes only when one instance on
// the physical Mac advertises any.
candidates = device.instances
}
let nonEmpty = candidates
.map { $0.routes.compactMap(\.value) }
.filter { !$0.isEmpty }
return nonEmpty.count == 1 ? nonEmpty[0] : nil
guard let devices = parseDeviceList(in: data) else { return nil }
return routes(
forMacDeviceID: macDeviceID,
pairedMacInstanceTag: pairedMacInstanceTag,
in: devices
)
}

// MARK: - Request building
Expand All @@ -364,3 +338,47 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
return request
}
}

/// Exact, immutable authority lookup for one authenticated registry generation.
/// Building it once keeps a reconnect pass linear even with many saved Macs.
struct DeviceRegistryRouteIndex: Sendable {
private let devicesByID: [String: [RegistryDevice]]

init(devices: [RegistryDevice]) {
devicesByID = Dictionary(grouping: devices) { device in
Self.normalizedDeviceID(device.deviceId)
}
}

func resolve(
macDeviceID: String,
instanceTag: String?
) -> DeviceRegistryRouteResolution {
let matches = devicesByID[Self.normalizedDeviceID(macDeviceID)] ?? []
guard !matches.isEmpty else { return .missing }
guard matches.count == 1, let device = matches.first else { return .ambiguous }

let instances: [RegistryAppInstance]
if let expectedTag = MobileMacInstanceTagAuthority.normalized(instanceTag) {
instances = device.instances.filter {
MobileMacInstanceTagAuthority.normalized($0.tag) == expectedTag
}
} else {
instances = device.instances
}
let nonEmptyRoutes = instances.map(\.routes).filter { !$0.isEmpty }
guard !nonEmptyRoutes.isEmpty else { return .missing }
guard nonEmptyRoutes.count == 1 else { return .ambiguous }
return .unique(nonEmptyRoutes[0])
}

private static func normalizedDeviceID(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}

enum DeviceRegistryRouteResolution: Equatable, Sendable {
case unique([CmxAttachRoute])
case missing
case ambiguous
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ public enum MobilePairingFailureCategory: Equatable, Sendable {
/// The scanned/pasted code only points back at the Mac itself (loopback),
/// which the phone can never dial.
case loopbackRejected
/// A saved legacy route is still valid, but the Mac must publish an Iroh
/// route before this iOS version can reconnect securely. This is version
/// skew, not an account failure, so the saved pairing stays intact.
case macUpdateRequired
/// The pairing code carried only an untrusted manual route that cannot carry
/// the account credential.
case unsupportedRoute
Expand Down Expand Up @@ -109,6 +113,7 @@ extension MobilePairingFailureCategory {
case .invalidCode: return "invalid_code"
case .unrecognizedVersion: return "unrecognized_version"
case .loopbackRejected: return "loopback_rejected"
case .macUpdateRequired: return "mac_update_required"
case .unsupportedRoute: return "unsupported_route"
case .noSupportedRoute: return "no_supported_route"
case .cancelled: return "cancelled"
Expand Down Expand Up @@ -247,6 +252,11 @@ extension MobilePairingFailureCategory {
"mobile.pairing.loopbackRejected",
defaultValue: "This code points at the Mac itself (localhost), so your iPhone can't use it. Update cmux on the Mac and scan its Iroh code."
)
case .macUpdateRequired:
return L10n.string(
"mobile.pairing.macUpdateRequired",
defaultValue: "Update cmux on this Mac to connect securely."
)
case .unsupportedRoute:
return L10n.string(
"mobile.pairing.secureRouteRequired",
Expand Down Expand Up @@ -321,6 +331,11 @@ extension MobilePairingFailureCategory {
"mobile.pairing.guidance.updateApp",
defaultValue: "Update cmux from the App Store (or TestFlight), then scan again."
)
case .macUpdateRequired:
return L10n.string(
"mobile.pairing.guidance.macUpdateRequired",
defaultValue: "Your saved computer will reconnect automatically after you update cmux on the Mac. You do not need to sign out or pair again."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case .invalidCode, .loopbackRejected, .cancelled, .unknown:
return nil
}
Expand Down
Loading