diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift deleted file mode 100644 index 2e460c9505af..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift +++ /dev/null @@ -1,55 +0,0 @@ -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, - pairingURLScheme: CmxPairingURLScheme? = - CmxPairingURLSchemeResolver().resolved - ) throws -> URL? { - let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale } - guard !tailscaleRoutes.isEmpty, - let scheme = pairingURLScheme?.rawValue 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: "\(scheme)://attach?v=\(legacyTicket.version)&payload=\(payload)" - ) - } - - private func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift index 29e200668e38..812860796bd0 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift @@ -25,11 +25,13 @@ public struct CmxPairingQRBitmap: Sendable { /// zone included, or `nil` when Core Image produces no code (empty or /// over-capacity payload). /// - /// ECC M rather than L: the routes-only payload is small enough that M - /// still keeps the code at QR version 6 or lower (asserted by tests), and - /// the extra redundancy tolerates the glare, moire, and off-angle blur of - /// photographing a glossy Mac screen. L would maximize module size, but - /// module size is not the binding constraint at these payload sizes. + /// ECC M rather than L: the minimal payloads are small enough that M + /// still keeps the code at QR version 6 or lower for routes-only and + /// Iroh codes, and version 8 or lower for the account-bound Tailscale + /// compatibility code (both asserted by tests), and the extra redundancy + /// tolerates the glare, moire, and off-angle blur of photographing a + /// glossy Mac screen. L would maximize module size, but module size is + /// not the binding constraint at these payload sizes. public func makeImage(payload: String) -> CGImage? { let filter = CIFilter.qrCodeGenerator() filter.message = Data(payload.utf8) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index 8b0ea89a41cd..bf26d0c0bea8 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -14,7 +14,16 @@ import Foundation /// /// Tailscale compatibility codes keep the v2 grammar so already-released /// clients can still scan them: -/// `cmux-ios://attach?v=2&ub=&pc=&av=&ab=&r=:[&r=:...]`. +/// `cmux-ios://attach?v=2&ub=&pc=&r=:[&r=:...]`. +/// +/// The only metadata a Tailscale code carries is what the phone consults +/// before dialing: `ub`, the opaque Stack user id the account preflight +/// matches against the signed-in phone so a wrong-account scan fails fast +/// (#6028), and `pc`, the pairing compatibility level, which fielded +/// decoders default to 0 when absent — omitting it would spuriously fire the +/// cross-version pairing warning on every current phone. App version and +/// build (`av`/`ab`) only ever decorated that warning's message, so they are +/// no longer written; the decoder still reads them from older Macs' codes. /// /// Both grammars share these properties: /// - **No auth token.** The owner's Stack access token is the host's sole @@ -22,9 +31,10 @@ import Foundation /// code look like a leaked credential. /// - **No expiry.** Ticket age authorizes nothing, so a code that sat on /// screen for an hour still pairs. -/// - **No display name, no device id.** Both arrive post-handshake from -/// `mobile.host.status`; the decoder leaves `macDeviceID` empty and the -/// shell adopts the host-reported identity once connected. +/// - **No display name, no device id, no build metadata.** All arrive +/// post-handshake from `mobile.host.status`; the decoder leaves +/// `macDeviceID` empty and the shell adopts the host-reported identity +/// once connected. /// - **No loopback, ever.** v2 routes are Tailscale `host:port` only: the /// encoder drops a DEBUG Mac's dev loopback route instead of encoding it, /// the Mac refuses to mint a QR without a Tailscale route (it shows the @@ -105,12 +115,6 @@ public struct CmxPairingQRCode: Sendable { if let compatibilityVersion = ticket.macPairingCompatibilityVersion { compatibilityItems.append("pc=\(compatibilityVersion)") } - if let version = normalizedNonEmpty(ticket.macAppVersion) { - compatibilityItems.append("av=\(percentEncodeQueryValue(version))") - } - if let build = normalizedNonEmpty(ticket.macAppBuild) { - compatibilityItems.append("ab=\(percentEncodeQueryValue(build))") - } compatibilityItems.append(contentsOf: routes.map { route -> String in guard case let .hostPort(host, port) = route.endpoint else { // Unreachable: the selector admits host/port endpoints only. diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift deleted file mode 100644 index f9e57ebeb3c2..000000000000 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -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) - } -} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift index 935452f1b47b..8c82f2040c60 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift @@ -1,4 +1,5 @@ import CoreGraphics +import Foundation import Testing @testable import CMUXMobileCore @@ -65,6 +66,58 @@ import Testing } } + /// The pairing window's real Tailscale compatibility payload also carries + /// the `ub` account binding (an opaque user id, in practice a UUID) and + /// the `pc` compatibility level. Built through the real encoder with the + /// longest realistic inputs (tagged dev scheme, IPv4 + IPv6 routes) so + /// this tracks whatever the encoder actually emits: it may exceed + /// version 6, but stays at or below version 8 (49 modules), where + /// modules still render large on screen. The full-key JSON payload this + /// replaced rendered version 23 (109 modules). + @Test func realCompatibilityPayloadStaysAtOrBelowVersionEight() throws { + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-device-uuid", + macDisplayName: "Lawrence's Mac", + macUserEmail: nil, + macUserID: "8b7e6a2f-1234-4c5d-9e8f-0a1b2c3d4e5f", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, + macAppVersion: "0.65.0", + macAppBuild: "42", + routes: [ + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.101.102.103", port: 52341), + priority: 10 + ), + try CmxAttachRoute( + id: "tailscale_2", + kind: .tailscale, + endpoint: .hostPort(host: "fd7a:115c:a1e0::1234:5678", port: 52341), + priority: 20 + ), + ], + expiresAt: Date().addingTimeInterval(600), + authToken: "minted-but-never-in-the-qr" + ) + let payload = try #require(CmxPairingQRCode().encode( + ticket, + routeDisclosureMode: .legacyPrivateNetworkCompatibility, + pairingURLScheme: try #require( + CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.ios.longtag") + ) + )) + let image = try #require(CmxPairingQRBitmap().makeImage(payload: payload)) + let modules = image.width - CmxPairingQRBitmap.quietZoneModules * 2 + #expect((modules - 17) % 4 == 0, "\(modules) modules is not a QR version") + #expect( + modules <= 49, + "account-bound compat payload should stay at version <= 8, got \(modules) modules" + ) + } + /// Renders `image` into an sRGB bitmap and reduces each pixel to its red /// channel; the QR is grayscale, so one channel carries the module value. private func grayLevels(of image: CGImage) throws -> [UInt8] { diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift index 72b5cf0ec75e..b3784a8467f5 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift @@ -91,7 +91,7 @@ import Testing #expect(decoded.routes.map(\.priority) == [10, 20]) } - @Test func roundTripsUserIDAndBuildMetadataWithoutExposingEmail() throws { + @Test func encodesOnlyAccountBindingAndCompatibilityLevelFromMetadata() throws { let ticket = try CmxAttachTicket( workspaceID: "", terminalID: nil, @@ -110,20 +110,37 @@ import Testing ) let url = try #require(encodeLegacy(ticket)) + // `ub` survives (the account preflight's wrong-account fast-fail) and + // `pc` survives (fielded decoders default a missing `pc` to 0, which + // would spuriously fire the cross-version pairing warning). Email is + // never written, and app version/build only ever decorated that + // warning's message, so they are no longer written either. #expect(url.contains("ub=user_mac_123")) + #expect(url.contains("pc=1")) #expect(!url.contains("Lawrence@Example.com")) #expect(!url.lowercased().contains("lawrence@example.com")) - #expect(url.contains("pc=1")) - #expect(url.contains("av=0.64.15")) - #expect(url.contains("ab=42")) + #expect(!url.contains("av=")) + #expect(!url.contains("ab=")) let decoded = try CmxPairingQRCode().decode(try components(url)) #expect(decoded.macUserEmail == nil) #expect(decoded.macUserID == "user_mac_123") #expect(decoded.macPairingCompatibilityVersion == 1) + #expect(decoded.macAppVersion == nil) + #expect(decoded.macAppBuild == nil) + #expect(decoded.routes == ticket.routes) + } + + @Test func decodeStillReadsBuildMetadataFromOlderMacsCodes() throws { + // Macs that predate the av/ab removal still stamp both fields; the + // decoder keeps reading them so the cross-version warning can name + // the older Mac's version. + let url = "cmux-ios://attach?v=2&ub=user_mac_123&pc=1&av=0.64.15&ab=42&r=100.64.0.5:58465" + let decoded = try CmxPairingQRCode().decode(try components(url)) + #expect(decoded.macUserID == "user_mac_123") + #expect(decoded.macPairingCompatibilityVersion == 1) #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") - #expect(decoded.routes == ticket.routes) } @Test func roundTripsIPv6LiteralThroughRealURLParsing() throws { diff --git a/Sources/Mobile/MobileAttachTarget.swift b/Sources/Mobile/MobileAttachTarget.swift index 072dfcab78ed..10d1518fe510 100644 --- a/Sources/Mobile/MobileAttachTarget.swift +++ b/Sources/Mobile/MobileAttachTarget.swift @@ -32,26 +32,38 @@ enum MobileAttachTarget: String, Sendable { selected = irohRoutes break } - let physicalRoutes = routes.filter { - $0.kind == .tailscale && !CmxLoopbackHost().matches($0) - } - // A route-id filter can leave `tailscale_2` as the only route. - // Reindex the selected endpoints to the canonical sequence the v2 - // QR decoder reconstructs, keeping the destination lossless while - // avoiding a token-bearing v1 fallback on physical devices. - selected = try physicalRoutes.enumerated().map { index, route in + selected = try Self.canonicalTailscaleRoutes(from: routes) + } + guard !selected.isEmpty else { + throw MobileAttachTicketStoreError.routeUnavailable + } + return selected + } + + /// The non-loopback Tailscale routes of `routes`, reindexed to the + /// canonical id/priority sequence the v2 pairing decoder resynthesizes. + /// + /// A route-id filter can leave `tailscale_2` as the only route, and mixed + /// snapshots interleave Iroh and loopback entries. Reindexing keeps the + /// disclosed subsequence expressible in the bare `host:port` grammar + /// (which encodes neither ids nor priorities) without a token-bearing v1 + /// fallback. Shared by the physical-device destination and the pairing + /// window's Tailscale compatibility code. + static func canonicalTailscaleRoutes( + from routes: [CmxAttachRoute] + ) throws -> [CmxAttachRoute] { + try routes + .filter { $0.kind == .tailscale && !CmxLoopbackHost().matches($0) } + .enumerated().map { index, route in try CmxAttachRoute( - id: index == 0 ? "tailscale" : "tailscale_\(index + 1)", + id: index == 0 + ? CmxAttachTransportKind.tailscale.rawValue + : "\(CmxAttachTransportKind.tailscale.rawValue)_\(index + 1)", kind: .tailscale, endpoint: route.endpoint, priority: 10 + index * 10 ) } - } - guard !selected.isEmpty else { - throw MobileAttachTicketStoreError.routeUnavailable - } - return selected } private static func identityOnlyIrohRoutes( diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index 9848760de4d8..2e9a57c37c81 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -161,19 +161,20 @@ final class MobileAttachTicketStore { routeDisclosureMode: CmxPairingRouteDisclosureMode, pairingURLScheme: CmxPairingURLScheme? ) throws -> URL { - // Frozen iOS builds predate either the compact short-key v1 payload or - // the bare-route v2 grammar. Give those clients the original full-key - // v1 ticket, restricted to Tailscale and stripped of its attach token. - // The Mac pairing window requests this mode. Explicit Iroh attach - // targets still use the EndpointID-only representation below. + // Compatibility disclosure filters mixed snapshots down to the + // canonical Tailscale subsequence and emits the plain v2 grammar. + // The Mac pairing window requests this mode. The full-key v1 JSON + // this branch used to emit base64-encoded the Mac's device id, + // display name, and build metadata into a QR several versions denser + // (23 vs 8 at ECC M) for fields the phone recovers post-handshake + // from `mobile.host.status`; fielded clients have decoded v2 since + // #5872. Explicit Iroh attach targets still use the EndpointID-only + // representation below. if routeDisclosureMode == .legacyPrivateNetworkCompatibility, - ticket.routes.contains(where: { $0.kind == .tailscale }) { - guard let url = try CmxLegacyPrivateNetworkPairingCode().encode( - ticket, - pairingURLScheme: pairingURLScheme - ) else { - throw MobileAttachTicketStoreError.invalidAttachURL - } + let url = tailscaleCompatibilityAttachURL( + for: ticket, + pairingURLScheme: pairingURLScheme + ) { return url } @@ -291,6 +292,50 @@ final class MobileAttachTicketStore { return url } + /// The minimal v2 Tailscale pairing URL for `ticket`, or `nil` when it + /// carries no dialable Tailscale route or the v2 grammar cannot express + /// it (workspace scope, a host needing escaping); callers fall back to + /// the compact v1 payload so every ticket still has an attach URL. + /// + /// The disclosed sub-ticket keeps only the account binding (`ub`) and + /// compatibility level (`pc`) the phone consults before dialing, drops + /// the attach token, and reindexes the Tailscale routes to the canonical + /// sequence the decoder resynthesizes, so mixed Iroh/loopback snapshots + /// stay expressible. + private func tailscaleCompatibilityAttachURL( + for ticket: CmxAttachTicket, + pairingURLScheme: CmxPairingURLScheme? + ) -> URL? { + guard let routes = try? MobileAttachTarget.canonicalTailscaleRoutes( + from: ticket.routes + ), !routes.isEmpty else { + return nil + } + guard let compatibilityTicket = 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: routes, + expiresAt: ticket.expiresAt, + authToken: nil + ), + let pairingURL = CmxPairingQRCode().encode( + compatibilityTicket, + routeDisclosureMode: .legacyPrivateNetworkCompatibility, + pairingURLScheme: pairingURLScheme + ) else { + return nil + } + return URL(string: pairingURL) + } + private static func hasOnlyIdentityOnlyIrohRoutes(_ routes: [CmxAttachRoute]) -> Bool { !routes.isEmpty && routes.allSatisfy { route in guard route.kind == .iroh, diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index 4de030a7bf56..c8cd8265d6f5 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -117,39 +117,19 @@ extension MobileHostAuthorizationTests { let attachURL = try #require(payload["attach_url"] as? String) let decoded = try CmxAttachTicketInput.decode(attachURL) - #expect(!CmxPairingQRCode().isPairingCodeURLString(attachURL)) + // The compatibility code is the plain v2 grammar: the Iroh route is + // dropped (it rides the primary v3 code), the Tailscale route + // survives, and nothing token- or identity-shaped is encoded beyond + // the opaque `ub` account binding. + #expect(CmxPairingQRCode().isPairingCodeURLString(attachURL)) #expect(decoded.routes == [tailscale]) #expect(decoded.authToken == nil) - let sourceExpiry = try #require(ticket.expiresAt) - let legacyExpiry = try #require(decoded.expiresAt) - #expect(legacyExpiry > sourceExpiry.addingTimeInterval(365 * 24 * 60 * 60)) + #expect(decoded.expiresAt == nil) + #expect(decoded.macUserEmail == nil) + #expect(decoded.macUserID == "opaque-user-id") + #expect(!attachURL.contains("payload=")) #expect(!attachURL.contains(String(repeating: "a", count: 64))) - - let components = try #require(URLComponents(string: attachURL)) - let encoded = try #require( - components.queryItems?.first(where: { $0.name == "payload" })?.value - ) - let legacyData = try #require(Self.decodeBase64URL(encoded)) - let legacyObject = try #require( - JSONSerialization.jsonObject(with: legacyData) as? [String: Any] - ) - #expect(legacyObject["version"] as? Int == CmxAttachTicket.currentVersion) - #expect(legacyObject["expiresAt"] != nil) - #expect(legacyObject["auth_token"] == nil) - #expect(legacyObject["macUserEmail"] == nil) - #expect(legacyObject["macUserID"] as? String == "opaque-user-id") - #expect((legacyObject["routes"] as? [[String: Any]])?.count == 1) - } - - private static func decodeBase64URL(_ value: String) -> Data? { - var base64 = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = base64.count % 4 - if padding > 0 { - base64.append(String(repeating: "=", count: 4 - padding)) - } - return Data(base64Encoded: base64) + #expect(!attachURL.contains("private@example.com")) } @Test func testBindingPublicationDoesNotWaitForPersistence() async { diff --git a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift index fb0d03c6ecdb..44d1b6ba067e 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CoreGraphics import Foundation import Testing #if canImport(cmux_DEV) @@ -232,6 +233,69 @@ struct MobileHostWorkspaceTicketAuthorizationTests { #expect(try compactTicket(from: attachURL).routes == ticket.routes) } + @Test func omittedTargetTailscaleCompatibilityCodeIsMinimalV2() throws { + let store = MobileAttachTicketStore() + let secondaryTailscale = try tailscaleRoute( + id: "tailscale_2", + host: "100.64.0.6", + priority: 20 + ) + let ticket = try store.createTicket( + workspaceID: "", + terminalID: nil, + routes: [ + try loopbackRoute(), + try tailscaleRoute(), + secondaryTailscale, + try irohRoute(), + ], + ttl: 3600, + macUserEmail: "Owner@Example.com", + macUserID: "user_mac_123", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, + macAppVersion: "0.65.0", + macAppBuild: "42" + ) + + let payload = try store.payload(for: ticket) + let attachURL = try #require(payload["attach_url"] as? String) + + // The pairing window's Tailscale code speaks the plain v2 grammar: + // routes plus the account binding (`ub`, the wrong-account fast-fail) + // and the compatibility level (`pc`, which fielded decoders default + // to 0 when absent, spuriously firing the cross-version warning). + // Never base64 JSON carrying device id, display name, or build + // metadata: those arrive post-handshake from `mobile.host.status`. + #expect(CmxPairingQRCode().isPairingCodeURLString(attachURL)) + #expect(!attachURL.contains("payload=")) + #expect(!attachURL.contains("av=")) + #expect(!attachURL.contains("ab=")) + #expect(!attachURL.lowercased().contains("owner@example.com")) + #expect(!attachURL.contains("relay.should-not-leak.example")) + #expect(!attachURL.contains(try #require(ticket.authToken))) + + let components = try #require(URLComponents(string: attachURL)) + let decoded = try CmxPairingQRCode().decode(components) + #expect(decoded.routes == [try tailscaleRoute(), secondaryTailscale]) + #expect(decoded.macUserID == "user_mac_123") + #expect( + decoded.macPairingCompatibilityVersion + == CmxMobileDefaults.pairingCompatibilityVersion + ) + #expect(decoded.macAppVersion == nil) + #expect(decoded.macAppBuild == nil) + #expect(decoded.macDisplayName == nil) + #expect(decoded.macDeviceID == "") + + // Scannability: the account-bound two-route code stays at or below + // QR version 8 (49x49 modules) at the renderer's ECC M, so modules + // render large on a glossy screen. The full-key JSON payload this + // replaced rendered version 23 (109x109 modules). + let image = try #require(CmxPairingQRBitmap().makeImage(payload: attachURL)) + let modules = image.width - CmxPairingQRBitmap.quietZoneModules * 2 + #expect(modules <= 49, "pairing QR too dense: \(modules)x\(modules) modules") + } + #if DEBUG @Test func omittedTargetRPCPreservesLegacyAttachURL() async throws { let previousManager = TerminalController.shared.activeTabManagerForCallerNotification()