Run cmux iOS over authenticated Iroh transport (#7908)

* feat(iroh): bridge the production Swift endpoint

* feat(iroh): run the mac host transport

* test(iroh): reject local binding substitution

* test(ios): cover pooled fallback route

* fix(iroh): bind discovery to the local app instance

* fix(ios): retain successful pooled route

* test(iroh): require online-first host policy fallback

* feat(iroh): start hosts from verified offline policy

* feat(iroh): persist mac offline host policy

* fix(iroh): reject partial online binding rotation

* test(iroh): reject unvalidated private fallback

* feat(iroh): own iOS endpoint and multistream sessions

* fix(iroh): revalidate private fallback paths

* fix(iroh): accept JSON media type parameters

* ci(iroh): test full app on Intel Sonoma

* ci(iroh): run transport tests on Intel Sonoma

* test(iroh): cover abandoned relay reservations

* fix(iroh): expire abandoned relay reservations

* feat(iroh): expose admitted host multistream sessions

* test(iroh): require bounded incoming streams

* fix(iroh): bound peer-created QUIC streams

* feat(iroh): defer iOS transport activation

* feat(ios): prefer verified Iroh routes

* test(iroh): preserve endpoint on preferred port collision

* test(iroh): cover LAN rendezvous consistency

* fix(iroh): fall back from occupied preferred port

* feat(iroh): derive private rotating LAN aliases

* fix(iroh): serialize LAN discovery with revocation

* feat(iroh): make secure pairing the default

* docs(iroh): record offline and LAN trust boundaries

* feat(iroh): cache verified client policy offline

* docs(iroh): add Apple and proxy launch caveats

* docs(iroh): clarify Apple local network prompting

* test(iroh): cover offline cache teardown races

* fix(iroh): fence offline cache teardown

* test(iroh): cover online admission leases

* feat(iroh): gate online admission leases

* feat(iroh): add authenticated Bonjour LAN fallback

* feat(iroh): enforce online revocation leases

* test(iroh): cover offline admission leases

* test(iroh): cover canonical trust errors

* fix(iroh): harden trust broker boundaries

* fix(iroh): bound offline admission leases

* test(iroh): cover policy refresh revision races

* fix(iroh): fence admission policy refreshes

* docs(iroh): narrow private network release scope

* test(tailscale): reject unbound bearer routes

* fix(mobile): state private network boundaries

* test(tailscale): reject unbound bearer routes

Cover numeric-only Tailscale bearer routes and reject authorization, DNS, and route substitution before transport writes.

* docs(iroh): specify NAT authorization barrier

* fix(tailscale): bind bearer writes to live tunnel

* test(iroh): require acknowledged NAT admission barrier

* test(tailscale): reject route-only transport bypass

* fix(tailscale): close route-only transport bypass

* fix(iroh): acknowledge NAT admission before app streams

* test(iroh): hide database failure details

* fix(iroh): defer reservation constraint validation

* test(iroh): retain revocation monitor after handoff

* fix(iroh): retain revocation monitor for connection

* test(iroh): reject broker credential redirects

* fix(iroh): block broker credential redirects

* test(iroh): fail closed on terminal foreground policy

* fix(iroh): fail closed on terminal policy refresh

* test(iroh): prevent raw fallback after admission failure

* fix(iroh): pin authenticated pairings to Iroh

* test(auth): reject device registry redirects

* test(iroh): lock registration identity and relay bootstrap

* fix(iroh): preserve registration trust identity

* fix(auth): reject credentialed API redirects

* test(iroh): keep direct paths out of cloud storage

* fix(iroh): keep direct paths device local

* test(iroh): evict remotely closed client sessions

* test(iroh): recover dead session on foreground

* test(iroh): prevent server path-hint disclosure

* fix(iroh): recover suspended client sessions

* fix(iroh): keep private paths off server surfaces

* test(iroh): reject overlapping LAN bootstrap routes

* fix(iroh): reject ambiguous LAN interfaces

* test(iroh): bound pending admissions per identity

* fix(iroh): limit pending admissions per peer

* test(iroh): require owned server event stream

* test(auth): bound credentialed HTTP responses

* test(iroh): reject concurrent control owners

* fix(auth): cap credentialed HTTP responses

* test(iroh): cover firewall dependency failures

* fix(iroh): bound firewall availability checks

* feat(iroh): deliver server events on owned stream

* test(iroh): cap stalled firewall work

* test(iroh): bound active sessions per binding

* fix(iroh): cap stalled firewall work

* fix(iroh): cap active sessions per binding

* test(iroh): require firewall timeout recovery

* fix(iroh): abort stalled firewall checks

* fix(ci): isolate Iroh transport test suites

* test(iroh): route revocation to broker delete

* fix(iroh): send revocation to broker route

* test(mobile): bound concurrent RPC work

* fix(mobile): cap concurrent RPC work

* test(mobile): bound decoded frame batches

* fix(mobile): cap decoded frame batches

* test(iroh): bound pending Bonjour resolves

* test(iroh): gate reserved application lanes

* test(iroh): retain failed binding revocations

* fix(iroh): bound pending Bonjour resolves

* fix(iroh): gate reserved application lanes

* docs(iroh): narrow production multistream claims

* build(iroh): pin attested Swift fork release

* test(iroh): require retry-safe binding revocation

* fix(iroh): make binding revocation retry-safe

* fix(iroh): durably retry binding revocations

* build(iroh): lock iOS Swift fork release

* test(iroh): retain Bonjour observation lifetime

* test(auth): prepare before raced sign-out clear

* test(iroh): quarantine failed sign-out persistence

* test(ios): quarantine failed Iroh sign-out

* fix(iroh): quarantine incomplete sign-out teardown

* fix(iroh): clear host network state in quarantine

* fix(auth): quarantine Iroh before sign-out clear

* fix(ios): quarantine incomplete Iroh sign-out

* fix(mobile): type Iroh binding snapshot

* fix(ios): wait for auth clear before Iroh recovery

* build(iroh): lock app Swift fork release

* fix(iroh): persist secrets in ad-hoc debug builds

* test(iroh): require local-only HTTP minter opt-in

* feat(iroh): add loopback relay minter runner

* test(tailscale): require numeric registry targets

* feat(iroh): gate local relay minter HTTP

* fix(iroh): normalize local minter opt-in

* test(ios): require tagged API origin bake

* fix(ios): bake tagged API origin

* fix(tailscale): pin MagicDNS remotes to peer IPs

* fix(tailscale): reject inactive peer snapshots

* build(iroh): pin hardened FFI release

* test(auth): preserve auto-login during token reads

* test(auth): preserve manual sign-in during token reads

* test(iroh): require startup network event delivery

* fix(iroh): establish endpoint observation before activation

* fix(auth): preserve active session writers

* test(iroh): accept existing binding registration responses

* fix(iroh): accept existing binding relay status

* test(iroh): keep host active after refresh throttling

* fix(iroh): preserve host during broker throttling

* test(iroh): preserve client during broker throttling

* fix(iroh): retain verified policy during broker outages

* fix(iroh): decode broker dates on older macOS

* test(iroh): reject synthetic network change floods

* fix(iroh): observe address changes without feedback loop

* test(iroh): accept canonical UUID identity case

* fix(iroh): canonicalize pinned device UUIDs

* fix(iroh): harden compatibility and private routes

* refactor(iroh): split runtime ownership boundaries

* test(iroh): repair authorization suite split boundaries

* test(iroh): link mobile RPC authorization tests

* test(iroh): support compatibility compilers

* test(iroh): cover uppercase UUID fallback paths

* fix(iroh): canonicalize device UUID authority

* test(iroh): support Intel Sonoma compiler

* test(iroh): avoid non-Sendable fixture captures

* fix(updater): handle Intel-only Sparkle reason

* test(iroh): cover bearer and discovery overload

* fix(iroh): close route and discovery gaps

* fix(ci): close Iroh compatibility regressions

* feat(iroh): integrate endpoint-bound relay fleet

* fix(ci): wrap command timers for Intel Swift

* fix(ios): expose relay deployment to Sendable factory

* test(ci): cover private networking on Intel Sonoma

* test(ci): support Intel Swift Testing macros

* test(iroh): expose relay refresh expiry gap

* fix(iroh): retry relay refresh before expiry

* fix(ios): serialize Iroh quarantine recovery

* refactor(auth): isolate lifecycle revision API

* fix(ci): eliminate Iroh Swift 6 warnings

* fix(ci): support Intel Xcode 16.2

* fix(ci): mark canvas clock sleep sendable

* fix(ci): bridge canvas preferences to main actor

* fix(ci): support sidebar git on Xcode 16.2

* fix(ci): mark RPC termination handler sendable

* fix(ci): support CLI on Xcode 16.2

* fix(ci): support app target on Xcode 16.2

* fix(ci): finish Xcode 16.2 source compatibility

* iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs

Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both
allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS,
kept in lockstep) and the tests that referenced hosted URLs. The self-hosted
relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime
upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879)
mints.

* fix(ci): support trailing closure on Xcode 16.2

* ci: allow Intel compatibility suite to finish

* test(ci): avoid Xcode 16.2 require recursion

* ci: focus Intel compatibility coverage

* fix(ci): stabilize replay ownership and Intel budget

* test(ios): isolate authoritative resync coverage

* feat(iroh): add secure flexible relay policy

* test(iroh): split relay runtime coverage

* test(iroh): allow self-hosted broker without legacy minter

* fix(iroh): make hosted relay minter optional

* test(iroh): cover public firewall host fallback

* fix(iroh): use public host for firewall checks

* fix(iroh): keep accepts and sign-out responsive

* test(iroh): reproduce lost online reachability

* fix(iroh): republish endpoint online routes

* test(iroh): reproduce coalesced route refresh

* fix(iroh): replay coalesced route refreshes

* refactor(iroh): split oversized runtime files

* test(iroh): cover lifecycle refresh races

* fix(iroh): fence lifecycle refresh work

* test(ios): reproduce loopback dev auto-pair race

* test(ios): cover redacted dev Iroh attach URLs

* fix(ios): wait for redacted Iroh dev attach ticket

* test(ios): reproduce Iroh cold-start attach race

* fix(ios): await Iroh before dev auto-pair

* feat(iroh): add server-driven relay preferences

* feat(iroh): complete relay controls and multistream runtime

* ci: rehearse staging migrations from dispatched branch

* Make managed Iroh credentials server-driven

* feat(iroh): expose redacted live path diagnostics

* security(iroh): stage relay policy key rotation

* fix(iroh): use instance-scoped host display name

* test(iroh): require dev attach targets to prefer identity routes

* fix(iroh): prefer identity routes for dev attach

* fix(web): include shared relay catalog in Next root

* test(web): keep relay catalog inside Next boundary

* fix(web): generate relay catalog inside runtime boundaries

* test(mobile): cover transport lifetime ownership

* fix(mobile): retain Iroh transport lifetime

* test(iroh): cover relay policy clock skew

* fix(iroh): tolerate bounded relay policy clock skew

* test(iroh): cover admitted session lifetime

* fix(iroh): separate admission and session lifetimes

* test(iroh): cover relay and route renewal stalls

* fix(iroh): keep relay routes renewed through storage stalls

* test(iroh): cover nonblocking binding persistence

* fix(iroh): publish bindings before secure persistence

* test(iroh): cover strict transport verification modes

* feat(iroh): add strict transport verification modes

* test(iroh): await nonblocking relay persistence

* test(iroh): cover live peer connection quotas

* fix(iroh): bound live sessions per endpoint

* test(iroh): cover broker-aware route renewal backoff

* fix(iroh): back off broker route renewal retries

* test(mobile): cover superseded Iroh transport cleanup

* fix(iroh): close unowned mobile sessions

* test(iroh): reproduce stale reconnect sessions

* fix(iroh): replace stale peer sessions on admission

* feat(iroh): add debug transport mode menu

* Add regression coverage for Iroh merge blockers

* Fix Iroh relay and reconnect merge blockers

---------

Co-authored-by: lawrencecchen <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Aziz Albahar <[email protected]>
This commit is contained in:
Abdulaziz Albahar
2026-07-14 20:17:42 -05:00
committed by GitHub
co-authored by lawrencecchen cmux reload-cloud Aziz Albahar
parent 8ae4711352
commit 3822f1dd47
672 changed files with 78036 additions and 3679 deletions
+62 -68
View File
@@ -1,53 +1,53 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
35469 CLI/cmux.swift
17804 Sources/AppDelegate.swift
16252 Sources/ContentView.swift
14931 Sources/TerminalController.swift
12860 Sources/Workspace.swift
12501 Sources/GhosttyTerminalView.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
11388 Sources/Panels/BrowserPanel.swift
35405 CLI/cmux.swift
17782 Sources/AppDelegate.swift
15818 Sources/ContentView.swift
14701 Sources/TerminalController.swift
12829 Sources/Workspace.swift
12554 Sources/GhosttyTerminalView.swift
12262 cmuxTests/AppDelegateShortcutRoutingTests.swift
11332 Sources/Panels/BrowserPanel.swift
9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8016 CLI/cmux_open.swift
7959 Sources/Panels/BrowserPanelView.swift
7736 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7968 CLI/cmux_open.swift
7756 Sources/Panels/BrowserPanelView.swift
7489 cmuxTests/WorkspaceUnitTests.swift
7473 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7310 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
6255 cmuxTests/GhosttyConfigTests.swift
6199 Sources/TabManager.swift
5915 cmuxTests/TerminalAndGhosttyTests.swift
5809 Sources/TextBoxInput.swift
5572 cmuxTests/BrowserConfigTests.swift
4759 Sources/cmuxApp.swift
6188 Sources/TabManager.swift
5857 cmuxTests/TerminalAndGhosttyTests.swift
5782 Sources/TextBoxInput.swift
5571 cmuxTests/BrowserConfigTests.swift
4735 Sources/cmuxApp.swift
4482 Sources/Panels/FilePreviewPanel.swift
4367 cmuxTests/BrowserPanelTests.swift
4007 cmuxTests/TabManagerUnitTests.swift
3981 Sources/BrowserWindowPortal.swift
4196 cmuxTests/BrowserPanelTests.swift
4004 cmuxTests/TabManagerUnitTests.swift
3965 Sources/BrowserWindowPortal.swift
3953 cmuxTests/WindowAndDragTests.swift
3934 Sources/Feed/FeedPanelView.swift
3827 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3779 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3673 cmuxTests/TabManagerSessionSnapshotTests.swift
3668 cmuxTests/CLIGenericHookPersistenceTests.swift
3314 Sources/CmuxConfig.swift
2882 Sources/Update/UpdateTitlebarAccessory.swift
2875 Sources/SessionIndexView.swift
2874 cmuxTests/CMUXOpenCommandTests.swift
2580 Sources/KeyboardShortcutSettings.swift
2562 Sources/Panels/CmuxWebView.swift
2561 Sources/Panels/CmuxWebView.swift
2558 Sources/KeyboardShortcutSettings.swift
2546 cmuxTests/WorkspaceManualUnreadTests.swift
2524 cmuxTests/CommandPaletteSearchEngineTests.swift
2398 Sources/Mobile/MobileHostService.swift
2328 cmuxTests/CJKIMEInputTests.swift
2257 Sources/Mobile/MobileHostService.swift
2229 Sources/TerminalWindowPortal.swift
2216 Sources/TerminalNotificationStore.swift
2203 Sources/RestorableAgentSession.swift
2186 Sources/RestorableAgentSession.swift
2133 cmuxTests/ShortcutAndCommandPaletteTests.swift
2126 cmuxTests/CmuxConfigTests.swift
2014 Sources/SessionPersistence.swift
1989 Sources/KeyboardShortcutSettingsFileStore.swift
1999 Sources/SessionPersistence.swift
1983 Sources/KeyboardShortcutSettingsFileStore.swift
1900 cmuxTests/NotificationAndMenuBarTests.swift
1866 Sources/Panels/BrowserWebAuthnSupport.swift
1847 cmuxTests/TerminalControllerSocketSecurityTests.swift
@@ -58,12 +58,11 @@
1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
1656 Sources/FileExplorerView.swift
1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift
1604 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1597 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1560 cmuxTests/TextBoxMentionCompletionTests.swift
1523 cmuxTests/RestorableAgentSessionIndexTests.swift
1499 cmuxTests/OmnibarAndToolsTests.swift
1498 cmuxUITests/MultiWindowNotificationsUITests.swift
1433 Sources/RemoteTmuxControlConnection.swift
1428 cmuxTests/AgentSessionAutoResumeSwiftTests.swift
1420 cmuxTests/AppDelegateIssue2907RoutingTests.swift
1384 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
@@ -71,33 +70,29 @@
1363 Sources/CMUXInstalledExtensionSidebarHostView.swift
1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift
1317 Sources/FileExplorerStore.swift
1295 cmuxTests/MobileHostAuthorizationTests.swift
1290 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
1290 cmuxTests/TextBoxSubmitActionTests.swift
1285 cmuxUITests/SidebarHelpMenuUITests.swift
1270 cmuxTests/MobileHostAuthorizationTests.swift
1258 Sources/Feed/FeedCoordinator.swift
1240 cmuxTests/SidebarOrderingTests.swift
1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift
1205 Sources/RemoteTmuxController.swift
1204 cmuxTests/FileExplorerStoreTests.swift
1197 cmuxTests/CodexAppServerSessionTests.swift
1197 cmuxTests/VMDefaultCloudCommandTests.swift
1166 Sources/VaultAgentProcessScanner.swift
1147 cmuxTests/PiVaultAgentPersistenceTests.swift
1117 cmuxTests/AgentHibernationTests.swift
1093 cmuxUITests/BonsplitTabDragUITests.swift
1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift
1049 cmuxTests/WorkspaceGroupTests.swift
1038 Sources/AppDelegate+CmuxSSHURL.swift
1033 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift
1030 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift
1021 cmuxUITests/TerminalCmdClickUITests.swift
1009 cmuxTests/CmuxTopSnapshotScopeTests.swift
1006 cmuxTests/CmuxSSHURLRequestTests.swift
1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift
1000 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift
999 cmuxTests/DockSocketLifecycleTests.swift
982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
974 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
951 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
951 Sources/App/TerminalDirectoryOpenSupport.swift
948 Sources/App/ShortcutRoutingSupport.swift
947 Sources/TerminalNotificationPolicy.swift
@@ -108,36 +103,41 @@
929 Sources/Panels/TerminalPanel.swift
918 Sources/Panels/BrowserPopupWindowController.swift
905 Sources/CmuxSSHURLRequest.swift
904 Sources/VaultAgentProcessScanner.swift
899 Sources/Panels/MarkdownWebRenderer.swift
896 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
895 Sources/RemoteTmuxControlConnection.swift
885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift
882 Sources/DockSplitStore.swift
878 Sources/PortScanner.swift
871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift
868 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
865 Sources/DockSplitStore.swift
864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
858 Sources/PricingPlansScreen.swift
856 Sources/TextBoxMentionIndexStore.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
844 cmuxTests/SSHStartupSignalLifecycleTests.swift
837 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
834 Sources/MainWindowFocusController.swift
825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
822 Sources/WorkspaceContentView.swift
812 Sources/TerminalController+ControlPaneContext.swift
810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift
803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift
801 Sources/ClosedItemHistory.swift
798 cmuxTests/CmuxEventBusTests.swift
795 Sources/RemoteTmuxController.swift
786 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
773 Sources/App/MenuBarExtraController.swift
769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
768 cmuxUITests/BrowserFixtureInteractionUITests.swift
762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
766 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift
753 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
747 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
736 Sources/TerminalController+ControlWorkspaceContext.swift
724 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
722 Sources/TaskManagerTypes.swift
716 Sources/TaskManagerSnapshot.swift
714 Sources/AppleScriptSupport.swift
@@ -147,7 +147,6 @@
706 CLI/CMUXCLI+Config.swift
699 cmuxTests/TerminalNotificationClearAllTests.swift
698 cmuxTests/RestorableAgentHookProviderResumeTests.swift
696 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
696 cmuxTests/KeyboardShortcutContextTests.swift
691 Sources/NotificationSoundSettings.swift
691 cmuxTests/TaskManagerResourcesTests.swift
@@ -160,36 +159,35 @@
672 cmuxTests/SessionPersistenceResumeBindingTests.swift
668 cmuxTests/FeedCoordinatorTests.swift
665 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
664 Sources/CmuxTopSnapshot.swift
663 Sources/PortScanner.swift
661 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift
655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
663 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift
660 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
658 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
657 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift
657 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
654 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
654 Sources/CmuxTopSnapshot.swift
652 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift
650 Sources/Panels/MarkdownRemoteImageLoader.swift
649 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift
648 cmuxTests/TerminalNotificationQueueTests.swift
644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift
642 cmuxTests/RemoteTmuxControlParserTests.swift
641 cmuxTests/CommandPaletteNucleoFFITests.swift
638 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
637 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
635 Sources/Panels/BrowserNavigationDelegate.swift
635 cmuxUITests/RightSidebarChromeHeightUITests.swift
633 Sources/SettingsNavigation.swift
630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift
626 Sources/SettingsNavigation.swift
627 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift
620 cmuxTests/FinderFileDropRegressionTests.swift
610 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
619 Sources/CmuxEventBus.swift
608 Sources/SleepyFaceView.swift
608 Sources/TextBoxSubmitActions.swift
608 cmuxUITests/FeedSidebarUITests.swift
607 Sources/SessionIndexModels.swift
604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift
601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift
601 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift
596 cmuxTests/CmuxEventBusTests.swift
594 cmuxTests/PortalTabDragRoutingTests.swift
591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift
591 cmuxTests/CmuxConfigContextMenuTests.swift
@@ -198,6 +196,7 @@
586 Sources/JSONCParser.swift
585 cmuxTests/SettingsWindowPresenterTests.swift
583 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
581 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift
580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift
580 cmuxTests/CLIHookNoResponseTests.swift
579 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift
@@ -212,6 +211,7 @@
562 cmuxTests/AgentExecutableResolverTests.swift
561 cmuxTests/GhosttyConfigPathResolverTests.swift
560 cmuxTests/CLISSHPTYResizeInputTests.swift
560 cmuxTests/RemoteTmuxControlParserTests.swift
559 CLI/CMUXCLI+AgentHookDefinitions.swift
558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift
553 Sources/RightSidebarPanelView.swift
@@ -226,38 +226,32 @@
539 CLI/CodexTeamsApprovalBridge.swift
538 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
538 Sources/TerminalController+ControlSurfaceContext2.swift
535 Sources/App/WorkspaceRuntimeSettings.swift
533 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift
537 Sources/App/WorkspaceRuntimeSettings.swift
527 CLI/CLISocketPathResolver.swift
527 cmuxTests/BrowserHTTPBasicAuthPromptTests.swift
526 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift
526 Sources/BrowserPaneDropTargetView.swift
525 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift
524 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift
522 Sources/TerminalPaneDropTargetView.swift
520 CLI/CMUXCLI+AmpExtension.swift
520 cmuxTests/MainWindowVisibilityControllerTests.swift
519 CLI/CMUXCLI+AutoNaming.swift
519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
516 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
516 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
516 Sources/TerminalImageTransfer.swift
514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
514 cmuxUITests/UpdatePillUITests.swift
511 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarReportsV1.swift
511 Sources/AppDelegate+AgentChat.swift
510 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
510 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift
509 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift
508 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
507 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
507 Sources/TerminalControllerTopSupport.swift
506 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
506 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/PTYBridge/RemotePTYBridgeSession.swift
506 Sources/App/MainWindowVisibilityController.swift
504 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift
504 cmuxTests/TerminalNotificationSocketActionTests.swift
503 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
503 Sources/Settings/ConfigSource.swift
503 Sources/TerminalNotificationQueue.swift
502 Sources/CmuxEventPublishing.swift
501 Sources/TerminalNotificationQueue.swift
500 Sources/KeyboardShortcutRecorder.swift
1 # cmux-owned Swift file length budget.
2 # Format: max_lines<TAB>relative path
3 # Reduce counts as files shrink. CI fails if tracked files exceed this budget.
4 35469 35405
5 17804 17782
6 16252 15818
7 14931 14701
8 12860 12829
9 12501 12554
10 12348 12262
11 11388 11332
12 9497
13 8016 7968
14 7959 7756
7736
15 7489
16 7473
17 7310
18 6359
19 6255
20 6199 6188
21 5915 5857
22 5809 5782
23 5572 5571
24 4759 4735
25 4482
26 4367 4196
27 4007 4004
28 3981 3965
29 3953
30 3934
31 3827 3779
32 3673
33 3668
34 3314
35 2882
36 2875
37 2874
38 2580 2561
39 2562 2558
40 2546
41 2524
2398
42 2328
43 2257
44 2229
45 2216
46 2203 2186
47 2133
48 2126
49 2014 1999
50 1989 1983
51 1900
52 1866
53 1847
58 1680
59 1656
60 1652
61 1604 1597
62 1560
63 1523
64 1499
65 1498
1433
66 1428
67 1420
68 1384
70 1363
71 1360
72 1317
1295
73 1290
74 1290
75 1285
76 1270
77 1258
78 1240
79 1209
1205
80 1204
81 1197
82 1197
1166
83 1147
84 1117
85 1093
86 1087
87 1049
88 1038
89 1033 1030
90 1021
91 1009
92 1006
93 1002 1000
94 999
95 982
974
951
96 951
97 948
98 947
103 929
104 918
105 905
106 904
107 899
108 896
109 895
110 885
111 882 878
112 871
113 868
114 868
115 865
116 864
117 858
118 856
119 847
120 845 844
121 841 837
122 834
123 825
124 822
125 812
126 810
803
127 801
128 798
129 795
130 786
131 779
132 773
769
133 768
134 762 766
135 756
136 754
753
137 752
138 747
139 738
140 736
724
141 722
142 716
143 714
147 706
148 699
149 698
696
150 696
151 691
152 691
159 672
160 668
161 665
162 664 663
163 663 660
164 661 658
165 660 657
166 655 657
167 654
168 654
169 652
170 650
171 650
172 649
173 648
174 644
642
175 641
176 638 637
177 635
178 635
179 633
180 630
181 626 627
182 623
183 620
184 610 619
185 608
186 608
187 608
188 607
189 604
601
190 601
598
596
191 594
192 591
193 591
196 586
197 585
198 583
199 581
200 580
201 580
202 579
211 562
212 561
213 560
214 560
215 559
216 558
217 553
226 539
227 538
228 538
229 535 537
533
230 527
231 527
232 526
526
233 525
234 524
235 522
236 520
237 520
238 519
239 519
518
240 518
241 516
242 516
243 516
244 514
245 514
246 511
247 511 510
248 510
249 509
509
508
507
250 507
506
506
251 506
252 504
253 504
503
254 503
503
255 502
256 501
257 500
+37 -2
View File
@@ -9,16 +9,30 @@ jobs:
fail-fast: false
matrix:
include:
- os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout: 30
- os: macos-14-large
timeout: 60
run_unit_tests: false
startup_smoke: true
virtual_display: true
skip_zig: false
expected_arch: x86_64
expected_os_major: "14"
- os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout: 30
run_unit_tests: true
startup_smoke: true
virtual_display: true
skip_zig: false
expected_arch: ""
expected_os_major: ""
- os: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}
timeout: 30
run_unit_tests: true
startup_smoke: true
virtual_display: false
skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26
expected_arch: ""
expected_os_major: ""
runs-on: ${{ matrix.os }}
timeout-minutes: ${{ matrix.timeout }}
steps:
@@ -28,6 +42,9 @@ jobs:
submodules: recursive
- name: Select Xcode
env:
EXPECTED_ARCH: ${{ matrix.expected_arch }}
EXPECTED_OS_MAJOR: ${{ matrix.expected_os_major }}
run: |
set -euo pipefail
# Pick the latest Xcode installed on the runner. GitHub-hosted macos-14
@@ -56,6 +73,17 @@ jobs:
echo "$XCODE_VERSION_OUTPUT"
xcrun --sdk macosx --show-sdk-path
sw_vers
if [ -n "$EXPECTED_ARCH" ] && [ "$(uname -m)" != "$EXPECTED_ARCH" ]; then
echo "Expected runner architecture $EXPECTED_ARCH, got $(uname -m)" >&2
exit 1
fi
if [ -n "$EXPECTED_OS_MAJOR" ]; then
OS_MAJOR="$(sw_vers -productVersion | cut -d. -f1)"
if [ "$OS_MAJOR" != "$EXPECTED_OS_MAJOR" ]; then
echo "Expected macOS $EXPECTED_OS_MAJOR, got $(sw_vers -productVersion)" >&2
exit 1
fi
fi
- name: Capture Ghostty revision
id: ghostty-revision
@@ -119,7 +147,14 @@ jobs:
sleep $((attempt * 5))
done
- name: Run mobile transport package tests on Intel Sonoma
if: matrix.expected_arch == 'x86_64'
run: |
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CMUXMobileCore
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CmuxIrohTransport
- name: Run unit tests
if: matrix.run_unit_tests
env:
CMUX_SKIP_ZIG_BUILD: ${{ matrix.skip_zig && '1' || '0' }}
run: |
+6 -15
View File
@@ -310,6 +310,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Validate managed relay catalog
run: bun ../scripts/generate-managed-iroh-relay-catalog.ts --check
- name: Typecheck
run: bun run typecheck
@@ -1117,6 +1120,7 @@ jobs:
CmuxRemoteSession
CmuxAgentChat
CmuxAuthRuntime
CmuxIrohTransport
CmuxCommandPalette
CmuxControlSocket
CmuxFoundation
@@ -1148,21 +1152,8 @@ jobs:
fi
echo "::group::swift test $pkgdir"
case "$pkg" in
CmuxAuthRuntime)
suite_list="$(
find "$pkgdir/Tests" -name '*.swift' -print0 \
| xargs -0 perl -ne 'print "$1\n" if /^\s*@Suite(?:\([^)]*\))?\s+struct\s+([A-Za-z0-9_]+)/' \
| sort -u
)"
if [ -z "$suite_list" ]; then
echo "::error::no Swift Testing suites found for $pkg"
exit 1
fi
while IFS= read -r suite; do
[ -n "$suite" ] || continue
echo "swift test $pkgdir --filter $suite"
swift test --package-path "$pkgdir" --filter "$suite"
done <<< "$suite_list"
CmuxAuthRuntime|CmuxIrohTransport)
./scripts/ci/run-swift-testing-suites.sh "$pkgdir"
;;
CmuxTerminal|CmuxTerminalCore)
test_status=0
+5 -2
View File
@@ -30,7 +30,10 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: refs/heads/main
# Staging must rehearse the dispatched branch before it can merge.
# Production remains pinned to reviewed main for both preflight and
# the prerequisite staging migration.
ref: ${{ inputs.target == 'production' && 'refs/heads/main' || github.sha }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -63,7 +66,7 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: refs/heads/main
ref: ${{ inputs.target == 'production' && 'refs/heads/main' || github.sha }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+48
View File
@@ -0,0 +1,48 @@
name: Iroh relay minter
on:
pull_request:
paths:
- "services/iroh-relay-minter/**"
- "tests/fixtures/iroh/relay-minter-request-v1.json"
- ".github/workflows/iroh-relay-minter.yml"
push:
branches: [main]
paths:
- "services/iroh-relay-minter/**"
- "tests/fixtures/iroh/relay-minter-request-v1.json"
- ".github/workflows/iroh-relay-minter.yml"
concurrency:
group: iroh-relay-minter-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
defaults:
run:
working-directory: services/iroh-relay-minter
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install pinned Rust toolchain
run: rustup toolchain install 1.91.0 --profile minimal --component clippy,rustfmt
- name: Check formatting
run: cargo fmt --check
- name: Lint
run: cargo clippy --all-targets --locked -- -D warnings
- name: Test
run: cargo test --locked
- name: Build production function
run: cargo build --release --locked
+7
View File
@@ -19,11 +19,15 @@ on:
pull_request:
paths:
- "workers/presence/**"
- "config/iroh/managed-relay-catalog.json"
- "scripts/generate-managed-iroh-relay-catalog.ts"
- ".github/workflows/presence.yml"
push:
branches: [main]
paths:
- "workers/presence/**"
- "config/iroh/managed-relay-catalog.json"
- "scripts/generate-managed-iroh-relay-catalog.ts"
- ".github/workflows/presence.yml"
permissions:
@@ -53,6 +57,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Validate managed relay catalog
run: bun run relay-catalog:check
- name: Typecheck
run: bun run typecheck
+4 -4
View File
@@ -42,10 +42,10 @@ extension CMUXCLI {
cmux remotes add <name> --route <host:port> [--route <host:port> ...] [--tag <tag>] [--json]
Register or update a remote with one or more attach routes. Idempotent on
<name>: re-adding the same name updates its routes. <host> must be a Tailscale
address the phone can authenticate to: a 100.64.x.x-100.127.x.x (CGNAT) IP or a
*.ts.net MagicDNS name. Plain LAN IPs, hostnames, and loopback are rejected
because they would show in the device list but fail to connect from iOS.
<name>: re-adding the same name updates its routes. <host> must be a numeric
Tailscale IPv4/IPv6 peer or a *.ts.net MagicDNS name. cmux matches MagicDNS
against the local authenticated Tailscale peer map and stores the peer's numeric
address. Plain LAN IPs, other hostnames, and loopback are rejected.
cmux remotes remove <name-or-deviceId> [--json]
Remove a remote you registered from the device registry.
+1 -1
View File
@@ -7,7 +7,7 @@ import Foundation
/// and `CMUXCLI.sshPTYAttachRetryLoopLines`; keep those shell contracts in sync
/// with this taxonomy. The classifier patterns mirror
/// `userFacingRemotePTYErrorMessage` in `CLI/CMUXCLI+RemotePTYErrors.swift`.
nonisolated enum SSHPTYAttachExitCode: Int32 {
enum SSHPTYAttachExitCode: Int32 {
case fatal = 1
case sessionNotFound = 253
case bridgeClosedSessionRunning = 254
+3 -3
View File
@@ -1,14 +1,14 @@
import Foundation
nonisolated struct CLISocketOperationTelemetry: Sendable {
nonisolated enum Phase: String, Sendable {
struct CLISocketOperationTelemetry: Sendable {
enum Phase: String, Sendable {
case writeRequest = "write_request"
case waitForResponse = "wait_for_response"
case readMultilineResponse = "read_multiline_response"
case completed
}
nonisolated struct State: Sendable {
struct State: Sendable {
let name: String
var timeout: TimeInterval
let startedAt: Date
@@ -0,0 +1,16 @@
/// The disclosure boundary applied before serializing attach routes.
public enum CmxAttachRouteDisclosure: Equatable, Sendable {
/// Same-account registry, presence, or local persistence.
case authenticated
/// Cloud rendezvous shared with other authenticated devices. Iroh identity
/// and relay bootstrap are retained; direct path and network-profile
/// metadata stay device-local.
case cloudRendezvous
/// An unauthenticated network status response.
case publicStatus
/// A scannable pairing payload.
case pairingQRCode
/// The paired-Mac server backup. Iroh uses the same relay-only boundary as
/// cloud rendezvous.
case pairedMacCloudBackup
}
@@ -36,8 +36,10 @@ import Foundation
/// value, `p` priority (omitted when 0), `e` endpoint.
/// Key map (endpoint): the type is implied by the keys present (accepted
/// explicitly under `t` for first-revision payloads): `h` host + `p` port, or
/// `i` peer id + `rh` relay hint + `da` direct addrs + `ru` relay URL, or
/// `u` url.
/// `i` peer id, or `u` url. New pairing payloads carry no Iroh path hints:
/// managed relays are app configuration, online discovery is authenticated,
/// and offline pairing resolves the scanned EndpointID locally. Decoding still
/// accepts the first compact revision's `ph`, `rh`, `da`, and `ru` fields.
public struct CmxAttachTicketCompactCoder: Sendable {
/// Creates a coder. The coder is stateless; instances are interchangeable.
public init() {}
@@ -47,11 +49,19 @@ public struct CmxAttachTicketCompactCoder: Sendable {
/// Any `authToken`, `macDisplayName`, and `expiresAt` on the ticket are
/// intentionally not encoded: the token never authorizes anything on the
/// host (Stack auth is the sole gate), the name is read post-handshake
/// from `mobile.host.status`, and a pairing QR never expires.
public func encode(_ ticket: CmxAttachTicket) throws -> Data {
/// from `mobile.host.status`, and a pairing QR never expires. Callers must
/// explicitly select identity-only disclosure or the temporary released-
/// client compatibility mode.
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(CompactAttachTicket(ticket))
return try encoder.encode(CompactAttachTicket(
ticket,
routeDisclosureMode: routeDisclosureMode
))
}
/// Decode a compact JSON payload into a validated ``CmxAttachTicket``.
@@ -0,0 +1,5 @@
/// Failures while applying a pairing-route disclosure policy.
public enum CmxAttachTicketCompactCoderError: Error, Equatable, Sendable {
/// The selected disclosure mode removed every route from the payload.
case noRoutesForDisclosureMode(CmxPairingRouteDisclosureMode)
}
@@ -0,0 +1,24 @@
/// The authorization already established before application bytes are sent.
public enum CmxTransportAuthorizationMode: Equatable, Sendable {
/// RPC requests must add a Stack bearer on an approved transport.
case stackBearer
/// The transport handshake admitted this exact peer and account binding.
case transportAdmission
}
/// Route plus peer intent required to build a transport without substitution.
public struct CmxByteTransportRequest: Equatable, Sendable {
public let route: CmxAttachRoute
public let expectedPeerDeviceID: String?
public let authorizationMode: CmxTransportAuthorizationMode
public init(
route: CmxAttachRoute,
expectedPeerDeviceID: String?,
authorizationMode: CmxTransportAuthorizationMode
) {
self.route = route
self.expectedPeerDeviceID = expectedPeerDeviceID
self.authorizationMode = authorizationMode
}
}
@@ -0,0 +1,79 @@
import Foundation
public enum CmxCredentialedHTTPSessionError: Error, Equatable, Sendable {
case responseTooLarge
}
/// A cookie-free ephemeral URL session for requests that carry account secrets.
///
/// Redirects are rejected before Foundation can reconstruct and forward a
/// request. This is required for custom credential headers because Foundation's
/// normal cross-origin redirect handling strips `Authorization` but can preserve
/// unrelated headers such as a refresh token.
public final class CmxCredentialedHTTPSession: @unchecked Sendable {
public static let defaultMaximumResponseByteCount = 4 * 1_024 * 1_024
private let redirectDelegate: CmxCredentialedHTTPRedirectDelegate
private let session: URLSession
private let maximumResponseByteCount: Int
public init(
configuration: sending URLSessionConfiguration = .ephemeral,
maximumResponseByteCount: Int = defaultMaximumResponseByteCount
) {
precondition(maximumResponseByteCount > 0)
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let redirectDelegate = CmxCredentialedHTTPRedirectDelegate()
self.redirectDelegate = redirectDelegate
self.maximumResponseByteCount = maximumResponseByteCount
session = URLSession(
configuration: configuration,
delegate: redirectDelegate,
delegateQueue: nil
)
}
public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let (bytes, response) = try await session.bytes(for: request)
if response.expectedContentLength > maximumResponseByteCount {
bytes.task.cancel()
throw CmxCredentialedHTTPSessionError.responseTooLarge
}
var data = Data()
if response.expectedContentLength > 0 {
data.reserveCapacity(
min(Int(response.expectedContentLength), maximumResponseByteCount)
)
}
for try await byte in bytes {
guard data.count < maximumResponseByteCount else {
bytes.task.cancel()
throw CmxCredentialedHTTPSessionError.responseTooLarge
}
data.append(byte)
}
return (data, response)
}
deinit {
session.invalidateAndCancel()
}
}
final class CmxCredentialedHTTPRedirectDelegate: NSObject,
URLSessionTaskDelegate,
@unchecked Sendable
{
func urlSession(
_: URLSession,
task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
@@ -0,0 +1,35 @@
/// Authentication a user can request for a custom Iroh relay.
public enum CmxIrohCustomRelayCredentialMode: String, Equatable, Sendable {
/// The relay accepts connections without an application credential.
case none
/// This device must keep a provider-issued secret in secure storage.
case deviceSecret
}
/// Editable, non-secret metadata for one custom Iroh relay.
public struct CmxIrohCustomRelayDraft: Equatable, Sendable {
/// Stable account-scoped identifier. Empty when creating a relay.
public let id: String?
public let displayName: String
public let provider: String
public let region: String
public let url: String
public let authMode: CmxIrohCustomRelayCredentialMode
public init(
id: String? = nil,
displayName: String,
provider: String,
region: String,
url: String,
authMode: CmxIrohCustomRelayCredentialMode
) {
self.id = id
self.displayName = displayName
self.provider = provider
self.region = region
self.url = url
self.authMode = authMode
}
}
@@ -0,0 +1,8 @@
/// Debug-only Iroh controls exposed by a host composition root.
@MainActor
public protocol CmxIrohDebugSettingsControlling: AnyObject {
/// Restarts the host with direct-path activation enabled or disabled.
///
/// - Parameter enabled: `true` to keep authenticated traffic on relay paths.
func setIrohDebugRelayOnly(_ enabled: Bool) async throws
}
@@ -0,0 +1,19 @@
/// The two ordered attempts for reaching an Iroh peer.
///
/// Callers must finish or cancel the public/native attempt before starting the
/// private-network fallback. The type intentionally has no flattened hint
/// list, so private routes cannot accidentally enter Iroh's first dial.
public struct CmxIrohDialPlan: Equatable, Sendable {
/// Iroh-native public direct and relay paths used for the first attempt.
public let publicPaths: [CmxIrohPathHint]
/// Active-profile private/LAN paths used only after the first attempt fails.
public let privateFallbackPaths: [CmxIrohPathHint]
init(
publicPaths: [CmxIrohPathHint],
privateFallbackPaths: [CmxIrohPathHint]
) {
self.publicPaths = publicPaths
self.privateFallbackPaths = privateFallbackPaths
}
}
@@ -0,0 +1,43 @@
/// A provider-qualified private-network profile.
///
/// The provider is part of the key so equal profile names from Tailscale, a
/// LAN observer, and a custom VPN can never authorize one another's hints.
public struct CmxIrohNetworkProfileKey: Codable, Equatable, Hashable, Sendable {
private enum CodingKeys: String, CodingKey {
case source
case profileID = "profile_id"
}
/// The provider that owns this profile namespace.
public let source: CmxIrohPathHintSource
/// An opaque account-scoped digest of the provider-local profile.
public let profileID: String
/// Creates a provider-qualified profile key.
/// - Parameters:
/// - source: The provider that owns the identifier namespace.
/// - profileID: A 32-byte account-scoped digest encoded as canonical
/// lowercase hexadecimal. Human-readable network names must be hashed
/// before constructing this value so discovery cannot disclose them.
/// - Throws: ``CmxIrohNetworkProfileKeyError/invalidProfileID`` when the
/// identifier cannot be represented safely on the wire.
public init(source: CmxIrohPathHintSource, profileID: String) throws {
guard profileID.utf8.count == 64,
profileID.utf8.allSatisfy({ byte in
(48...57).contains(byte) || (97...102).contains(byte)
}) else {
throw CmxIrohNetworkProfileKeyError.invalidProfileID
}
self.source = source
self.profileID = profileID
}
/// Decodes and validates a provider-qualified profile key.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
source: container.decode(CmxIrohPathHintSource.self, forKey: .source),
profileID: container.decode(String.self, forKey: .profileID)
)
}
}
@@ -0,0 +1,5 @@
/// Validation failures for provider-qualified network profiles.
public enum CmxIrohNetworkProfileKeyError: Error, Equatable, Sendable {
/// The identifier was not a canonical lowercase-hex 32-byte digest.
case invalidProfileID
}
@@ -0,0 +1,410 @@
import Darwin
import Foundation
private struct CmxIrohPathHintValidator {
let hint: CmxIrohPathHint
/// Returns `nil` for malformed socket syntax, otherwise whether the IP is
/// allowed as a remote Iroh peer address.
private func directSocketAddressIsAllowed(_ value: String) -> Bool? {
guard value == value.trimmingCharacters(in: .whitespacesAndNewlines),
value.utf8.count <= 80,
!value.contains("/"),
!value.contains("@"),
!value.contains("?") && !value.contains("#")
else {
return nil
}
if value.hasPrefix("[") {
guard let closingBracket = value.firstIndex(of: "]"),
value.index(after: closingBracket) < value.endIndex,
value[value.index(after: closingBracket)] == ":"
else {
return nil
}
let host = String(value[value.index(after: value.startIndex)..<closingBracket])
let portStart = value.index(closingBracket, offsetBy: 2)
let port = String(value[portStart...])
guard !host.contains("%"),
let addressIsAllowed = ipv6LiteralIsAllowed(host),
isCanonicalPort(port)
else {
return nil
}
return addressIsAllowed
}
guard let separator = value.lastIndex(of: ":"),
value[..<separator].contains(":") == false
else {
return nil
}
let host = String(value[..<separator])
let port = String(value[value.index(after: separator)...])
guard let octets = canonicalIPv4Octets(host),
isCanonicalPort(port)
else {
return nil
}
return ipv4AddressIsAllowed(octets)
}
private func directSocketAddressIsGloballyRoutable(_ value: String) -> Bool {
if value.hasPrefix("["),
let closingBracket = value.firstIndex(of: "]") {
let host = String(value[value.index(after: value.startIndex)..<closingBracket])
guard let bytes = ipv6LiteralBytes(host) else {
return false
}
return ipv6AddressIsGloballyRoutable(bytes)
}
guard let separator = value.lastIndex(of: ":"),
let octets = canonicalIPv4Octets(String(value[..<separator]))
else {
return false
}
return ipv4AddressIsGloballyRoutable(octets)
}
private func canonicalIPv4Octets(_ host: String) -> [UInt8]? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else {
return nil
}
let octets = parts.compactMap { part -> UInt8? in
guard !part.isEmpty,
part.utf8.allSatisfy({ (48...57).contains($0) }),
let value = Int(part),
(0...255).contains(value)
else {
return nil
}
guard String(value) == part else {
return nil
}
return UInt8(value)
}
return octets.count == 4 ? octets : nil
}
private func ipv4AddressIsAllowed(_ octets: [UInt8]) -> Bool {
guard octets.count == 4 else {
return false
}
if octets[0] == 0 || octets[0] == 127 || (224...255).contains(octets[0]) {
return false
}
// IPv4 link-local addresses need an interface scope just like IPv6
// link-local addresses. This wire type cannot carry one, so no 169.254/16
// address is safely dialable after serialization.
if octets[0] == 169 && octets[1] == 254 {
return false
}
return true
}
private func ipv4AddressIsGloballyRoutable(_ octets: [UInt8]) -> Bool {
guard ipv4AddressIsAllowed(octets) else {
return false
}
let first = octets[0]
let second = octets[1]
let third = octets[2]
if first == 10
|| (first == 100 && (64...127).contains(second))
|| (first == 169 && second == 254)
|| (first == 172 && (16...31).contains(second))
|| (first == 192 && second == 168) {
return false
}
if (first == 192 && second == 0 && third == 0)
|| (first == 192 && second == 0 && third == 2)
|| (first == 192 && second == 88 && third == 99)
|| (first == 198 && (second == 18 || second == 19))
|| (first == 198 && second == 51 && third == 100)
|| (first == 203 && second == 0 && third == 113) {
return false
}
return true
}
private func ipv6LiteralIsAllowed(_ host: String) -> Bool? {
guard let bytes = ipv6LiteralBytes(host) else {
return nil
}
return ipv6AddressIsAllowed(bytes)
}
private func ipv6LiteralBytes(_ host: String) -> [UInt8]? {
var address = in6_addr()
let parsed = host.withCString { pointer in
inet_pton(AF_INET6, pointer, &address)
}
guard parsed == 1 else {
return nil
}
return withUnsafeBytes(of: &address) { Array($0) }
}
private func ipv6AddressIsAllowed(_ bytes: [UInt8]) -> Bool {
if bytes.allSatisfy({ $0 == 0 }) || bytes == Array(repeating: 0, count: 15) + [1] {
return false
}
if bytes.first == 0xFF {
return false
}
// A serialized remote `%en0` scope is meaningless on the receiving
// device, while an unscoped fe80::/10 address is not dialable. Local
// discovery must construct any scoped link-local address in-process.
if bytes.count == 16,
bytes[0] == 0xFE,
(bytes[1] & 0xC0) == 0x80 {
return false
}
if bytes == [0xFD, 0x00, 0x0E, 0xC2]
+ Array(repeating: 0, count: 10)
+ [0x02, 0x54] {
return false
}
let ipv4MappedPrefix = Array(repeating: UInt8(0), count: 10) + [0xFF, 0xFF]
if Array(bytes.prefix(12)) == ipv4MappedPrefix {
return ipv4AddressIsAllowed(Array(bytes.suffix(4)))
}
return true
}
private func ipv6AddressIsGloballyRoutable(_ bytes: [UInt8]) -> Bool {
let ipv4MappedPrefix = Array(repeating: UInt8(0), count: 10) + [0xFF, 0xFF]
if Array(bytes.prefix(12)) == ipv4MappedPrefix {
return ipv4AddressIsGloballyRoutable(Array(bytes.suffix(4)))
}
guard bytes.count == 16,
(bytes[0] & 0xE0) == 0x20
else {
return false
}
if bytes[0] == 0x20,
bytes[1] == 0x01,
bytes[2] <= 0x01 || (bytes[2] == 0x0D && bytes[3] == 0xB8) {
return false
}
if bytes[0] == 0x20 && bytes[1] == 0x02 {
return false
}
if bytes[0] == 0x3F && bytes[1] == 0xFF && (bytes[2] & 0xF0) == 0 {
return false
}
return true
}
private func isCanonicalPort(_ port: String) -> Bool {
guard !port.isEmpty,
port.utf8.allSatisfy({ (48...57).contains($0) }),
let value = Int(port),
(1...65_535).contains(value)
else {
return false
}
return String(value) == port
}
private func isSafeRelayURL(_ value: String) -> Bool {
guard value == value.trimmingCharacters(in: .whitespacesAndNewlines),
value.utf8.count <= 2_048,
value.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
value.rangeOfCharacter(from: .controlCharacters) == nil,
!value.contains("\\"),
let components = URLComponents(string: value),
components.scheme?.lowercased() == "https",
let host = components.host,
relayHostIsAllowed(host),
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/"
else {
return false
}
return components.port.map { (1...65_535).contains($0) } ?? true
}
private func relayHostIsAllowed(_ host: String) -> Bool {
let normalized = host.lowercased()
if let octets = canonicalIPv4Octets(normalized) {
return ipv4AddressIsGloballyRoutable(octets)
}
if normalized.contains(":"),
let bytes = ipv6LiteralBytes(normalized)
{
return ipv6AddressIsGloballyRoutable(bytes)
}
guard normalized.utf8.count <= 253,
!normalized.hasSuffix("."),
!normalized.hasSuffix(".localhost"),
!normalized.hasSuffix(".local"),
!normalized.hasSuffix(".home.arpa")
else {
return false
}
let labels = normalized.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 2,
labels.allSatisfy({ dnsLabelIsAllowed($0) }),
let topLevelLabel = labels.last,
topLevelLabel.utf8.contains(where: { (97...122).contains($0) })
else {
return false
}
return true
}
private func dnsLabelIsAllowed(_ label: Substring) -> Bool {
guard !label.isEmpty,
label.utf8.count <= 63,
let first = label.utf8.first,
let last = label.utf8.last,
isASCIILetterOrDigit(first),
isASCIILetterOrDigit(last)
else {
return false
}
return label.utf8.allSatisfy { byte in
isASCIILetterOrDigit(byte) || byte == 45
}
}
private func isASCIILetterOrDigit(_ byte: UInt8) -> Bool {
(48...57).contains(byte) || (97...122).contains(byte)
}
private func isSafeIdentifier(
_ value: String,
maximumUTF8Count: Int
) -> Bool {
guard !value.isEmpty,
value.utf8.count <= maximumUTF8Count
else {
return false
}
return value.utf8.allSatisfy { byte in
(48...57).contains(byte)
|| (65...90).contains(byte)
|| (97...122).contains(byte)
|| byte == 45
|| byte == 46
|| byte == 58
|| byte == 95
}
}
func validate(
requireCurrentPrivateMetadata: Bool,
requireSafeValueShape: Bool
) throws {
let value = hint.value
let kind = hint.kind
let source = hint.source
let privacyScope = hint.privacyScope
let observedAt = hint.observedAt
let expiresAt = hint.expiresAt
let networkProfile = hint.networkProfile
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CmxIrohPathHintError.emptyValue
}
if requireSafeValueShape {
switch kind {
case .directAddress:
guard let directAddressIsAllowed = directSocketAddressIsAllowed(value) else {
throw CmxIrohPathHintError.invalidDirectAddress
}
guard directAddressIsAllowed else {
throw CmxIrohPathHintError.forbiddenDirectAddress
}
if privacyScope == .publicInternet,
!directSocketAddressIsGloballyRoutable(value)
{
throw CmxIrohPathHintError.nonGlobalPublicDirectAddress
}
case .relayIdentifier:
guard isSafeIdentifier(value, maximumUTF8Count: 255) else {
throw CmxIrohPathHintError.invalidRelayIdentifier
}
case .relayURL:
guard isSafeRelayURL(value) else {
throw CmxIrohPathHintError.unsafeRelayURL
}
}
}
if kind == .relayIdentifier || kind == .relayURL {
guard source == .native, privacyScope == .publicInternet else {
throw CmxIrohPathHintError.relayHintRequiresNativePublicSource
}
}
switch source {
case .native:
let isInertLegacyPrivateHint = !requireCurrentPrivateMetadata
&& observedAt == nil
&& expiresAt == nil
&& networkProfile == nil
guard privacyScope == .publicInternet || isInertLegacyPrivateHint else {
throw CmxIrohPathHintError.incompatiblePrivacyScope(
source: source,
scope: privacyScope
)
}
case .lan:
guard privacyScope == .localNetwork else {
throw CmxIrohPathHintError.incompatiblePrivacyScope(
source: source,
scope: privacyScope
)
}
case .tailscale, .customVPN:
guard privacyScope == .privateNetwork else {
throw CmxIrohPathHintError.incompatiblePrivacyScope(
source: source,
scope: privacyScope
)
}
}
if privacyScope == .publicInternet {
guard networkProfile == nil else {
throw CmxIrohPathHintError.unexpectedPublicNetworkProfile
}
return
}
guard requireCurrentPrivateMetadata else {
return
}
guard let observedAt else {
throw CmxIrohPathHintError.missingPrivateHintObservation
}
guard let expiresAt else {
throw CmxIrohPathHintError.missingPrivateHintExpiry
}
guard let networkProfile else {
throw CmxIrohPathHintError.missingPrivateHintNetworkProfile
}
guard networkProfile.source == source else {
throw CmxIrohPathHintError.networkProfileSourceMismatch
}
let lifetime = expiresAt.timeIntervalSince(observedAt)
guard lifetime > 0 else {
throw CmxIrohPathHintError.invalidPrivateHintLifetime
}
guard lifetime <= CmxIrohPathHint.maximumPrivateHintTTL else {
throw CmxIrohPathHintError.privateHintTTLExceedsMaximum
}
}
}
extension CmxIrohPathHint {
func validate(
requireCurrentPrivateMetadata: Bool,
requireSafeValueShape: Bool
) throws {
try CmxIrohPathHintValidator(hint: self).validate(
requireCurrentPrivateMetadata: requireCurrentPrivateMetadata,
requireSafeValueShape: requireSafeValueShape
)
}
}
@@ -0,0 +1,254 @@
import Darwin
import Foundation
/// A provider-attributed, privacy-scoped address hint for an Iroh peer.
///
/// Hints influence reachability only. They never establish peer identity or
/// authorize credentials. Non-public hints are fallback-only by construction
/// and newly created private hints must expire.
public struct CmxIrohPathHint: Equatable, Sendable {
/// The longest lifetime accepted for any non-public hint.
public static let maximumPrivateHintTTL: TimeInterval = 60 * 60
/// The clock skew tolerated when comparing a provider observation with the
/// local clock. A larger future offset makes the hint inert instead of
/// extending its usable lifetime.
public static let maximumObservationClockSkew: TimeInterval = 5 * 60
private enum CodingKeys: String, CodingKey {
case kind
case value
case source
case privacyScope = "privacy_scope"
case observedAt = "observed_at"
case expiresAt = "expires_at"
case networkProfile = "network_profile"
case legacyNetworkProfileID = "network_profile_id"
}
/// The address form carried by the hint.
public let kind: CmxIrohPathHintKind
/// The socket address, relay identifier, or relay URL.
public let value: String
/// The provider that discovered the hint.
public let source: CmxIrohPathHintSource
/// The network scope in which the hint may be disclosed.
public let privacyScope: CmxIrohPathHintPrivacyScope
/// When the provider last observed this path.
public let observedAt: Date?
/// The time after which the hint must no longer be attempted.
public let expiresAt: Date?
/// The provider-qualified overlay, site, or network profile.
///
/// This disambiguates overlapping private address spaces. It is routing
/// metadata only and never contributes to peer authentication.
public let networkProfile: CmxIrohNetworkProfileKey?
/// Creates a validated Iroh path hint.
///
/// Every non-public hint requires an observation time, an expiry no more
/// than one hour later, and a provider-qualified active-network profile.
/// Older hints missing those fields decode only through the internal inert
/// compatibility path and remain unusable until refreshed.
/// - Parameters:
/// - kind: The address form carried by the hint.
/// - value: The socket address, relay identifier, or relay URL.
/// - source: The provider that discovered the hint.
/// - privacyScope: The narrowest scope in which it may be disclosed.
/// - observedAt: When the provider observed the path.
/// - expiresAt: The time after which the hint must not be attempted.
/// - networkProfile: The provider-qualified active-network profile.
/// - Throws: ``CmxIrohPathHintError`` when the hint violates its invariants.
public init(
kind: CmxIrohPathHintKind,
value: String,
source: CmxIrohPathHintSource,
privacyScope: CmxIrohPathHintPrivacyScope,
observedAt: Date? = nil,
expiresAt: Date? = nil,
networkProfile: CmxIrohNetworkProfileKey? = nil
) throws {
self.kind = kind
self.value = value
self.source = source
self.privacyScope = privacyScope
self.observedAt = observedAt
self.expiresAt = expiresAt
self.networkProfile = networkProfile
try validate(requireCurrentPrivateMetadata: true, requireSafeValueShape: true)
}
/// The routing tier derived from privacy scope.
///
/// Callers cannot promote a private-network address to a primary path.
public var use: CmxIrohPathHintUse {
privacyScope == .publicInternet ? .primary : .fallbackOnly
}
/// Whether the hint may be attempted at a given time.
///
/// Legacy private hints without an expiry decode for compatibility but are
/// deliberately inert until a current producer replaces them.
/// - Parameter now: The time against which expiry is checked.
/// - Returns: `true` when the hint is current and usable.
public func isUsable(at now: Date) -> Bool {
guard isSafeForCurrentWireFormat else {
return false
}
if let observedAt,
observedAt > now.addingTimeInterval(Self.maximumObservationClockSkew) {
return false
}
if privacyScope != .publicInternet {
guard let expiresAt,
expiresAt <= now.addingTimeInterval(
Self.maximumPrivateHintTTL + Self.maximumObservationClockSkew
) else {
return false
}
}
if let expiresAt {
return expiresAt > now
}
return privacyScope == .publicInternet
}
/// A public-disclosure copy, or `nil` when this hint is private, local,
/// expired, or structurally unsafe.
public func publicDisclosure(at now: Date) -> Self? {
guard privacyScope == .publicInternet, isUsable(at: now) else {
return nil
}
return try? Self(
kind: kind,
value: value,
source: source,
privacyScope: privacyScope,
observedAt: observedAt,
expiresAt: expiresAt,
networkProfile: nil
)
}
/// Revalidates structural relationships while tolerating inert legacy data.
func validate() throws {
try validate(requireCurrentPrivateMetadata: false, requireSafeValueShape: false)
}
/// Whether the hint satisfies the current value, privacy, and expiry rules.
///
/// Legacy fields may decode without satisfying this predicate so old
/// tickets remain readable, but those hints must not be attempted or
/// re-emitted into a format that would promote them.
public var isSafeForCurrentWireFormat: Bool {
do {
try validate(requireCurrentPrivateMetadata: true, requireSafeValueShape: true)
return true
} catch {
return false
}
}
/// Builds an inert compatibility hint from the pre-provenance wire fields.
init(
legacyKind kind: CmxIrohPathHintKind,
value: String,
privacyScope: CmxIrohPathHintPrivacyScope
) {
self.init(
rawKind: kind,
value: value,
source: .native,
privacyScope: privacyScope,
observedAt: nil,
expiresAt: nil,
networkProfile: nil
)
}
private init(
rawKind kind: CmxIrohPathHintKind,
value: String,
source: CmxIrohPathHintSource,
privacyScope: CmxIrohPathHintPrivacyScope,
observedAt: Date?,
expiresAt: Date?,
networkProfile: CmxIrohNetworkProfileKey?
) {
self.kind = kind
self.value = value
self.source = source
self.privacyScope = privacyScope
self.observedAt = observedAt
self.expiresAt = expiresAt
self.networkProfile = networkProfile
}
}
extension CmxIrohPathHint: Codable {
/// Decodes a path hint, preserving incomplete legacy private hints as inert
/// compatibility data while validating current wire forms.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(CmxIrohPathHintKind.self, forKey: .kind)
let value = try container.decode(String.self, forKey: .value)
let source = try container.decode(CmxIrohPathHintSource.self, forKey: .source)
let privacyScope = try container.decode(CmxIrohPathHintPrivacyScope.self, forKey: .privacyScope)
let observedAt = try container.decodeIfPresent(Date.self, forKey: .observedAt)
let expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
let networkProfile: CmxIrohNetworkProfileKey?
if let current = try container.decodeIfPresent(
CmxIrohNetworkProfileKey.self,
forKey: .networkProfile
) {
networkProfile = current
} else if let legacyID = try container.decodeIfPresent(
String.self,
forKey: .legacyNetworkProfileID
) {
networkProfile = try CmxIrohNetworkProfileKey(source: source, profileID: legacyID)
} else {
networkProfile = nil
}
if privacyScope == .publicInternet
|| (observedAt != nil && expiresAt != nil && networkProfile != nil) {
try self.init(
kind: kind,
value: value,
source: source,
privacyScope: privacyScope,
observedAt: observedAt,
expiresAt: expiresAt,
networkProfile: networkProfile
)
} else {
// Compatibility with the first provenance-aware wire revision.
// Missing freshness/profile metadata remains readable but inert,
// and endpoint encoders prune it instead of re-emitting it.
self.init(
rawKind: kind,
value: value,
source: source,
privacyScope: privacyScope,
observedAt: observedAt,
expiresAt: expiresAt,
networkProfile: networkProfile
)
try validate()
}
}
/// Encodes the validated path-hint fields in the current wire form.
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(kind, forKey: .kind)
try container.encode(value, forKey: .value)
try container.encode(source, forKey: .source)
try container.encode(privacyScope, forKey: .privacyScope)
try container.encodeIfPresent(observedAt, forKey: .observedAt)
try container.encodeIfPresent(expiresAt, forKey: .expiresAt)
try container.encodeIfPresent(networkProfile, forKey: .networkProfile)
}
}
@@ -0,0 +1,36 @@
/// Validation failures for Iroh path hints.
public enum CmxIrohPathHintError: Error, Equatable, Sendable {
/// The hint carried no address or relay value.
case emptyValue
/// The provider and privacy scope describe incompatible networks.
case incompatiblePrivacyScope(
source: CmxIrohPathHintSource,
scope: CmxIrohPathHintPrivacyScope
)
/// A newly created private hint omitted its required expiry.
case missingPrivateHintExpiry
/// A newly created non-public hint omitted the time it was observed.
case missingPrivateHintObservation
/// A non-public hint's expiry did not follow its observation time.
case invalidPrivateHintLifetime
/// A non-public hint exceeded the maximum one-hour lifetime.
case privateHintTTLExceedsMaximum
/// A non-public hint omitted its provider-qualified network profile.
case missingPrivateHintNetworkProfile
/// A hint used a profile owned by a different provider.
case networkProfileSourceMismatch
/// A public hint carried private-network profile metadata.
case unexpectedPublicNetworkProfile
/// Relay hints must come from Iroh-native public discovery.
case relayHintRequiresNativePublicSource
/// A direct hint was not an IPv4-or-bracketed-IPv6 socket address.
case invalidDirectAddress
/// A direct hint targeted a non-peer address such as loopback or multicast.
case forbiddenDirectAddress
/// A direct hint claimed public scope for a non-globally-routable address.
case nonGlobalPublicDirectAddress
/// A relay identifier contained unsafe or ambiguous characters.
case invalidRelayIdentifier
/// A relay URL was not a root HTTPS URL without credentials or query data.
case unsafeRelayURL
}
@@ -0,0 +1,9 @@
/// The address form carried by an Iroh path hint.
public enum CmxIrohPathHintKind: String, Codable, Sendable {
/// A socket address that Iroh may try directly.
case directAddress = "direct_address"
/// A legacy relay identifier understood by the Iroh integration.
case relayIdentifier = "relay_identifier"
/// A relay server URL.
case relayURL = "relay_url"
}
@@ -0,0 +1,9 @@
/// The narrowest network scope in which an Iroh path hint may be disclosed.
public enum CmxIrohPathHintPrivacyScope: String, Codable, Sendable {
/// The hint is safe to publish through Internet discovery.
case publicInternet = "public_internet"
/// The hint may be shared only on the current local network.
case localNetwork = "local_network"
/// The hint may be shared only through the user's private network.
case privateNetwork = "private_network"
}
@@ -0,0 +1,13 @@
/// The provider that discovered an Iroh path hint.
///
/// Provenance affects privacy and routing policy, never peer authentication.
public enum CmxIrohPathHintSource: String, Codable, Sendable {
/// Iroh's native discovery or relay configuration supplied the hint.
case native
/// Local-link discovery supplied the hint.
case lan
/// Tailscale supplied the hint.
case tailscale
/// A user-configured private-network or VPN provider supplied the hint.
case customVPN = "custom_vpn"
}
@@ -0,0 +1,7 @@
/// The routing tier assigned to an Iroh path hint.
public enum CmxIrohPathHintUse: String, Codable, Sendable {
/// A public Iroh-native path that may be attempted normally.
case primary
/// A private path that may be attempted only after primary paths.
case fallbackOnly = "fallback_only"
}
@@ -0,0 +1,34 @@
/// The stable cryptographic identity of an Iroh endpoint.
///
/// This value identifies the peer independently from every address or relay
/// hint used to reach it. A route may change hints without changing identity.
public struct CmxIrohPeerIdentity: Codable, Equatable, Hashable, Sendable {
private enum CodingKeys: String, CodingKey {
case endpointID
}
/// The Iroh endpoint identifier presented by the route.
public let endpointID: String
/// Creates an Iroh peer identity from an endpoint identifier.
///
/// Iroh's canonical display form is exactly 32 bytes encoded as 64
/// lowercase hexadecimal characters. Other spellings are rejected so one
/// peer cannot acquire multiple persistence or deduplication identities.
/// - Parameter endpointID: The stable Iroh endpoint identifier.
public init(endpointID: String) throws {
guard endpointID.utf8.count == 64,
endpointID.utf8.allSatisfy({ byte in
(48...57).contains(byte) || (97...102).contains(byte)
}) else {
throw CmxIrohPeerIdentityError.nonCanonicalEndpointID
}
self.endpointID = endpointID
}
/// Decodes and validates a canonical Iroh EndpointID.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(endpointID: container.decode(String.self, forKey: .endpointID))
}
}
@@ -0,0 +1,5 @@
/// Validation failures for Iroh peer identity values.
public enum CmxIrohPeerIdentityError: Error, Equatable, Sendable {
/// The value was not exactly 64 lowercase hexadecimal characters.
case nonCanonicalEndpointID
}
@@ -0,0 +1,32 @@
/// A user-visible relay preference shared by the macOS and iOS settings surfaces.
public enum CmxIrohRelayPreferenceDraft: Equatable, Sendable {
/// Allow every relay in the signed cmux catalog and let Iroh choose.
case automatic
/// Allow only the selected stable relay identifiers from the signed catalog.
case managed(Set<String>)
/// Disable managed relays and use the account's custom relay definitions.
case custom
/// Returns this preference after enforcing the cross-platform UI boundary.
/// Controllers must call this before persistence so an alternate settings
/// entrypoint cannot create an empty or oversized managed selection.
public func validated() throws -> Self {
guard case let .managed(ids) = self else { return self }
guard (1 ... 16).contains(ids.count), ids.allSatisfy(Self.isSafeRelayID) else {
throw CmxIrohRelayPreferenceDraftError.invalidManagedSelection
}
return self
}
private static func isSafeRelayID(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 95].contains(byte)
}
}
}
@@ -0,0 +1,4 @@
/// Validation failures for a user-visible relay preference draft.
public enum CmxIrohRelayPreferenceDraftError: Error, Equatable, Sendable {
case invalidManagedSelection
}
@@ -0,0 +1,11 @@
/// Non-secret result of probing a configured custom relay.
public enum CmxIrohRelayTestResult: Equatable, Sendable {
/// The relay accepted a protocol connection.
case reachable(latencyMilliseconds: Int?)
/// The relay could not be reached or rejected the configured credential.
case failed
/// The relay definition or its required device credential is incomplete.
case incomplete
}
@@ -0,0 +1,29 @@
/// A redacted description of the live Iroh path safe for application settings.
///
/// The value deliberately excludes IP addresses, ports, relay URLs, and Iroh
/// path identifiers. Relay labels come only from the verified effective policy.
public enum CmxIrohSelectedTransportPath: Equatable, Sendable {
/// No attributable live connection path is available.
case unavailable
/// Application traffic is using a direct public peer-to-peer path.
case direct
/// Application traffic is using a private or local network path.
case privateNetwork
/// Application traffic is using a relay from the signed managed catalog.
///
/// - Parameters:
/// - provider: The provider label from the signed policy.
/// - region: The region label from the signed policy.
case managedRelay(provider: String, region: String)
/// Application traffic is using an account-defined custom relay.
///
/// - Parameters:
/// - displayName: The user-supplied display name, or stable relay ID.
/// - provider: The user-supplied provider label.
/// - region: The user-supplied region label.
case customRelay(displayName: String, provider: String, region: String)
}
@@ -0,0 +1,27 @@
/// Cross-platform settings boundary implemented by each app's Iroh composition root.
@MainActor
public protocol CmxIrohSettingsControlling: AnyObject {
/// Returns a credential-free snapshot suitable for display and diagnostics.
func irohSettingsSnapshot() async -> CmxIrohSettingsSnapshot
/// Emits snapshot changes without polling.
func irohSettingsUpdates() -> AsyncStream<CmxIrohSettingsSnapshot>
/// Persists the account-level relay preference and safely rebuilds the endpoint.
func setIrohRelayPreference(_ preference: CmxIrohRelayPreferenceDraft) async throws
/// Creates or updates account-visible custom relay metadata and a device-local secret.
func upsertIrohCustomRelay(
_ relay: CmxIrohCustomRelayDraft,
deviceSecret: String?
) async throws
/// Removes custom relay metadata and erases this device's associated secret.
func removeIrohCustomRelay(id: String) async throws
/// Probes one custom relay without changing the active preference.
func testIrohCustomRelay(id: String) async -> CmxIrohRelayTestResult
/// Fetches the latest signed fleet and account preference.
func refreshIrohSettings() async
}
@@ -0,0 +1,138 @@
public import Foundation
/// Immutable, credential-free state rendered by Iroh settings on macOS and iOS.
public struct CmxIrohSettingsSnapshot: Equatable, Sendable {
public enum RuntimeStatus: Equatable, Sendable {
case inactive
case starting
/// Endpoint is active, but no live peer path is currently attributable.
case active
case direct
case relayed(provider: String, region: String)
case privateNetwork(displayName: String)
case degraded
/// Creates an active runtime status from one coordinate-free path.
///
/// - Parameter path: The redacted selected transport path.
public init(activePath path: CmxIrohSelectedTransportPath) {
switch path {
case .unavailable:
self = .active
case .direct:
self = .direct
case .privateNetwork:
self = .privateNetwork(displayName: "")
case let .managedRelay(provider, region):
self = .relayed(provider: provider, region: region)
case let .customRelay(_, provider, region):
self = .relayed(provider: provider, region: region)
}
}
}
public enum PolicySource: Equatable, Sendable {
case server
case cached
case unavailable
}
public enum CredentialState: Equatable, Sendable {
case notRequired
case configured
case missing
case unavailable
}
public struct ManagedRelay: Identifiable, Equatable, Sendable {
public let id: String
public let provider: String
public let region: String
public let url: String
public let isSelected: Bool
public init(id: String, provider: String, region: String, url: String, isSelected: Bool) {
self.id = id
self.provider = provider
self.region = region
self.url = url
self.isSelected = isSelected
}
}
public struct CustomRelay: Identifiable, Equatable, Sendable {
public let id: String
public let displayName: String
public let provider: String
public let region: String
public let url: String
public let authMode: CmxIrohCustomRelayCredentialMode
public let credentialState: CredentialState
public init(
id: String,
displayName: String,
provider: String,
region: String,
url: String,
authMode: CmxIrohCustomRelayCredentialMode,
credentialState: CredentialState
) {
self.id = id
self.displayName = displayName
self.provider = provider
self.region = region
self.url = url
self.authMode = authMode
self.credentialState = credentialState
}
}
public let runtimeStatus: RuntimeStatus
/// Redacted selected-path attribution, independent from lifecycle status.
public let selectedTransportPath: CmxIrohSelectedTransportPath
public let preference: CmxIrohRelayPreferenceDraft
public let managedRelays: [ManagedRelay]
public let customRelays: [CustomRelay]
public let policySource: PolicySource
public let policySequence: Int64?
public let policyExpiresAt: Date?
public let staleRelayIDs: Set<String>
public let failureDescription: String?
/// Debug-only relay-path override, or `nil` when the current app cannot control it.
public let debugRelayOnlyEnabled: Bool?
public init(
runtimeStatus: RuntimeStatus,
selectedTransportPath: CmxIrohSelectedTransportPath = .unavailable,
preference: CmxIrohRelayPreferenceDraft,
managedRelays: [ManagedRelay],
customRelays: [CustomRelay],
policySource: PolicySource,
policySequence: Int64? = nil,
policyExpiresAt: Date? = nil,
staleRelayIDs: Set<String> = [],
failureDescription: String? = nil,
debugRelayOnlyEnabled: Bool? = nil
) {
self.runtimeStatus = runtimeStatus
self.selectedTransportPath = selectedTransportPath
self.preference = preference
self.managedRelays = managedRelays
self.customRelays = customRelays
self.policySource = policySource
self.policySequence = policySequence
self.policyExpiresAt = policyExpiresAt
self.staleRelayIDs = staleRelayIDs
self.failureDescription = failureDescription
self.debugRelayOnlyEnabled = debugRelayOnlyEnabled
}
public static let unavailable = CmxIrohSettingsSnapshot(
runtimeStatus: .inactive,
preference: .automatic,
managedRelays: [],
customRelays: [],
policySource: .unavailable
)
}
@@ -0,0 +1,178 @@
import Foundation
extension CmxAttachEndpoint {
/// Creates an Iroh endpoint from the legacy peer fields.
///
/// This compatibility constructor preserves existing source and wire
/// producers while new code moves to ``peer(identity:pathHints:)``.
/// Legacy direct addresses have no provenance or expiry, so they decode as
/// private, fallback-only, and unusable until refreshed by a current source.
/// - Parameters:
/// - id: The Iroh EndpointID.
/// - relayHint: The optional legacy relay identifier.
/// - directAddrs: Legacy direct socket addresses.
/// - relayURL: The optional relay URL.
/// - Returns: A peer endpoint with identity separated from path hints.
public static func peer(
id: String,
relayHint: String?,
directAddrs: [String],
relayURL: String?
) throws -> Self {
var pathHints: [CmxIrohPathHint] = []
if let relayHint {
pathHints.append(CmxIrohPathHint(
legacyKind: .relayIdentifier,
value: relayHint,
privacyScope: .publicInternet
))
}
pathHints.append(contentsOf: directAddrs.map { address in
CmxIrohPathHint(
legacyKind: .directAddress,
value: address,
privacyScope: .privateNetwork
)
})
if let relayURL {
pathHints.append(CmxIrohPathHint(
legacyKind: .relayURL,
value: relayURL,
privacyScope: .publicInternet
))
}
return .peer(
identity: try CmxIrohPeerIdentity(endpointID: id),
pathHints: pathHints
)
}
/// The Iroh identity carried by a peer endpoint, independent of its hints.
public var irohPeerIdentity: CmxIrohPeerIdentity? {
guard case let .peer(identity, _) = self else {
return nil
}
return identity
}
/// Builds the explicit two-attempt Iroh dial plan.
///
/// A profile-scoped hint is omitted unless its overlay/site/profile is
/// currently active, preventing an overlapping private address from being
/// attempted on the wrong network.
/// - Parameters:
/// - now: The time against which hint expiry is checked.
/// - managedRelayURLs: The exact relay URLs configured by cmux. Relay
/// hints outside this set and legacy relay identifiers are excluded.
/// - activeNetworkProfiles: Locally verified provider-qualified profiles.
/// - Returns: A two-phase plan for peer endpoints, otherwise `nil`.
public func irohDialPlan(
at now: Date,
managedRelayURLs: Set<String>,
activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> = []
) -> CmxIrohDialPlan? {
guard case let .peer(_, pathHints) = self else {
return nil
}
let publicPaths = pathHints.filter { hint in
guard hint.privacyScope == .publicInternet,
hint.isUsable(at: now) else {
return false
}
switch hint.kind {
case .directAddress:
return true
case .relayURL:
return managedRelayURLs.contains(hint.value)
case .relayIdentifier:
return false
}
}
let privateFallbackPaths = pathHints.filter { hint in
guard hint.privacyScope != .publicInternet,
hint.isUsable(at: now),
let networkProfile = hint.networkProfile else {
return false
}
return activeNetworkProfiles.contains(networkProfile)
}
return CmxIrohDialPlan(
publicPaths: publicPaths,
privateFallbackPaths: privateFallbackPaths
)
}
}
extension CmxAttachEndpoint {
/// Returns a copy whose Iroh hints are current and permitted at a
/// serialization boundary.
fileprivate func disclosed(
for disclosure: CmxAttachRouteDisclosure,
at now: Date
) -> Self {
guard case let .peer(identity, pathHints) = self else {
return self
}
let disclosedHints: [CmxIrohPathHint]
switch disclosure {
case .authenticated:
disclosedHints = pathHints.filter { $0.isUsable(at: now) }
case .cloudRendezvous, .pairedMacCloudBackup:
disclosedHints = pathHints.compactMap { hint in
guard hint.kind == .relayURL else { return nil }
return hint.publicDisclosure(at: now)
}
case .publicStatus, .pairingQRCode:
disclosedHints = []
}
return .peer(identity: identity, pathHints: disclosedHints)
}
}
extension CmxAttachRoute {
/// Returns the route shape permitted at a serialization boundary.
///
/// Unauthenticated status exposes no attach routes. Cloud rendezvous,
/// pairing QR, and paired-Mac backup keep only the route data permitted by
/// their stricter disclosure policies.
public func disclosed(
for disclosure: CmxAttachRouteDisclosure,
at now: Date
) -> Self? {
if disclosure == .publicStatus {
return nil
}
return try? Self(
id: id,
kind: kind,
endpoint: endpoint.disclosed(for: disclosure, at: now),
priority: priority
)
}
}
extension CmxAttachTicket {
/// Returns a ticket whose routes are safe for an authenticated transport.
///
/// Pairing QR and public-status payloads intentionally have different
/// field-level disclosure rules, so they must not use this copy operation.
public func authenticatedDisclosure(at now: Date) throws -> Self {
try Self(
version: version,
workspaceID: workspaceID,
terminalID: terminalID,
macDeviceID: macDeviceID,
macDisplayName: macDisplayName,
macUserEmail: macUserEmail,
macUserID: macUserID,
macPairingCompatibilityVersion: macPairingCompatibilityVersion,
macAppVersion: macAppVersion,
macAppBuild: macAppBuild,
routes: routes.compactMap {
$0.disclosed(for: .authenticated, at: now)
},
expiresAt: expiresAt,
authToken: authToken
)
}
}
@@ -54,13 +54,17 @@ public struct CmxPairingQRCode: Sendable {
public init() {}
/// Encode `ticket` as a v2 pairing URL, or `nil` when the ticket does not
/// qualify (see ``canEncode(_:)``); callers fall back to the compact v1
/// payload so every ticket still has an attach URL.
/// qualify (see ``canEncode(_:routeDisclosureMode:)``); callers fall back
/// to the compact v1 payload so every ticket still has an attach URL.
///
/// Only the ticket's Tailscale routes are encoded: a DEBUG Mac's dev
/// loopback route is dropped, never written into a scannable code.
public func encode(_ ticket: CmxAttachTicket) -> String? {
guard let routes = encodableRoutes(of: ticket) else {
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) -> String? {
guard routeDisclosureMode == .legacyPrivateNetworkCompatibility,
let routes = encodableRoutes(of: ticket) else {
return nil
}
var items: [String] = ["v=\(Self.version)"]
@@ -91,10 +95,15 @@ public struct CmxPairingQRCode: Sendable {
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
}
/// Whether `ticket` is expressible in the minimal grammar; see
/// ``encodableRoutes(of:)`` for the rules.
public func canEncode(_ ticket: CmxAttachTicket) -> Bool {
encodableRoutes(of: ticket) != nil
/// Whether `ticket` is expressible in the minimal grammar under the
/// explicitly selected disclosure mode; see ``encodableRoutes(of:)`` for
/// the rules.
public func canEncode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) -> Bool {
routeDisclosureMode == .legacyPrivateNetworkCompatibility
&& encodableRoutes(of: ticket) != nil
}
/// The route subsequence a v2 pairing URL would carry for `ticket`, or
@@ -0,0 +1,13 @@
/// The private-route disclosure policy for a scannable attach payload.
///
/// Callers must choose explicitly so adding a route to a ticket cannot silently
/// add it to a QR code. The legacy mode exists only while released clients still
/// require Tailscale host routes during the Iroh migration.
public enum CmxPairingRouteDisclosureMode: Equatable, Sendable {
/// Encode only Iroh EndpointIDs. All Iroh hints and every host/port or URL
/// route are removed.
case irohIdentityOnly
/// Preserve the pre-Iroh compact route grammar for released clients.
/// This may disclose private-network routes and must not become a default.
case legacyPrivateNetworkCompatibility
}
@@ -0,0 +1,97 @@
import Darwin
import Foundation
/// A canonical numeric address assigned to one Tailscale peer.
///
/// Construction rejects generic CGNAT, public, private-LAN, and Tailscale
/// service addresses. Callers can therefore persist ``value`` as a transport
/// target without retaining a DNS dependency.
public struct CmxTailscalePeerAddress: Hashable, Sendable {
/// The address family used by this peer address.
public enum Family: Hashable, Sendable {
/// A peer address in Tailscale's `100.64.0.0/10` range.
case ipv4
/// A peer address in Tailscale's `fd7a:115c:a1e0::/48` range.
case ipv6
}
/// The canonical numeric spelling suitable for a host/port endpoint.
public let value: String
/// The numeric address family.
public let family: Family
let bytes: [UInt8]
/// Parses one numeric Tailscale peer address.
/// - Parameter rawValue: An IPv4 or IPv6 literal without brackets or a zone.
public init?(_ rawValue: String) {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value == rawValue else { return nil }
if let parsed = Self.parseIPv4(value), Self.isTailscaleIPv4Peer(parsed.bytes) {
self.value = parsed.canonical
family = .ipv4
bytes = parsed.bytes
return
}
if let parsed = Self.parseIPv6(value), Self.isTailscaleIPv6Peer(parsed.bytes) {
self.value = parsed.canonical
family = .ipv6
bytes = parsed.bytes
return
}
return nil
}
private static func parseIPv4(_ value: String) -> (canonical: String, bytes: [UInt8])? {
var address = in_addr()
guard value.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { return nil }
let bytes = withUnsafeBytes(of: &address) { Array($0) }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil }
return (decode(buffer), bytes)
}
private static func parseIPv6(_ value: String) -> (canonical: String, bytes: [UInt8])? {
guard !value.contains("%") else { return nil }
var address = in6_addr()
guard value.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { return nil }
let bytes = withUnsafeBytes(of: &address) { Array($0) }
var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil }
return (decode(buffer).lowercased(), bytes)
}
private static func decode(_ buffer: [CChar]) -> String {
String(
decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) },
as: UTF8.self
)
}
private static func isTailscaleIPv4Peer(_ bytes: [UInt8]) -> Bool {
guard bytes.count == 4,
bytes[0] == 100,
(bytes[1] & 0xC0) == 64 else {
return false
}
// Tailscale reserves these ranges for local services and test traffic;
// they do not identify a peer node.
if bytes[1] == 100, bytes[2] == 0 || bytes[2] == 100 {
return false
}
if bytes[1] == 115, bytes[2] == 92 || bytes[2] == 93 {
return false
}
return true
}
private static func isTailscaleIPv6Peer(_ bytes: [UInt8]) -> Bool {
guard bytes.count == 16,
bytes.starts(with: [0xFD, 0x7A, 0x11, 0x5C, 0xA1, 0xE0]) else {
return false
}
// `fd7a:115c:a1e0::53` is the local MagicDNS service, not a peer.
let magicDNS = [UInt8](repeating: 0, count: 9) + [0x53]
return Array(bytes[6...]) != magicDNS
}
}
@@ -0,0 +1,13 @@
/// One peer identity from the authenticated local Tailscale status snapshot.
public struct CmxTailscalePeerRecord: Equatable, Sendable {
/// Tailscale's stable identifier for the peer when the client supplied it.
public let stableID: String?
/// The normalized fully qualified MagicDNS name without a trailing dot.
public let dnsName: String
/// Every numeric peer address carried by the same status record.
public let addresses: [CmxTailscalePeerAddress]
/// The deterministic numeric transport target, preferring IPv4 over IPv6.
public let preferredAddress: CmxTailscalePeerAddress
/// Whether this record came from the status snapshot's `Self` entry.
public let isLocalDevice: Bool
}
@@ -0,0 +1,20 @@
/// Fail-closed errors produced while binding a MagicDNS input to one local
/// Tailscale control-plane peer record.
public enum CmxTailscaleStatusPeerResolutionError: Error, Equatable, Sendable {
/// The requested value was not a syntactically valid fully qualified `*.ts.net` name.
case invalidMagicDNSName
/// The status command did not return a bounded JSON object.
case malformedStatus
/// The local Tailscale backend was not running when the snapshot was read.
case statusNotRunning
/// No peer in the status snapshot had the exact normalized DNS name.
case peerNotFound
/// More than one status record claimed the exact normalized DNS name.
case ambiguousPeer
/// The exact name identified this device rather than a remote peer.
case localDeviceNotAllowed
/// The matched record had no numeric addresses.
case missingPeerAddresses
/// At least one address in the matched record was not a Tailscale peer address.
case invalidPeerAddress
}
@@ -0,0 +1,140 @@
import Foundation
/// Resolves a `*.ts.net` input against an authenticated `tailscale status --json`
/// snapshot and returns one deterministic numeric transport target.
///
/// This parser does not perform DNS. It trusts only the local Tailscale daemon's
/// control-plane peer map, requires one exact record, validates every address in
/// that record, and then prefers IPv4 over IPv6. The caller persists the numeric
/// result so iOS never has to trust or re-resolve the DNS name.
public struct CmxTailscaleStatusPeerResolver: Sendable {
/// Maximum accepted status size, bounding local command output parsing.
public static let maximumStatusBytes = 8 * 1024 * 1024
/// Maximum peer records inspected from one status snapshot.
public static let maximumPeerRecords = 16_384
/// Creates a stateless status resolver.
public init() {}
/// Finds one exact peer record for a MagicDNS name.
/// - Parameters:
/// - magicDNSName: A fully qualified `*.ts.net` name, with an optional trailing dot.
/// - statusJSON: Authenticated local output from `tailscale status --json`.
/// - allowLocalDevice: Whether a matching `Self` record may be returned.
/// - Returns: The exact peer record and deterministic numeric target.
/// - Throws: ``CmxTailscaleStatusPeerResolutionError`` when the name or status is unsafe.
public func resolve(
magicDNSName: String,
statusJSON: Data,
allowLocalDevice: Bool = false
) throws -> CmxTailscalePeerRecord {
guard let requestedName = normalizedMagicDNSName(magicDNSName) else {
throw CmxTailscaleStatusPeerResolutionError.invalidMagicDNSName
}
guard !statusJSON.isEmpty,
statusJSON.count <= Self.maximumStatusBytes,
let root = try? JSONSerialization.jsonObject(with: statusJSON) as? [String: Any] else {
throw CmxTailscaleStatusPeerResolutionError.malformedStatus
}
guard root["BackendState"] as? String == "Running" else {
throw CmxTailscaleStatusPeerResolutionError.statusNotRunning
}
var candidates: [(object: [String: Any], isLocalDevice: Bool)] = []
if let local = root["Self"] as? [String: Any] {
candidates.append((local, true))
}
if let peers = root["Peer"] as? [String: Any] {
guard peers.count <= Self.maximumPeerRecords else {
throw CmxTailscaleStatusPeerResolutionError.malformedStatus
}
candidates.append(contentsOf: peers.values.compactMap { value in
guard let object = value as? [String: Any] else { return nil }
return (object, false)
})
} else if root["Peer"] != nil, !(root["Peer"] is NSNull) {
throw CmxTailscaleStatusPeerResolutionError.malformedStatus
}
let matches = candidates.filter { candidate in
guard let dnsName = candidate.object["DNSName"] as? String else { return false }
return normalizedDNSName(dnsName) == requestedName
}
guard !matches.isEmpty else {
throw CmxTailscaleStatusPeerResolutionError.peerNotFound
}
guard matches.count == 1, let match = matches.first else {
throw CmxTailscaleStatusPeerResolutionError.ambiguousPeer
}
guard allowLocalDevice || !match.isLocalDevice else {
throw CmxTailscaleStatusPeerResolutionError.localDeviceNotAllowed
}
guard let rawAddresses = match.object["TailscaleIPs"] as? [Any],
!rawAddresses.isEmpty else {
throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses
}
var addresses = Set<CmxTailscalePeerAddress>()
for rawAddress in rawAddresses {
guard let value = rawAddress as? String,
let address = CmxTailscalePeerAddress(value) else {
throw CmxTailscaleStatusPeerResolutionError.invalidPeerAddress
}
addresses.insert(address)
}
guard !addresses.isEmpty else {
throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses
}
let orderedAddresses = addresses.sorted(by: Self.addressPrecedes)
guard let preferredAddress = orderedAddresses.first else {
throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses
}
return CmxTailscalePeerRecord(
stableID: (match.object["ID"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
dnsName: requestedName,
addresses: orderedAddresses,
preferredAddress: preferredAddress,
isLocalDevice: match.isLocalDevice
)
}
private func normalizedMagicDNSName(_ rawName: String) -> String? {
guard let name = normalizedDNSName(rawName), name.hasSuffix(".ts.net") else {
return nil
}
let labels = name.split(separator: ".", omittingEmptySubsequences: false)
guard labels.count >= 3, name.count <= 253 else { return nil }
for label in labels {
guard !label.isEmpty,
label.count <= 63,
label.first != "-",
label.last != "-",
label.utf8.allSatisfy({ byte in
(byte >= 0x61 && byte <= 0x7A) ||
(byte >= 0x30 && byte <= 0x39) ||
byte == 0x2D
}) else {
return nil
}
}
return name
}
private func normalizedDNSName(_ rawName: String) -> String? {
let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let name = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
guard !name.isEmpty, !name.hasSuffix(".") else { return nil }
return name
}
private static func addressPrecedes(
_ lhs: CmxTailscalePeerAddress,
_ rhs: CmxTailscalePeerAddress
) -> Bool {
if lhs.family != rhs.family {
return lhs.family == .ipv4
}
return lhs.bytes.lexicographicallyPrecedes(rhs.bytes)
}
}
@@ -1,8 +1,15 @@
import Foundation
/// The address shape used to reach an attach route.
public enum CmxAttachEndpoint: Equatable, Sendable {
/// The maximum number of reachability hints accepted for one Iroh peer.
public static let maximumIrohPathHintCount = 16
/// A direct host and TCP port.
case hostPort(host: String, port: Int)
case peer(id: String, relayHint: String?, directAddrs: [String], relayURL: String?)
/// An authenticated Iroh identity plus untrusted reachability hints.
case peer(identity: CmxIrohPeerIdentity, pathHints: [CmxIrohPathHint])
/// A URL-based transport endpoint.
case url(String)
}
@@ -15,6 +22,7 @@ extension CmxAttachEndpoint: Codable {
case relayHint = "relay_hint"
case directAddrs = "direct_addrs"
case relayURL = "relay_url"
case pathHints = "path_hints"
case url
}
@@ -24,6 +32,7 @@ extension CmxAttachEndpoint: Codable {
case url
}
/// Decodes and validates an attach endpoint.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(EndpointType.self, forKey: .type)
@@ -34,17 +43,31 @@ extension CmxAttachEndpoint: Codable {
port: container.decode(Int.self, forKey: .port)
)
case .peer:
self = try .peer(
id: container.decode(String.self, forKey: .id),
relayHint: container.decodeIfPresent(String.self, forKey: .relayHint),
directAddrs: container.decodeIfPresent([String].self, forKey: .directAddrs) ?? [],
relayURL: container.decodeIfPresent(String.self, forKey: .relayURL)
let identity = try CmxIrohPeerIdentity(
endpointID: try container.decode(String.self, forKey: .id)
)
if let pathHints = try container.decodeIfPresent(
[CmxIrohPathHint].self,
forKey: .pathHints
) {
self = .peer(identity: identity, pathHints: pathHints)
} else {
self = try .peer(
id: identity.endpointID,
relayHint: try container.decodeIfPresent(String.self, forKey: .relayHint),
directAddrs: try container.decodeIfPresent(
[String].self,
forKey: .directAddrs
) ?? [],
relayURL: try container.decodeIfPresent(String.self, forKey: .relayURL)
)
}
case .url:
self = try .url(container.decode(String.self, forKey: .url))
}
}
/// Encodes the endpoint while omitting unsafe legacy Iroh hint forms.
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
@@ -52,9 +75,35 @@ extension CmxAttachEndpoint: Codable {
try container.encode(EndpointType.hostPort, forKey: .type)
try container.encode(host, forKey: .host)
try container.encode(port, forKey: .port)
case let .peer(id, relayHint, directAddrs, relayURL):
case let .peer(identity, pathHints):
try container.encode(EndpointType.peer, forKey: .type)
try container.encode(id, forKey: .id)
try container.encode(identity.endpointID, forKey: .id)
// Encoding is deterministic: wall-clock freshness is applied by
// the caller's disclosure/persistence boundary. Structurally
// unsafe inert legacy values are never re-emitted.
let wireSafePathHints = pathHints.filter(\.isSafeForCurrentWireFormat)
if !wireSafePathHints.isEmpty {
try container.encode(wireSafePathHints, forKey: .pathHints)
}
// Legacy fields cannot represent observation or expiry metadata.
// Downgrade only timeless safe hints, otherwise an expired/future
// path would be promoted indefinitely for an older consumer.
let legacySafePathHints = wireSafePathHints.filter {
$0.observedAt == nil && $0.expiresAt == nil
}
let relayHint = legacySafePathHints.first {
$0.kind == .relayIdentifier
}?.value
// Legacy `direct_addrs` cannot carry expiry, privacy, or network
// profile. Emitting private fallbacks there would silently promote
// them for old clients, so only public primary addresses downgrade.
let directAddrs = legacySafePathHints
.filter { $0.kind == .directAddress && $0.use == .primary }
.map(\.value)
let relayURL = legacySafePathHints.first {
$0.kind == .relayURL
}?.value
try container.encodeIfPresent(relayHint, forKey: .relayHint)
if !directAddrs.isEmpty {
try container.encode(directAddrs, forKey: .directAddrs)
@@ -67,12 +116,21 @@ extension CmxAttachEndpoint: Codable {
}
}
/// Validation failures for attach-route endpoints.
public enum CmxAttachRouteError: Error, Equatable, Sendable {
/// A host/port route has an empty host.
case emptyHost
/// An Iroh peer route has an empty peer identity.
case emptyPeerID
/// An Iroh direct-address hint is empty.
case emptyPeerAddress
/// A URL or relay hint is empty.
case emptyURL
/// A host/port route uses a port outside the valid TCP range.
case invalidPort(Int)
/// A peer route exceeded ``CmxAttachEndpoint/maximumIrohPathHintCount``.
case tooManyPeerPathHints(actual: Int, maximum: Int)
/// The endpoint shape does not match its declared transport kind.
case endpointMismatch(kind: CmxAttachTransportKind, endpoint: CmxAttachEndpoint)
}
@@ -112,6 +170,7 @@ public struct CmxAttachRoute: Codable, Equatable, Sendable {
try validate()
}
/// Validates that the endpoint shape and route kind agree.
public func validate() throws {
switch endpoint {
case let .hostPort(host, port):
@@ -121,18 +180,26 @@ public struct CmxAttachRoute: Codable, Equatable, Sendable {
guard (1...65535).contains(port) else {
throw CmxAttachRouteError.invalidPort(port)
}
case let .peer(id, _, directAddrs, relayURL):
guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
case let .peer(identity, pathHints):
guard !identity.endpointID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CmxAttachRouteError.emptyPeerID
}
for address in directAddrs {
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CmxAttachRouteError.emptyPeerAddress
}
guard pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount else {
throw CmxAttachRouteError.tooManyPeerPathHints(
actual: pathHints.count,
maximum: CmxAttachEndpoint.maximumIrohPathHintCount
)
}
if let relayURL {
guard !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CmxAttachRouteError.emptyURL
for pathHint in pathHints {
do {
try pathHint.validate()
} catch CmxIrohPathHintError.emptyValue {
switch pathHint.kind {
case .directAddress:
throw CmxAttachRouteError.emptyPeerAddress
case .relayIdentifier, .relayURL:
throw CmxAttachRouteError.emptyURL
}
}
}
case let .url(url):
@@ -148,6 +215,7 @@ public struct CmxAttachRoute: Codable, Equatable, Sendable {
throw CmxAttachRouteError.endpointMismatch(kind: kind, endpoint: endpoint)
}
}
}
public enum CmxAttachTicketError: Error, Equatable, Sendable {
@@ -342,8 +410,26 @@ public protocol CmxByteTransport: Sendable {
func close() async
}
/// Independently framed server-event bytes delivered outside the RPC control stream.
public typealias CmxIndependentEventByteStream = AsyncThrowingStream<Data, any Error>
/// Opens the one bounded event byte stream associated with an exact transport intent.
public typealias CmxIndependentEventByteStreamProvider = @Sendable (
CmxByteTransportRequest
) async throws -> CmxIndependentEventByteStream
public protocol CmxByteTransportFactory: Sendable {
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport
func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport
}
extension CmxByteTransportFactory {
/// Compatibility path for transports whose peer intent is fully represented by the route.
public func makeTransport(
for request: CmxByteTransportRequest
) throws -> any CmxByteTransport {
try makeTransport(for: request.route)
}
}
public protocol CmxRouteAwareByteTransportFactory: CmxByteTransportFactory {
@@ -391,4 +477,13 @@ public struct CmxRouteTransportFactory: CmxRouteAwareByteTransportFactory {
}
return try factory.makeTransport(for: route)
}
public func makeTransport(
for request: CmxByteTransportRequest
) throws -> any CmxByteTransport {
guard let factory = factories[request.route.kind] else {
throw CmxRouteTransportFactoryError.unsupportedRouteKind(request.route.kind)
}
return try factory.makeTransport(for: request)
}
}
@@ -15,6 +15,7 @@ struct CompactAttachEndpoint: Codable {
let rh: String?
let da: [String]?
let ru: String?
let ph: [CmxIrohPathHint]?
let u: String?
init(_ endpoint: CmxAttachEndpoint) {
@@ -27,14 +28,19 @@ struct CompactAttachEndpoint: Codable {
rh = nil
da = nil
ru = nil
ph = nil
u = nil
case let .peer(id, relayHint, directAddrs, relayURL):
case let .peer(identity, _):
h = nil
p = nil
i = id
rh = relayHint
da = directAddrs.isEmpty ? nil : directAddrs
ru = relayURL
i = identity.endpointID
// A scannable payload discloses Iroh identity only. Managed relays
// are app configuration, online discovery is authenticated, and
// first-time offline pairing resolves this EndpointID locally.
rh = nil
da = nil
ru = nil
ph = nil
u = nil
case let .url(url):
h = nil
@@ -43,6 +49,7 @@ struct CompactAttachEndpoint: Codable {
rh = nil
da = nil
ru = nil
ph = nil
u = url
}
}
@@ -58,7 +65,13 @@ struct CompactAttachEndpoint: Codable {
guard let i else {
throw Self.corruptedEndpoint("peer endpoint requires i")
}
return .peer(id: i, relayHint: rh, directAddrs: da ?? [], relayURL: ru)
if let ph {
return .peer(
identity: try CmxIrohPeerIdentity(endpointID: i),
pathHints: ph
)
}
return try .peer(id: i, relayHint: rh, directAddrs: da ?? [], relayURL: ru)
case "url":
guard let u else {
throw Self.corruptedEndpoint("url endpoint requires u")
@@ -7,6 +7,8 @@ import Foundation
/// grammar revision that still carry `e` (expiry) and `n` (display name)
/// decode here with both intentionally dropped: a pairing QR never expires,
/// and the Mac's name is read post-handshake from `mobile.host.status`.
/// New Iroh pairing payloads disclose only EndpointID identity. The explicit
/// compatibility mode temporarily retains released clients' legacy routes.
struct CompactAttachTicket: Codable {
let v: Int
let w: String?
@@ -18,16 +20,25 @@ struct CompactAttachTicket: Codable {
let ab: String?
let r: [CompactAttachRoute]
init(_ ticket: CmxAttachTicket) {
init(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) throws {
let disclosedRoutes = ticket.routes.disclosed(for: routeDisclosureMode)
guard !disclosedRoutes.isEmpty else {
throw CmxAttachTicketCompactCoderError.noRoutesForDisclosureMode(
routeDisclosureMode
)
}
v = ticket.version
w = Self.normalizedNonEmpty(ticket.workspaceID)
t = Self.normalizedNonEmpty(ticket.terminalID)
w = ticket.workspaceID.isEmpty ? nil : ticket.workspaceID
t = ticket.terminalID.flatMap { $0.isEmpty ? nil : $0 }
d = ticket.macDeviceID
u = Self.normalizedNonEmpty(ticket.macUserID)
u = ticket.macUserID.flatMap { $0.isEmpty ? nil : $0 }
pc = ticket.macPairingCompatibilityVersion
av = Self.normalizedNonEmpty(ticket.macAppVersion)
ab = Self.normalizedNonEmpty(ticket.macAppBuild)
r = Self.compactedRoutes(ticket.routes)
av = ticket.macAppVersion.flatMap { $0.isEmpty ? nil : $0 }
ab = ticket.macAppBuild.flatMap { $0.isEmpty ? nil : $0 }
r = disclosedRoutes.compacted()
}
func ticket() throws -> CmxAttachTicket {
@@ -42,55 +53,59 @@ struct CompactAttachTicket: Codable {
macPairingCompatibilityVersion: pc ?? 0,
macAppVersion: av,
macAppBuild: ab,
routes: Self.expandedRoutes(r),
routes: try r.expanded(),
expiresAt: nil
)
}
private static func normalizedNonEmpty(_ value: String?) -> String? {
guard let value, !value.isEmpty else {
return nil
}
return value
}
}
private extension CompactAttachTicket {
/// Encode routes, omitting each route id the decoder can resynthesize
/// (`kind` for the first route of a kind, `kind_N` for the Nth; exactly
/// the ids the Mac's route resolver mints). Ids that differ are kept, so
/// the mapping is lossless for every ticket.
static func compactedRoutes(_ routes: [CmxAttachRoute]) -> [CompactAttachRoute] {
private extension Array where Element == CmxAttachRoute {
func disclosed(for mode: CmxPairingRouteDisclosureMode) -> Self {
switch mode {
case .irohIdentityOnly:
return compactMap { route in
guard route.kind == .iroh,
case let .peer(identity, _) = route.endpoint else {
return nil
}
return try? CmxAttachRoute(
id: route.id,
kind: route.kind,
endpoint: .peer(identity: identity, pathHints: []),
priority: route.priority
)
}
case .legacyPrivateNetworkCompatibility:
return self
}
}
/// Encode routes, omitting each route id the decoder can resynthesize.
func compacted() -> [CompactAttachRoute] {
var kindCounts: [CmxAttachTransportKind: Int] = [:]
return routes.map { route in
return map { route in
let occurrence = (kindCounts[route.kind] ?? 0) + 1
kindCounts[route.kind] = occurrence
let synthesized = synthesizedRouteID(kind: route.kind, occurrence: occurrence)
let synthesized = occurrence == 1
? route.kind.rawValue
: "\(route.kind.rawValue)_\(occurrence)"
return CompactAttachRoute(route, omittingID: route.id == synthesized)
}
}
}
/// Decode routes, resynthesizing each omitted route id with the same
/// `kind` / `kind_N` rule the encoder applied.
static func expandedRoutes(_ compactRoutes: [CompactAttachRoute]) throws -> [CmxAttachRoute] {
private extension Array where Element == CompactAttachRoute {
/// Decode routes, resynthesizing each omitted route id.
func expanded() throws -> [CmxAttachRoute] {
var kindCounts: [CmxAttachTransportKind: Int] = [:]
return try compactRoutes.map { compactRoute in
return try map { compactRoute in
let kind = try compactRoute.kind()
let occurrence = (kindCounts[kind] ?? 0) + 1
kindCounts[kind] = occurrence
return try compactRoute.route(
synthesizedID: synthesizedRouteID(kind: kind, occurrence: occurrence)
)
let synthesized = occurrence == 1
? kind.rawValue
: "\(kind.rawValue)_\(occurrence)"
return try compactRoute.route(synthesizedID: synthesized)
}
}
/// The route id the Mac's route resolver mints for the `occurrence`-th
/// route of `kind` (`kind` for the first, `kind_N` after).
static func synthesizedRouteID(
kind: CmxAttachTransportKind,
occurrence: Int
) -> String {
occurrence == 1 ? kind.rawValue : "\(kind.rawValue)_\(occurrence)"
}
}
@@ -0,0 +1,56 @@
/// Admission policy for decoded mobile RPC frames awaiting a response.
///
/// The host evaluates this policy and inserts the admitted task within the same
/// actor turn. This bounds both request-handler tasks and waiters on the shared
/// serialized response writer for every byte transport.
public struct MobileHostRPCWorkQuota: Sendable {
/// Keeps useful request concurrency while bounding per-connection tasks.
public static let recommendedMaximumConcurrentRequestCount = 16
/// One connection may retain at most one protocol-sized frame of decoded
/// request data across all in-flight handlers.
public static let recommendedMaximumAggregateFrameByteCount =
MobileSyncFrameCodec.defaultMaximumFrameByteCount
public let maximumConcurrentRequestCount: Int
public let maximumAggregateFrameByteCount: Int
public init(
maximumConcurrentRequestCount: Int = Self
.recommendedMaximumConcurrentRequestCount,
maximumAggregateFrameByteCount: Int = Self
.recommendedMaximumAggregateFrameByteCount
) {
precondition(maximumConcurrentRequestCount > 0)
precondition(maximumAggregateFrameByteCount > 0)
self.maximumConcurrentRequestCount = maximumConcurrentRequestCount
self.maximumAggregateFrameByteCount = maximumAggregateFrameByteCount
}
/// Returns whether one more decoded frame fits both request budgets.
///
/// Subtraction from the remaining budget avoids overflowing when evaluating
/// malformed or defensive caller-provided counts.
public func allowsAdmission<ActiveFrameByteCounts: Sequence>(
frameByteCount: Int,
activeFrameByteCounts: ActiveFrameByteCounts
) -> Bool where ActiveFrameByteCounts.Element == Int {
guard frameByteCount >= 0,
frameByteCount <= maximumAggregateFrameByteCount else {
return false
}
var activeRequestCount = 0
var remainingByteCount = maximumAggregateFrameByteCount - frameByteCount
for activeFrameByteCount in activeFrameByteCounts {
activeRequestCount += 1
guard activeRequestCount < maximumConcurrentRequestCount,
activeFrameByteCount >= 0,
activeFrameByteCount <= remainingByteCount else {
return false
}
remainingByteCount -= activeFrameByteCount
}
return true
}
}
@@ -177,6 +177,7 @@ public struct MobileSyncPairingPayload: Equatable, Sendable, Codable {
public enum MobileSyncFrameCodecError: Error, Equatable, Sendable {
case frameTooLarge(Int)
case tooManyFrames(Int)
}
/// Length-prefixed frame codec for the mobile sync wire protocol.
@@ -185,6 +186,7 @@ public struct MobileSyncFrameCodec {
public static let headerByteCount = 4
public static let defaultMaximumFrameByteCount = 8 * 1024 * 1024
public static let defaultMaximumDecodedFrameCount = 256
public static func encodeFrame(_ payload: Data) throws -> Data {
guard payload.count <= defaultMaximumFrameByteCount else {
@@ -198,24 +200,56 @@ public struct MobileSyncFrameCodec {
public static func decodeFrames(
from buffer: inout Data,
maximumFrameByteCount: Int = defaultMaximumFrameByteCount
maximumFrameByteCount: Int = defaultMaximumFrameByteCount,
maximumDecodedFrameCount: Int = defaultMaximumDecodedFrameCount
) throws -> [Data] {
precondition(maximumFrameByteCount >= 0)
precondition(maximumDecodedFrameCount > 0)
var frames: [Data] = []
while buffer.count >= headerByteCount {
let length = buffer.prefix(headerByteCount).reduce(UInt32(0)) { partial, byte in
frames.reserveCapacity(min(maximumDecodedFrameCount, 16))
var consumedByteCount = 0
defer {
if consumedByteCount > 0 {
buffer.removeSubrange(
buffer.startIndex..<buffer.index(
buffer.startIndex,
offsetBy: consumedByteCount
)
)
}
}
while buffer.count - consumedByteCount >= headerByteCount {
let frameStart = buffer.index(
buffer.startIndex,
offsetBy: consumedByteCount
)
let headerEnd = buffer.index(
frameStart,
offsetBy: headerByteCount
)
let length = buffer[frameStart..<headerEnd].reduce(UInt32(0)) { partial, byte in
(partial << 8) | UInt32(byte)
}
let payloadLength = Int(length)
guard payloadLength <= maximumFrameByteCount else {
throw MobileSyncFrameCodecError.frameTooLarge(payloadLength)
}
guard buffer.count >= headerByteCount + payloadLength else {
guard buffer.count - consumedByteCount >= headerByteCount + payloadLength else {
break
}
let payloadStart = headerByteCount
let payloadEnd = payloadStart + payloadLength
guard frames.count < maximumDecodedFrameCount else {
throw MobileSyncFrameCodecError.tooManyFrames(
maximumDecodedFrameCount
)
}
let payloadStart = headerEnd
let payloadEnd = buffer.index(
payloadStart,
offsetBy: payloadLength
)
frames.append(buffer.subdata(in: payloadStart..<payloadEnd))
buffer.removeSubrange(0..<payloadEnd)
consumedByteCount += headerByteCount + payloadLength
}
return frames
}
@@ -15,6 +15,14 @@ import Testing
/// ticket.
private let compactCoder = CmxAttachTicketCompactCoder()
private let compactCanonicalEndpointID = String(repeating: "c", count: 64)
private func encodeLegacyCompatibility(_ ticket: CmxAttachTicket) throws -> Data {
try compactCoder.encode(
ticket,
routeDisclosureMode: .legacyPrivateNetworkCompatibility
)
}
private func wholeSecondFutureExpiry() -> Date {
Date(timeIntervalSince1970: 4_000_000_000)
@@ -51,7 +59,7 @@ private func legacyDecoder() -> JSONDecoder {
authToken: "ticket-secret"
)
let data = try compactCoder.encode(ticket)
let data = try encodeLegacyCompatibility(ticket)
let json = try #require(String(data: data, encoding: .utf8))
#expect(!json.contains("auth_token"))
@@ -86,10 +94,33 @@ private func legacyDecoder() -> JSONDecoder {
id: "iroh",
kind: .iroh,
endpoint: .peer(
id: "peer-1",
relayHint: "use1",
directAddrs: ["192.168.1.4:4242"],
relayURL: "https://relay.example"
identity: try CmxIrohPeerIdentity(endpointID: compactCanonicalEndpointID),
pathHints: [
try CmxIrohPathHint(
kind: .relayIdentifier,
value: "use1",
source: .native,
privacyScope: .publicInternet
),
try CmxIrohPathHint(
kind: .directAddress,
value: "192.168.1.4:4242",
source: .lan,
privacyScope: .localNetwork,
observedAt: wholeSecondFutureExpiry().addingTimeInterval(-60),
expiresAt: wholeSecondFutureExpiry(),
networkProfile: CmxIrohNetworkProfileKey(
source: .lan,
profileID: String(repeating: "b", count: 64)
)
),
try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example",
source: .native,
privacyScope: .publicInternet
),
]
),
priority: 1
),
@@ -114,9 +145,19 @@ private func legacyDecoder() -> JSONDecoder {
authToken: "ticket-secret"
)
let decoded = try compactCoder.decode(
compactCoder.encode(ticket)
)
let encoded = try encodeLegacyCompatibility(ticket)
let json = try #require(String(data: encoded, encoding: .utf8))
#expect(json.contains(compactCanonicalEndpointID))
#expect(!json.contains("\"ph\""))
#expect(!json.contains("\"rh\""))
#expect(!json.contains("\"ru\""))
#expect(!json.contains("\"da\""))
#expect(!json.contains("192.168.1.4"))
#expect(!json.contains("network_profile"))
#expect(!json.contains("relay.example"))
#expect(!json.contains("use1"))
let decoded = try compactCoder.decode(encoded)
#expect(decoded.version == ticket.version)
#expect(decoded.workspaceID == ticket.workspaceID)
@@ -127,9 +168,15 @@ private func legacyDecoder() -> JSONDecoder {
#expect(decoded.macPairingCompatibilityVersion == ticket.macPairingCompatibilityVersion)
#expect(decoded.macAppVersion == ticket.macAppVersion)
#expect(decoded.macAppBuild == ticket.macAppBuild)
// Routes round-trip losslessly even with custom ids ("ws" differs from
// the synthesized "websocket", so it is carried verbatim).
#expect(decoded.routes == ticket.routes)
#expect(decoded.routes.map(\.id) == ticket.routes.map(\.id))
guard case let .peer(decodedIdentity, decodedHints) = decoded.routes[1].endpoint else {
Issue.record("Expected compact Iroh peer route")
return
}
#expect(decodedIdentity.endpointID == compactCanonicalEndpointID)
#expect(decodedHints.isEmpty)
#expect(decoded.routes[0] == ticket.routes[0])
#expect(decoded.routes[2] == ticket.routes[2])
// Dropped by design: the auth token never authorizes anything, the name
// arrives via `mobile.host.status`, and a pairing QR never expires.
#expect(decoded.authToken == nil)
@@ -162,7 +209,7 @@ private func legacyDecoder() -> JSONDecoder {
authToken: "ticket-secret"
)
let data = try compactCoder.encode(ticket)
let data = try encodeLegacyCompatibility(ticket)
let object = try #require(
try JSONSerialization.jsonObject(with: data) as? [String: Any]
)
@@ -213,7 +260,7 @@ private func legacyDecoder() -> JSONDecoder {
routes: routes
)
let data = try compactCoder.encode(ticket)
let data = try encodeLegacyCompatibility(ticket)
let object = try #require(
try JSONSerialization.jsonObject(with: data) as? [String: Any]
)
@@ -241,7 +288,23 @@ private func legacyDecoder() -> JSONDecoder {
#expect(decoded.macDisplayName == nil)
#expect(decoded.expiresAt == nil)
#expect(!decoded.isExpired(at: .distantFuture))
#expect(decoded.routes == [try hostPortRoute()])
let expectedRoutes = [try hostPortRoute()]
#expect(decoded.routes == expectedRoutes)
}
@Test func compactDecodeKeepsFirstRevisionIrohHintFieldsReadable() throws {
let firstRevision = Data("""
{"d":"mac-1","r":[{"e":{"da":["8.8.8.8:4242"],"i":"\(compactCanonicalEndpointID)","rh":"use1","ru":"https://relay.example/","t":"peer"},"i":"iroh","k":"iroh"}],"v":1}
""".utf8)
let decoded = try compactCoder.decode(firstRevision)
guard case let .peer(identity, pathHints) = decoded.routes.first?.endpoint else {
Issue.record("Expected legacy compact Iroh peer route")
return
}
#expect(identity.endpointID == compactCanonicalEndpointID)
#expect(pathHints.map(\.kind) == [.relayIdentifier, .directAddress, .relayURL])
#expect(pathHints.first { $0.kind == .directAddress }?.isUsable(at: .distantPast) == false)
}
@Test func legacyDecoderRejectsCompactPayloadLoudly() throws {
@@ -255,7 +318,7 @@ private func legacyDecoder() -> JSONDecoder {
routes: [try hostPortRoute()],
expiresAt: wholeSecondFutureExpiry()
)
let compact = try compactCoder.encode(ticket)
let compact = try encodeLegacyCompatibility(ticket)
#expect(throws: DecodingError.self) {
try legacyDecoder().decode(CmxAttachTicket.self, from: compact)
@@ -293,7 +356,7 @@ private func legacyDecoder() -> JSONDecoder {
routes: [try hostPortRoute()],
expiresAt: wholeSecondFutureExpiry()
)
let compact = try compactCoder.encode(ticket)
let compact = try encodeLegacyCompatibility(ticket)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let legacy = try encoder.encode(ticket)
@@ -355,7 +418,7 @@ private func legacyDecoder() -> JSONDecoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let legacy = try encoder.encode(ticket)
let compact = try compactCoder.encode(ticket)
let compact = try encodeLegacyCompatibility(ticket)
#expect(compact.count < legacy.count)
#expect(compact.count <= 150)
@@ -0,0 +1,75 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Test func authenticatedTicketDisclosurePreservesFieldsAndFiltersRoutes() throws {
let now = Date(timeIntervalSince1970: 2_000_000_000)
let currentHint = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .publicInternet
)
let expiredHint = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: now.addingTimeInterval(-120),
expiresAt: now.addingTimeInterval(-60),
networkProfile: CmxIrohNetworkProfileKey(
source: .tailscale,
profileID: String(repeating: "a", count: 64)
)
)
let route = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "a", count: 64)
),
pathHints: [expiredHint, currentHint]
),
priority: 7
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace",
terminalID: "terminal",
macDeviceID: "mac-device",
macDisplayName: "Mac",
macUserEmail: "[email protected]",
macUserID: "user-id",
macPairingCompatibilityVersion: 4,
macAppVersion: "1.2.3",
macAppBuild: "456",
routes: [route],
expiresAt: now.addingTimeInterval(300),
authToken: "attach-token"
)
let disclosed = try ticket.authenticatedDisclosure(at: now)
let disclosedRoute = try #require(route.disclosed(for: .authenticated, at: now))
let expected = try CmxAttachTicket(
version: ticket.version,
workspaceID: ticket.workspaceID,
terminalID: ticket.terminalID,
macDeviceID: ticket.macDeviceID,
macDisplayName: ticket.macDisplayName,
macUserEmail: ticket.macUserEmail,
macUserID: ticket.macUserID,
macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion,
macAppVersion: ticket.macAppVersion,
macAppBuild: ticket.macAppBuild,
routes: [disclosedRoute],
expiresAt: ticket.expiresAt,
authToken: ticket.authToken
)
#expect(disclosed == expected)
guard case let .peer(_, pathHints) = disclosed.routes[0].endpoint else {
Issue.record("Expected an Iroh peer route")
return
}
#expect(pathHints == [currentHint])
}
@@ -0,0 +1,109 @@
import Foundation
import Testing
@testable import CMUXMobileCore
private let compactIrohQRCoder = CmxAttachTicketCompactCoder()
private let compactIrohQREndpointID = String(repeating: "c", count: 64)
private func compactIrohQRExpiry() -> Date {
Date(timeIntervalSince1970: 4_000_000_000)
}
private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.1.2", port: 49831)
)
}
@Test func identityOnlyQRModeKeepsOnlyIrohIdentityAndRejectsTicketsWithoutIt() throws {
let privateAddress = "100.64.1.2:49152"
let relayURL = "https://relay.attacker.example/"
let websocketURL = "wss://private.example/connect?token=secret"
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: compactIrohQREndpointID),
pathHints: [
CmxIrohPathHint(
kind: .directAddress,
value: privateAddress,
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: compactIrohQRExpiry().addingTimeInterval(-60),
expiresAt: compactIrohQRExpiry(),
networkProfile: CmxIrohNetworkProfileKey(
source: .tailscale,
profileID: String(repeating: "a", count: 64)
)
),
CmxIrohPathHint(
kind: .relayURL,
value: relayURL,
source: .native,
privacyScope: .publicInternet
),
]
)
)
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "mac-1",
macDisplayName: nil,
routes: [
compactIrohQRHostPortRoute(),
iroh,
CmxAttachRoute(
id: "websocket",
kind: .websocket,
endpoint: .url(websocketURL)
),
]
)
let encoded = try compactIrohQRCoder.encode(
ticket,
routeDisclosureMode: .irohIdentityOnly
)
let json = try #require(String(data: encoded, encoding: .utf8))
#expect(json.contains(compactIrohQREndpointID))
#expect(!json.contains(privateAddress))
#expect(!json.contains(relayURL))
#expect(!json.contains(websocketURL))
#expect(!json.contains("\"h\""))
#expect(!json.contains("\"u\":\"wss"))
#expect(!json.contains("\"ph\""))
let decoded = try compactIrohQRCoder.decode(encoded)
#expect(decoded.routes.count == 1)
#expect(decoded.routes.first?.id == iroh.id)
guard case let .peer(identity, hints) = decoded.routes.first?.endpoint else {
Issue.record("Expected identity-only Iroh route")
return
}
#expect(identity.endpointID == compactIrohQREndpointID)
#expect(hints.isEmpty)
#expect(CmxPairingQRCode().encode(
ticket,
routeDisclosureMode: .irohIdentityOnly
) == nil)
let tailscaleOnly = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "mac-1",
macDisplayName: nil,
routes: [compactIrohQRHostPortRoute()]
)
#expect(throws: CmxAttachTicketCompactCoderError.noRoutesForDisclosureMode(
.irohIdentityOnly
)) {
_ = try compactIrohQRCoder.encode(
tailscaleOnly,
routeDisclosureMode: .irohIdentityOnly
)
}
}
@@ -0,0 +1,109 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct CmxCredentialedHTTPSessionTests {
@Test func rejects307CredentialHeadersAndBody() throws {
let source = try #require(URL(string: "https://cmux.example/api/devices"))
let destination = try #require(URL(string: "https://attacker.example/capture"))
var redirected = URLRequest(url: destination)
redirected.httpMethod = "POST"
redirected.setValue("Bearer access", forHTTPHeaderField: "Authorization")
redirected.setValue("refresh-secret", forHTTPHeaderField: "X-Stack-Refresh-Token")
redirected.httpBody = Data(#"{"secret":"body-secret"}"#.utf8)
let response = try #require(HTTPURLResponse(
url: source,
statusCode: 307,
httpVersion: nil,
headerFields: ["Location": destination.absoluteString]
))
let session = URLSession(configuration: .ephemeral)
let task = session.dataTask(with: source)
var completionCalled = false
var forwardedRequest: URLRequest? = redirected
CmxCredentialedHTTPRedirectDelegate().urlSession(
session,
task: task,
willPerformHTTPRedirection: response,
newRequest: redirected
) { request in
completionCalled = true
forwardedRequest = request
}
#expect(completionCalled)
#expect(forwardedRequest == nil)
}
@Test func rejectsDeclaredOversizedResponseBeforeBufferingIt() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [OversizedCredentialedHTTPURLProtocol.self]
let session = CmxCredentialedHTTPSession(configuration: configuration)
let url = try #require(URL(string: "https://cmux.example/api/devices"))
await #expect(throws: CmxCredentialedHTTPSessionError.responseTooLarge) {
_ = try await session.data(for: URLRequest(url: url))
}
}
@Test func rejectsOversizedResponseWithoutDeclaredLength() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [UndeclaredOversizedCredentialedHTTPURLProtocol.self]
let session = CmxCredentialedHTTPSession(
configuration: configuration,
maximumResponseByteCount: 8
)
let url = try #require(URL(string: "https://cmux.example/api/devices"))
await #expect(throws: CmxCredentialedHTTPSessionError.responseTooLarge) {
_ = try await session.data(for: URLRequest(url: url))
}
}
}
private final class OversizedCredentialedHTTPURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with _: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Length": "4194305"]
) else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("must-not-buffer".utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
private final class UndeclaredOversizedCredentialedHTTPURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with _: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: [:]
) else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("ninebytes".utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@@ -0,0 +1,468 @@
import Foundation
import Testing
@testable import CMUXMobileCore
private let canonicalEndpointID = String(repeating: "a", count: 64)
private let canonicalNetworkProfileID = String(repeating: "b", count: 64)
private func profile(
_ source: CmxIrohPathHintSource,
_ profileID: String = "default"
) throws -> CmxIrohNetworkProfileKey {
let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined()
let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64))
return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID)
}
@Test func irohEndpointIDRequiresCanonicalLowercaseHex() throws {
let identity = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID)
#expect(identity.endpointID == canonicalEndpointID)
for invalid in [
"",
String(repeating: "a", count: 63),
String(repeating: "a", count: 65),
String(repeating: "A", count: 64),
String(repeating: "g", count: 64),
] {
#expect(throws: CmxIrohPeerIdentityError.nonCanonicalEndpointID) {
_ = try CmxIrohPeerIdentity(endpointID: invalid)
}
}
}
@Test func networkProfileIDRequiresOpaqueCanonicalLowercaseHex() throws {
#expect(
(try CmxIrohNetworkProfileKey(
source: .tailscale,
profileID: canonicalNetworkProfileID
)).profileID == canonicalNetworkProfileID
)
for invalid in [
"production",
String(repeating: "a", count: 63),
String(repeating: "a", count: 65),
String(repeating: "A", count: 64),
String(repeating: "g", count: 64),
] {
#expect(throws: CmxIrohNetworkProfileKeyError.invalidProfileID) {
_ = try CmxIrohNetworkProfileKey(source: .tailscale, profileID: invalid)
}
}
}
@Test func nativePathHintsCannotAuthorizePrivateOrLocalNetworks() throws {
let now = Date(timeIntervalSince1970: 2_000_000_000)
for scope in [CmxIrohPathHintPrivacyScope.localNetwork, .privateNetwork] {
#expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope(
source: .native,
scope: scope
)) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .native,
privacyScope: scope,
observedAt: now,
expiresAt: now.addingTimeInterval(60),
networkProfile: try CmxIrohNetworkProfileKey(
source: .native,
profileID: canonicalNetworkProfileID
)
)
}
}
}
@Test func serializedIPv4LinkLocalHintsAreNotDialable() throws {
let now = Date(timeIntervalSince1970: 2_000_000_000)
#expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "169.254.42.7:49152",
source: .lan,
privacyScope: .localNetwork,
observedAt: now,
expiresAt: now.addingTimeInterval(60),
networkProfile: try CmxIrohNetworkProfileKey(
source: .lan,
profileID: canonicalNetworkProfileID
)
)
}
}
@Test func attachTicketChoosesFirstSupportedRouteByPriority() throws {
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
id: canonicalEndpointID,
relayHint: "relay-1",
directAddrs: ["192.168.1.20:3478"],
relayURL: "https://relay.example.test"
),
priority: 0
)
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.1.2", port: 49831),
priority: 1
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace-1",
terminalID: "terminal-1",
macDeviceID: "mac-1",
macDisplayName: "Studio",
routes: [tailscale, iroh],
expiresAt: Date(timeIntervalSince1970: 2_000_000_000)
)
#expect(ticket.preferredRoute(supportedKinds: [.tailscale, .iroh]) == iroh)
#expect(ticket.preferredRoute(supportedKinds: [.websocket]) == nil)
#expect(ticket.preferredRoute(supportedKinds: []) == nil)
}
@Test func irohPeerIdentityIsIndependentFromOrderedProviderPathHints() throws {
let now = Date(timeIntervalSince1970: 2_000_000_000)
let relay = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test",
source: .native,
privacyScope: .publicInternet
)
let expiredLAN = try CmxIrohPathHint(
kind: .directAddress,
value: "192.168.1.20:49152",
source: .lan,
privacyScope: .localNetwork,
observedAt: now.addingTimeInterval(-60),
expiresAt: now.addingTimeInterval(-1),
networkProfile: profile(.lan, "studio")
)
let tailscale = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: now,
expiresAt: now.addingTimeInterval(60),
networkProfile: profile(.tailscale, "production")
)
let customVPN = try CmxIrohPathHint(
kind: .directAddress,
value: "10.10.0.8:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: now,
expiresAt: now.addingTimeInterval(30),
networkProfile: profile(.customVPN, "corp")
)
let endpoint = CmxAttachEndpoint.peer(
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [tailscale, expiredLAN, relay, customVPN]
)
let expectedIdentity = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID)
#expect(endpoint.irohPeerIdentity == expectedIdentity)
#expect(tailscale.use == .fallbackOnly)
#expect(expiredLAN.use == .fallbackOnly)
#expect(customVPN.use == .fallbackOnly)
#expect(relay.use == .primary)
let firstPhaseOnly = try #require(endpoint.irohDialPlan(
at: now,
managedRelayURLs: [relay.value]
))
#expect(firstPhaseOnly.publicPaths == [relay])
#expect(firstPhaseOnly.privateFallbackPaths.isEmpty)
let fullPlan = try #require(endpoint.irohDialPlan(
at: now,
managedRelayURLs: [relay.value],
activeNetworkProfiles: [
profile(.tailscale, "production"),
profile(.customVPN, "corp"),
]
))
#expect(fullPlan.publicPaths == [relay])
#expect(fullPlan.privateFallbackPaths == [tailscale, customVPN])
}
@Test func privateProviderHintsRequireMatchingScopeAndExpiry() throws {
let expiry = Date(timeIntervalSince1970: 2_000_000_000)
#expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope(
source: .tailscale,
scope: .publicInternet
)) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "8.8.8.8:49152",
source: .tailscale,
privacyScope: .publicInternet,
expiresAt: expiry
)
}
#expect(throws: CmxIrohPathHintError.missingPrivateHintObservation) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "192.168.1.20:49152",
source: .lan,
privacyScope: .localNetwork
)
}
#expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope(
source: .native,
scope: .privateNetwork
)) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .native,
privacyScope: .privateNetwork
)
}
#expect(throws: CmxIrohPathHintError.missingPrivateHintExpiry) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
networkProfile: profile(.customVPN)
)
}
#expect(throws: CmxIrohPathHintError.missingPrivateHintNetworkProfile) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry
)
}
#expect(throws: CmxIrohPathHintError.privateHintTTLExceedsMaximum) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-(CmxIrohPathHint.maximumPrivateHintTTL + 1)),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
}
#expect(throws: CmxIrohPathHintError.networkProfileSourceMismatch) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.tailscale)
)
}
}
@Test func irohPeerRouteCapsPathHintsAtSixteen() throws {
let hint = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .publicInternet
)
let maximum = CmxAttachEndpoint.maximumIrohPathHintCount
let endpointID = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID)
_ = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: endpointID,
pathHints: Array(repeating: hint, count: maximum)
)
)
#expect(throws: CmxAttachRouteError.tooManyPeerPathHints(
actual: maximum + 1,
maximum: maximum
)) {
_ = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: endpointID,
pathHints: Array(repeating: hint, count: maximum + 1)
)
)
}
}
@Test func directPathHintsAcceptOnlyCanonicalIPSocketAddresses() throws {
let expiry = Date(timeIntervalSince1970: 2_000_000_000)
let ipv4 = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
let ipv6 = try CmxIrohPathHint(
kind: .directAddress,
value: "[fd7a:115c:a1e0::1]:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.tailscale)
)
#expect(ipv4.value == "10.0.0.4:49152")
#expect(ipv6.value == "[fd7a:115c:a1e0::1]:49152")
for malformed in [
"mac.tailnet.ts.net:49152",
"https://10.0.0.4:49152",
"[email protected]:49152",
"10.0.0.0/24:49152",
"10.0.0.4",
"10.0.0.4:0",
"010.0.0.4:49152",
"[fe80::1%en0]:49152",
] {
#expect(throws: CmxIrohPathHintError.invalidDirectAddress) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: malformed,
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
}
}
}
@Test func directPathHintsRejectNonPeerAndMetadataAddresses() throws {
let expiry = Date(timeIntervalSince1970: 2_000_000_000)
for forbidden in [
"0.0.0.0:49152",
"127.0.0.1:49152",
"224.0.0.1:49152",
"255.255.255.255:49152",
"169.254.169.254:49152",
"[::]:49152",
"[::1]:49152",
"[ff02::1]:49152",
"[fe80::1]:49152",
"[fd00:ec2::254]:49152",
] {
#expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: forbidden,
source: .native,
privacyScope: .localNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.native)
)
}
}
#expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "169.254.42.7:49152",
source: .lan,
privacyScope: .localNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.lan)
)
}
}
@Test func publicDirectPathHintsRequireGloballyRoutableAddresses() throws {
let publicIPv4 = try CmxIrohPathHint(
kind: .directAddress,
value: "8.8.8.8:49152",
source: .native,
privacyScope: .publicInternet
)
let publicIPv6 = try CmxIrohPathHint(
kind: .directAddress,
value: "[2606:4700:4700::1111]:49152",
source: .native,
privacyScope: .publicInternet
)
#expect(publicIPv4.use == .primary)
#expect(publicIPv6.use == .primary)
for nonGlobal in [
"10.0.0.4:49152",
"172.16.0.4:49152",
"192.168.1.4:49152",
"100.64.1.4:49152",
"192.0.2.4:49152",
"198.18.0.4:49152",
"198.51.100.4:49152",
"203.0.113.4:49152",
"[fd7a:115c:a1e0::1]:49152",
"[2001:db8::1]:49152",
"[3fff::1]:49152",
] {
#expect(throws: CmxIrohPathHintError.nonGlobalPublicDirectAddress) {
_ = try CmxIrohPathHint(
kind: .directAddress,
value: nonGlobal,
source: .native,
privacyScope: .publicInternet
)
}
}
let expiry = Date(timeIntervalSince1970: 2_000_000_000)
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "[fd7a:115c:a1e0::1]:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.tailscale)
)
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "192.0.2.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
_ = try CmxIrohPathHint(
kind: .directAddress,
value: "[2001:db8::1]:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN)
)
}
@@ -0,0 +1,61 @@
import Testing
@testable import CMUXMobileCore
@Test func relayPathHintsAcceptOnlyCredentialFreeRootHTTPSURLs() throws {
let valid = try CmxIrohPathHint(
kind: .relayURL,
value: "https://use1-1.relay.lawrence.cmux.iroh.link/",
source: .native,
privacyScope: .publicInternet
)
#expect(valid.use == .primary)
for unsafe in [
"http://relay.example.test/",
"https://user:[email protected]/",
"https://relay.example.test/admin",
"https://relay.example.test/?token=secret",
"https://169.254.169.254/",
"https://169.254.42.7/",
"https://10.0.0.1/",
"https://127.0.0.1/",
"https://[::1]/",
"https://[fd7a:115c:a1e0::1]/",
"https://relay.local/",
"https://0177.0.0.1/",
"https://0x7f.0.0.1/",
"https://127.1/",
"https://localhost./",
"https://relay..example.test/",
"https://-relay.example.test/",
"https://relay.example-.test/",
"https://relay.example.123/",
"relay.example.test",
] {
#expect(throws: CmxIrohPathHintError.unsafeRelayURL) {
_ = try CmxIrohPathHint(
kind: .relayURL,
value: unsafe,
source: .native,
privacyScope: .publicInternet
)
}
}
#expect(throws: CmxIrohPathHintError.relayHintRequiresNativePublicSource) {
_ = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .privateNetwork
)
}
#expect(throws: CmxIrohPathHintError.relayHintRequiresNativePublicSource) {
_ = try CmxIrohPathHint(
kind: .relayIdentifier,
value: "use1",
source: .tailscale,
privacyScope: .privateNetwork
)
}
}
@@ -0,0 +1,124 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite
struct CmxIrohSettingsSnapshotTests {
@Test
func activeRuntimeStatusPreservesOnlyRedactedPathLabels() {
#expect(CmxIrohSettingsSnapshot.RuntimeStatus(
activePath: .direct
) == .direct)
#expect(CmxIrohSettingsSnapshot.RuntimeStatus(
activePath: .privateNetwork
) == .privateNetwork(displayName: ""))
#expect(CmxIrohSettingsSnapshot.RuntimeStatus(
activePath: .managedRelay(provider: "cmux", region: "us-east1")
) == .relayed(provider: "cmux", region: "us-east1"))
#expect(CmxIrohSettingsSnapshot.RuntimeStatus(
activePath: .customRelay(
displayName: "Office Relay",
provider: "My Network",
region: "Office"
)
) == .relayed(provider: "My Network", region: "Office"))
}
@Test func snapshotCopiesMutableInputsIntoAnImmutableValue() {
var managedRelays = [Self.managedRelay(id: "use1")]
var staleRelayIDs: Set<String> = ["retired"]
let snapshot = CmxIrohSettingsSnapshot(
runtimeStatus: .relayed(provider: "cmux", region: "us-east"),
selectedTransportPath: .managedRelay(provider: "cmux", region: "us-east"),
preference: .managed(["use1"]),
managedRelays: managedRelays,
customRelays: [],
policySource: .server,
policySequence: 42,
staleRelayIDs: staleRelayIDs
)
managedRelays.removeAll()
staleRelayIDs.removeAll()
#expect(snapshot.managedRelays.map(\.id) == ["use1"])
#expect(snapshot.staleRelayIDs == ["retired"])
#expect(snapshot.preference == .managed(["use1"]))
#expect(snapshot.selectedTransportPath == .managedRelay(
provider: "cmux",
region: "us-east"
))
}
@Test func customRelayProjectionExposesCredentialStateWithoutSecretMaterial() {
let relay = CmxIrohSettingsSnapshot.CustomRelay(
id: "personal",
displayName: "Personal Relay",
provider: "Self-hosted",
region: "Home",
url: "https://relay.example.test",
authMode: .deviceSecret,
credentialState: .configured
)
let snapshot = CmxIrohSettingsSnapshot(
runtimeStatus: .active,
preference: .custom,
managedRelays: [],
customRelays: [relay],
policySource: .cached
)
#expect(snapshot.customRelays == [relay])
#expect(relay.credentialState == .configured)
#expect(secretBearingLabels(in: snapshot).isEmpty)
}
@Test func managedPreferenceRequiresOneToSixteenSafeRelayIdentifiers() throws {
#expect(throws: CmxIrohRelayPreferenceDraftError.self) {
try CmxIrohRelayPreferenceDraft.managed([]).validated()
}
#expect(throws: CmxIrohRelayPreferenceDraftError.self) {
try CmxIrohRelayPreferenceDraft.managed(Set((0 ... 16).map { "relay-\($0)" })).validated()
}
#expect(throws: CmxIrohRelayPreferenceDraftError.self) {
try CmxIrohRelayPreferenceDraft.managed(["relay/unsafe"]).validated()
}
#expect(try CmxIrohRelayPreferenceDraft.managed(["use1-1", "provider.region_2"]).validated()
== .managed(["use1-1", "provider.region_2"]))
#expect(try CmxIrohRelayPreferenceDraft.automatic.validated() == .automatic)
#expect(try CmxIrohRelayPreferenceDraft.custom.validated() == .custom)
}
private static func managedRelay(id: String) -> CmxIrohSettingsSnapshot.ManagedRelay {
CmxIrohSettingsSnapshot.ManagedRelay(
id: id,
provider: "cmux",
region: "us-east",
url: "https://\(id).relay.example.test",
isSelected: true
)
}
private func secretBearingLabels(in value: Any) -> [String] {
let forbiddenFragments = ["secret", "token", "credentialvalue", "authorization"]
var matches: [String] = []
func visit(_ value: Any) {
let mirror = Mirror(reflecting: value)
for child in mirror.children {
if let label = child.label {
let normalized = label.lowercased()
if forbiddenFragments.contains(where: normalized.contains) {
matches.append(label)
}
}
visit(child.value)
}
}
visit(value)
return matches
}
}
@@ -0,0 +1,453 @@
import Foundation
import Testing
@testable import CMUXMobileCore
private let canonicalEndpointID = String(repeating: "a", count: 64)
private func profile(
_ source: CmxIrohPathHintSource,
_ profileID: String = "default"
) throws -> CmxIrohNetworkProfileKey {
let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined()
let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64))
return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID)
}
@Test func dialPlanAdmitsOnlyExactManagedRelayURLsAndNeverLegacyRelayIdentifiers() throws {
let managedURL = "https://use1-1.relay.lawrence.cmux.iroh.link/"
let managed = try CmxIrohPathHint(
kind: .relayURL,
value: managedURL,
source: .native,
privacyScope: .publicInternet
)
let sameHostDifferentSpelling = try CmxIrohPathHint(
kind: .relayURL,
value: "https://use1-1.relay.lawrence.cmux.iroh.link",
source: .native,
privacyScope: .publicInternet
)
let attackerControlled = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.attacker.example/",
source: .native,
privacyScope: .publicInternet
)
let legacyIdentifier = CmxIrohPathHint(
legacyKind: .relayIdentifier,
value: "use1",
privacyScope: .publicInternet
)
let direct = try CmxIrohPathHint(
kind: .directAddress,
value: "8.8.8.8:49152",
source: .native,
privacyScope: .publicInternet
)
let endpoint = CmxAttachEndpoint.peer(
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [
attackerControlled,
legacyIdentifier,
sameHostDifferentSpelling,
managed,
direct,
]
)
let plan = try #require(endpoint.irohDialPlan(
at: Date(),
managedRelayURLs: [managedURL]
))
#expect(plan.publicPaths == [managed, direct])
#expect(plan.privateFallbackPaths.isEmpty)
let noRelayPlan = try #require(endpoint.irohDialPlan(
at: Date(),
managedRelayURLs: []
))
#expect(noRelayPlan.publicPaths == [direct])
}
@Test func networkProfileIdentityDisambiguatesOverlappingPrivateNetworks() throws {
let expiry = Date(timeIntervalSince1970: 2_000_000_000)
let siteA = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN, "site-a")
)
let siteB = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.customVPN, "site-b")
)
let sameNameFromTailscale = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.0.4:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.tailscale, "site-a")
)
let expectedSiteAProfile = try profile(.customVPN, "site-a")
let expectedSiteBProfile = try profile(.customVPN, "site-b")
#expect(siteA != siteB)
#expect(siteA.networkProfile == expectedSiteAProfile)
#expect(siteB.networkProfile == expectedSiteBProfile)
#expect(siteA.networkProfile != sameNameFromTailscale.networkProfile)
let endpoint = CmxAttachEndpoint.peer(
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [siteA, siteB, sameNameFromTailscale]
)
let activePlan = try #require(endpoint.irohDialPlan(
at: Date(timeIntervalSince1970: 1_999_999_999),
managedRelayURLs: [],
activeNetworkProfiles: [profile(.customVPN, "site-a")]
))
#expect(activePlan.privateFallbackPaths == [siteA])
let inactivePlan = try #require(endpoint.irohDialPlan(
at: Date(timeIntervalSince1970: 1_999_999_999),
managedRelayURLs: []
))
#expect(inactivePlan.privateFallbackPaths.isEmpty)
}
@Test func providerAttributedIrohEndpointRoundTripsIdentityAndHintPolicy() throws {
let expiry = Date(
timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down) + 300
)
let endpoint = CmxAttachEndpoint.peer(
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [
try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: expiry.addingTimeInterval(-60),
expiresAt: expiry,
networkProfile: profile(.tailscale, "production")
),
try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test",
source: .native,
privacyScope: .publicInternet
),
]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(
CmxAttachEndpoint.self,
from: encoder.encode(endpoint)
)
#expect(decoded == endpoint)
}
@Test func irohDisclosureAndPersistencePruneUnsafeHintScopes() throws {
let now = Date()
let publicRelay = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .publicInternet
)
let publicDirect = try CmxIrohPathHint(
kind: .directAddress,
value: "8.8.8.8:49152",
source: .native,
privacyScope: .publicInternet
)
let currentPrivate = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: now,
expiresAt: now.addingTimeInterval(300),
networkProfile: profile(.tailscale, "production")
)
let expiredPrivate = try CmxIrohPathHint(
kind: .directAddress,
value: "10.0.0.4:49152",
source: .customVPN,
privacyScope: .privateNetwork,
observedAt: now.addingTimeInterval(-120),
expiresAt: now.addingTimeInterval(-60),
networkProfile: profile(.customVPN, "corp")
)
let route = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [expiredPrivate, currentPrivate, publicDirect, publicRelay]
)
)
let authenticated = try #require(route.disclosed(for: .authenticated, at: now))
guard case let .peer(_, authenticatedHints) = authenticated.endpoint else {
Issue.record("Expected authenticated Iroh peer route")
return
}
#expect(authenticatedHints == [currentPrivate, publicDirect, publicRelay])
let cloud = try #require(route.disclosed(for: .cloudRendezvous, at: now))
guard case let .peer(_, cloudHints) = cloud.endpoint else {
Issue.record("Expected cloud Iroh peer route")
return
}
#expect(cloudHints == [publicRelay])
let backup = try #require(route.disclosed(for: .pairedMacCloudBackup, at: now))
guard case let .peer(_, backupHints) = backup.endpoint else {
Issue.record("Expected backup Iroh peer route")
return
}
#expect(backupHints == [publicRelay])
#expect(route.disclosed(for: .publicStatus, at: now) == nil)
let pairing = try #require(route.disclosed(for: .pairingQRCode, at: now))
guard case let .peer(_, pairingHints) = pairing.endpoint else {
Issue.record("Expected pairing Iroh peer route")
return
}
#expect(pairingHints.isEmpty)
let persisted = try JSONDecoder().decode(
CmxAttachRoute.self,
from: JSONEncoder().encode(authenticated)
)
guard case let .peer(_, persistedHints) = persisted.endpoint else {
Issue.record("Expected persisted Iroh peer route")
return
}
#expect(persistedHints == [currentPrivate, publicDirect, publicRelay])
}
@Test func materiallyFutureDatedPrivateHintsAreNeverAttemptedOrSerialized() throws {
let now = Date()
let networkProfile = try profile(.tailscale, "production")
let toleratedClockSkewHint = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.3:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: now.addingTimeInterval(
CmxIrohPathHint.maximumObservationClockSkew / 2
),
expiresAt: now.addingTimeInterval(300),
networkProfile: networkProfile
)
let futureHint = try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: now.addingTimeInterval(2 * 60 * 60),
expiresAt: now.addingTimeInterval(2 * 60 * 60 + 60),
networkProfile: networkProfile
)
let route = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [futureHint]
)
)
#expect(toleratedClockSkewHint.isUsable(at: now))
#expect(!futureHint.isUsable(at: now))
let dialPlan = try #require(route.endpoint.irohDialPlan(
at: now,
managedRelayURLs: [],
activeNetworkProfiles: [networkProfile]
))
#expect(dialPlan.privateFallbackPaths.isEmpty)
let disclosed = try #require(route.disclosed(for: .authenticated, at: now))
guard case let .peer(_, disclosedHints) = disclosed.endpoint else {
Issue.record("Expected disclosed Iroh peer route")
return
}
#expect(disclosedHints.isEmpty)
let persisted = try JSONDecoder().decode(
CmxAttachRoute.self,
from: JSONEncoder().encode(disclosed)
)
guard case let .peer(_, persistedHints) = persisted.endpoint else {
Issue.record("Expected persisted Iroh peer route")
return
}
#expect(persistedHints.isEmpty)
}
@Test func endpointEncodingIsClockIndependentAndDoesNotDowngradeFreshnessMetadata() throws {
let observedAt = Date(timeIntervalSince1970: 1_000)
let expiresAt = Date(timeIntervalSince1970: 1_060)
let direct = try CmxIrohPathHint(
kind: .directAddress,
value: "8.8.8.8:49152",
source: .native,
privacyScope: .publicInternet,
observedAt: observedAt,
expiresAt: expiresAt
)
let relay = try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .publicInternet,
observedAt: observedAt,
expiresAt: expiresAt
)
let endpoint = CmxAttachEndpoint.peer(
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [direct, relay]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
let firstEncoding = try encoder.encode(endpoint)
let secondEncoding = try encoder.encode(endpoint)
#expect(firstEncoding == secondEncoding)
let object = try #require(
try JSONSerialization.jsonObject(with: firstEncoding) as? [String: Any]
)
#expect((object["path_hints"] as? [[String: Any]])?.count == 2)
// The legacy fields cannot represent freshness metadata. Re-emitting
// either hint there would make an expired path look timeless to an older
// decoder.
#expect(object["direct_addrs"] == nil)
#expect(object["relay_url"] == nil)
#expect(object["relay_hint"] == nil)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let roundTrippedEndpoint = try decoder.decode(CmxAttachEndpoint.self, from: firstEncoding)
#expect(roundTrippedEndpoint == endpoint)
}
@Test func publicStatusDisclosesNoAttachRoutes() throws {
let routes = try [
CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [
CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test/",
source: .native,
privacyScope: .publicInternet
),
]
)
),
CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.1.2", port: 49152)
),
CmxAttachRoute(
id: "debug",
kind: .debugLoopback,
endpoint: .hostPort(host: "127.0.0.1", port: 49152)
),
CmxAttachRoute(
id: "websocket",
kind: .websocket,
endpoint: .url("wss://private.example.test/connect?token=secret")
),
]
for route in routes {
#expect(route.disclosed(for: .authenticated, at: Date()) == route)
#expect(route.disclosed(for: .publicStatus, at: Date()) == nil)
}
}
@Test func legacyFreeFormDirectHintStillDecodesButCannotBeUsedOrPromoted() throws {
let data = Data("""
{
"id": "iroh",
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": "\(canonicalEndpointID)",
"direct_addrs": ["old-hostname.example:49152"]
}
}
""".utf8)
let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data)
guard case let .peer(_, pathHints) = route.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
let hint = try #require(pathHints.first)
#expect(hint.use == .fallbackOnly)
#expect(!hint.isUsable(at: .distantPast))
let reencoded = try JSONEncoder().encode(route)
let redecoded = try JSONDecoder().decode(CmxAttachRoute.self, from: reencoded)
guard case let .peer(redecodedIdentity, redecodedHints) = redecoded.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
#expect(redecodedIdentity.endpointID == canonicalEndpointID)
// A current producer deliberately does not downgrade private fallbacks to
// legacy `direct_addrs`, whose consumers cannot enforce expiry or scope.
#expect(redecodedHints.isEmpty)
}
@Test func legacyUnsafeRelayURLStillDecodesButCannotBeUsedOrReemitted() throws {
let data = Data("""
{
"id": "iroh",
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": "\(canonicalEndpointID)",
"relay_url": "https://user:secret@relay.example.test/"
}
}
""".utf8)
let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data)
guard case let .peer(_, pathHints) = route.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
let hint = try #require(pathHints.first)
#expect(!hint.isSafeForCurrentWireFormat)
#expect(!hint.isUsable(at: .distantPast))
let reencoded = try JSONEncoder().encode(route)
let redecoded = try JSONDecoder().decode(CmxAttachRoute.self, from: reencoded)
guard case let .peer(_, redecodedHints) = redecoded.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
#expect(redecodedHints.isEmpty)
}
@@ -37,11 +37,25 @@ import Testing
return try #require(URLComponents(url: parsed, resolvingAgainstBaseURL: false))
}
private func encodeLegacy(_ ticket: CmxAttachTicket) -> String? {
CmxPairingQRCode().encode(
ticket,
routeDisclosureMode: .legacyPrivateNetworkCompatibility
)
}
private func canEncodeLegacy(_ ticket: CmxAttachTicket) -> Bool {
CmxPairingQRCode().canEncode(
ticket,
routeDisclosureMode: .legacyPrivateNetworkCompatibility
)
}
@Test func roundTripsSingleRoute() throws {
let ticket = try pairingTicket(routes: [
try tailscaleRoute(index: 0, host: "100.64.0.5"),
])
let url = try #require(CmxPairingQRCode().encode(ticket))
let url = try #require(encodeLegacy(ticket))
// The scheme is channel-specific: a release Mac emits cmux-ios, a dev
// Mac emits cmux-ios-dev, so the system camera routes each channel's QR
// to its build. The rest of the URL is identical across channels.
@@ -65,7 +79,7 @@ import Testing
try tailscaleRoute(index: 1, host: "100.64.0.5"),
]
let ticket = try pairingTicket(routes: routes)
let url = try #require(CmxPairingQRCode().encode(ticket))
let url = try #require(encodeLegacy(ticket))
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == routes)
@@ -93,7 +107,7 @@ import Testing
authToken: "minted-but-never-in-the-qr"
)
let url = try #require(CmxPairingQRCode().encode(ticket))
let url = try #require(encodeLegacy(ticket))
#expect(url.contains("ub=user_mac_123"))
#expect(!url.contains("[email protected]"))
#expect(!url.lowercased().contains("[email protected]"))
@@ -113,7 +127,7 @@ import Testing
@Test func roundTripsIPv6LiteralThroughRealURLParsing() throws {
let route = try tailscaleRoute(index: 0, host: "fd7a:115c:a1e0::1")
let ticket = try pairingTicket(routes: [route])
let url = try #require(CmxPairingQRCode().encode(ticket))
let url = try #require(encodeLegacy(ticket))
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == [route])
@@ -133,7 +147,7 @@ import Testing
let tailscale = try tailscaleRoute(index: 0, host: "100.64.0.5")
let ticket = try pairingTicket(routes: [loopback, tailscale])
let url = try #require(CmxPairingQRCode().encode(ticket))
let url = try #require(encodeLegacy(ticket))
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == [tailscale])
@@ -149,8 +163,8 @@ import Testing
macDisplayName: nil,
routes: [tailscale]
)
#expect(CmxPairingQRCode().encode(scoped) == nil)
#expect(!CmxPairingQRCode().canEncode(scoped))
#expect(encodeLegacy(scoped) == nil)
#expect(!canEncodeLegacy(scoped))
// Loopback-only dev tickets have nothing a phone could dial.
let loopbackOnly = try CmxAttachTicket(
@@ -166,7 +180,7 @@ import Testing
),
]
)
#expect(CmxPairingQRCode().encode(loopbackOnly) == nil)
#expect(encodeLegacy(loopbackOnly) == nil)
// Custom route ids cannot be resynthesized by the decoder.
let customID = try pairingTicket(routes: [
@@ -177,7 +191,7 @@ import Testing
priority: 10
),
])
#expect(CmxPairingQRCode().encode(customID) == nil)
#expect(encodeLegacy(customID) == nil)
// A Tailscale-kind route that somehow names a loopback host is a
// weak QR and must not encode.
@@ -189,7 +203,7 @@ import Testing
priority: 10
),
])
#expect(CmxPairingQRCode().encode(loopbackTailscale) == nil)
#expect(encodeLegacy(loopbackTailscale) == nil)
// A non-Tailscale fallback route the bare host:port grammar cannot
// express (an iroh peer) must NOT be silently dropped: the ticket
@@ -200,12 +214,17 @@ import Testing
try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(id: "peer-1", relayHint: nil, directAddrs: [], relayURL: nil),
endpoint: .peer(
id: String(repeating: "d", count: 64),
relayHint: nil,
directAddrs: [],
relayURL: nil
),
priority: 20
),
])
#expect(CmxPairingQRCode().encode(withIrohFallback) == nil)
#expect(!CmxPairingQRCode().canEncode(withIrohFallback))
#expect(encodeLegacy(withIrohFallback) == nil)
#expect(!canEncodeLegacy(withIrohFallback))
}
@Test(arguments: [
@@ -317,13 +336,16 @@ import Testing
])
for (label, ticket) in [("1-route", oneRoute), ("2-route", twoRoutes)] {
let compactPayload = try CmxAttachTicketCompactCoder().encode(ticket)
let compactPayload = try CmxAttachTicketCompactCoder().encode(
ticket,
routeDisclosureMode: .legacyPrivateNetworkCompatibility
)
let base64 = compactPayload.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let before = "cmux-ios://attach?v=1&payload=\(base64)"
let after = try #require(CmxPairingQRCode().encode(ticket))
let after = try #require(encodeLegacy(ticket))
let beforeBytes = before.utf8.count
let afterBytes = after.utf8.count
@@ -0,0 +1,215 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct CmxTailscaleStatusPeerResolverTests {
private let resolver = CmxTailscaleStatusPeerResolver()
@Test func resolvesOneExactDualStackPeerAndPrefersIPv4() throws {
let record = try resolver.resolve(
magicDNSName: " WORK-MAC.TAILNET.TS.NET. ",
statusJSON: statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["fd7a:115c:a1e0::1234", "100.71.210.41"]
),
])
)
#expect(record.stableID == "node-1")
#expect(record.dnsName == "work-mac.tailnet.ts.net")
#expect(record.addresses.map(\.value) == ["100.71.210.41", "fd7a:115c:a1e0::1234"])
#expect(record.preferredAddress.value == "100.71.210.41")
#expect(!record.isLocalDevice)
}
@Test func resolvesIPv6OnlyPeerWithoutFallingBackToGenericPrivateNetworking() throws {
let record = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["fd7a:115c:a1e0:0:0:0:0:1234"]
),
])
)
#expect(record.preferredAddress.value == "fd7a:115c:a1e0::1234")
#expect(record.preferredAddress.family == .ipv6)
}
@Test func rejectsNoMatchAndSuffixSubstitution() throws {
let status = try statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["100.71.210.41"]
),
])
#expect(throws: CmxTailscaleStatusPeerResolutionError.peerNotFound) {
_ = try resolver.resolve(
magicDNSName: "other-mac.tailnet.ts.net",
statusJSON: status
)
}
#expect(throws: CmxTailscaleStatusPeerResolutionError.invalidMagicDNSName) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net.attacker.example",
statusJSON: status
)
}
}
@Test func rejectsTwoPeerRecordsClaimingTheSameName() throws {
let status = try statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["100.71.210.41"]
),
peer(
id: "node-2",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["100.72.1.9"]
),
])
#expect(throws: CmxTailscaleStatusPeerResolutionError.ambiguousPeer) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: status
)
}
}
@Test(arguments: [
["100.71.210.41", "203.0.113.10"],
["100.71.210.41", "192.168.1.20"],
["100.71.210.41", "fd7a:115c:a1e0::53"],
["100.100.100.100"],
["not-an-address"],
])
func rejectsMixedPublicPrivateServiceAndMalformedPeerAddresses(
_ addresses: [String]
) throws {
let status = try statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: addresses
),
])
#expect(throws: CmxTailscaleStatusPeerResolutionError.invalidPeerAddress) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: status
)
}
}
@Test func rejectsTheLocalDeviceForManualPeerAddButCanResolveSelfPublication() throws {
let status = try statusJSON(
local: peer(
id: "self-node",
dnsName: "this-mac.tailnet.ts.net.",
addresses: ["100.70.1.5", "fd7a:115c:a1e0::5"]
),
peers: []
)
#expect(throws: CmxTailscaleStatusPeerResolutionError.localDeviceNotAllowed) {
_ = try resolver.resolve(
magicDNSName: "this-mac.tailnet.ts.net",
statusJSON: status
)
}
let local = try resolver.resolve(
magicDNSName: "this-mac.tailnet.ts.net",
statusJSON: status,
allowLocalDevice: true
)
#expect(local.isLocalDevice)
#expect(local.preferredAddress.value == "100.70.1.5")
}
@Test func rejectsEmptyAddressesMalformedStatusAndOversizedStatus() throws {
let emptyAddresses = try statusJSON(peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: []
),
])
#expect(throws: CmxTailscaleStatusPeerResolutionError.missingPeerAddresses) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: emptyAddresses
)
}
#expect(throws: CmxTailscaleStatusPeerResolutionError.malformedStatus) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: Data("[]".utf8)
)
}
#expect(throws: CmxTailscaleStatusPeerResolutionError.malformedStatus) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: Data(repeating: 0x20, count: CmxTailscaleStatusPeerResolver.maximumStatusBytes + 1)
)
}
}
@Test func rejectsCachedPeerMapWhenTailscaleIsNotRunning() throws {
let status = try statusJSON(
backendState: "Stopped",
peers: [
peer(
id: "node-1",
dnsName: "work-mac.tailnet.ts.net.",
addresses: ["100.71.210.41"]
),
]
)
#expect(throws: CmxTailscaleStatusPeerResolutionError.statusNotRunning) {
_ = try resolver.resolve(
magicDNSName: "work-mac.tailnet.ts.net",
statusJSON: status
)
}
}
private func statusJSON(
backendState: String = "Running",
local: [String: Any]? = nil,
peers: [[String: Any]]
) throws -> Data {
var root: [String: Any] = [
"BackendState": backendState,
"Peer": Dictionary(
uniqueKeysWithValues: peers.enumerated().map { ("peer-\($0.offset)", $0.element) }
),
]
if let local {
root["Self"] = local
}
return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
}
private func peer(
id: String,
dnsName: String,
addresses: [String]
) -> [String: Any] {
[
"ID": id,
"DNSName": dnsName,
"TailscaleIPs": addresses,
]
}
}
@@ -0,0 +1,41 @@
import Foundation
@testable import CMUXMobileCore
struct TaggedTransportFactory: CmxByteTransportFactory {
var tag: String
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport {
TaggedTransport(tag: tag, route: route)
}
}
struct RequestTaggedTransportFactory: CmxByteTransportFactory {
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport {
TaggedTransport(tag: "route-only", route: route)
}
func makeTransport(
for request: CmxByteTransportRequest
) throws -> any CmxByteTransport {
let mode = request.authorizationMode == .transportAdmission ? "admission" : "stack"
return TaggedTransport(
tag: "\(request.expectedPeerDeviceID ?? "missing"):\(mode)",
route: request.route
)
}
}
struct TaggedTransport: CmxByteTransport {
var tag: String
var route: CmxAttachRoute
func connect() async throws {}
func receive() async throws -> Data? {
nil
}
func send(_ data: Data) async throws {}
func close() async {}
}
@@ -2,38 +2,16 @@ import Foundation
import Testing
@testable import CMUXMobileCore
@Test func attachTicketChoosesFirstSupportedRouteByPriority() throws {
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
id: "node-1",
relayHint: "relay-1",
directAddrs: ["192.168.1.20:3478"],
relayURL: "https://relay.example.test"
),
priority: 0
)
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.1.2", port: 49831),
priority: 1
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace-1",
terminalID: "terminal-1",
macDeviceID: "mac-1",
macDisplayName: "Studio",
routes: [tailscale, iroh],
expiresAt: Date(timeIntervalSince1970: 2_000_000_000)
)
private let canonicalEndpointID = String(repeating: "a", count: 64)
#expect(ticket.preferredRoute(supportedKinds: [.tailscale, .iroh]) == iroh)
#expect(ticket.preferredRoute(supportedKinds: [.websocket]) == nil)
#expect(ticket.preferredRoute(supportedKinds: []) == nil)
private func profile(
_ source: CmxIrohPathHintSource,
_ profileID: String = "default"
) throws -> CmxIrohNetworkProfileKey {
let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined()
let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64))
return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID)
}
@Test func attachTicketUsesDebugLoopbackBeforeTailscaleWhenBothAreSupported() throws {
let loopback = try CmxAttachRoute(
id: "debug",
@@ -61,6 +39,9 @@ import Testing
}
@Test func attachTicketRoundTripsAllEndpointKinds() throws {
let privateHintExpiry = Date(
timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down) + 300
)
let routes = try [
CmxAttachRoute(
id: "tailscale",
@@ -71,10 +52,24 @@ import Testing
id: "iroh",
kind: .iroh,
endpoint: .peer(
id: "node-1",
relayHint: nil,
directAddrs: ["100.64.1.2:49152"],
relayURL: "https://relay.example.test"
identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID),
pathHints: [
try CmxIrohPathHint(
kind: .directAddress,
value: "100.64.1.2:49152",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: privateHintExpiry.addingTimeInterval(-60),
expiresAt: privateHintExpiry,
networkProfile: profile(.tailscale, "production")
),
try CmxIrohPathHint(
kind: .relayURL,
value: "https://relay.example.test",
source: .native,
privacyScope: .publicInternet
),
]
)
),
CmxAttachRoute(
@@ -171,7 +166,7 @@ import Testing
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": "node-1",
"id": "\(canonicalEndpointID)",
"direct_addrs": ["192.168.1.20:49152", "100.64.1.2:49152"],
"relay_url": "https://relay.example.test"
},
@@ -184,14 +179,20 @@ import Testing
#expect(route.id == "iroh")
#expect(route.kind == .iroh)
#expect(route.priority == 20)
guard case let .peer(id, relayHint, directAddrs, relayURL) = route.endpoint else {
guard case let .peer(identity, pathHints) = route.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
#expect(id == "node-1")
#expect(relayHint == nil)
#expect(directAddrs == ["192.168.1.20:49152", "100.64.1.2:49152"])
#expect(relayURL == "https://relay.example.test")
#expect(identity.endpointID == canonicalEndpointID)
#expect(pathHints.filter { $0.kind == .relayIdentifier }.isEmpty)
#expect(pathHints.filter { $0.kind == .directAddress }.map(\.value) == [
"192.168.1.20:49152",
"100.64.1.2:49152",
])
#expect(pathHints.first { $0.kind == .relayURL }?.value == "https://relay.example.test")
#expect(pathHints.filter { $0.kind == .directAddress }.allSatisfy {
$0.use == .fallbackOnly && !$0.isUsable(at: .distantPast)
})
}
@Test func attachRouteDecodesLegacyPeerRouteWithoutIrohAddressHints() throws {
@@ -201,7 +202,7 @@ import Testing
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": "node-1",
"id": "\(canonicalEndpointID)",
"relay_hint": "legacy-relay"
},
"priority": 20
@@ -210,14 +211,14 @@ import Testing
let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data)
guard case let .peer(id, relayHint, directAddrs, relayURL) = route.endpoint else {
guard case let .peer(identity, pathHints) = route.endpoint else {
Issue.record("Expected an Iroh peer endpoint")
return
}
#expect(id == "node-1")
#expect(relayHint == "legacy-relay")
#expect(directAddrs.isEmpty)
#expect(relayURL == nil)
#expect(identity.endpointID == canonicalEndpointID)
#expect(pathHints.first { $0.kind == .relayIdentifier }?.value == "legacy-relay")
#expect(pathHints.filter { $0.kind == .directAddress }.isEmpty)
#expect(pathHints.filter { $0.kind == .relayURL }.isEmpty)
}
@Test func attachRouteDecoderDefaultsMissingPriorityToZero() throws {
@@ -235,6 +236,8 @@ import Testing
let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data)
#expect(route.kind == .tailscale)
#expect(route.endpoint == .hostPort(host: "100.64.1.2", port: 49831))
#expect(route.priority == 0)
}
@@ -410,7 +413,7 @@ import Testing
let irohRoute = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(id: "node-1", relayHint: nil, directAddrs: [], relayURL: nil)
endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil)
)
let tailscaleTransport = try factory.makeTransport(for: tailscaleRoute)
@@ -436,6 +439,29 @@ import Testing
}
}
@Test func routeTransportFactoryPreservesPeerIntentForRequestAwareTransports() throws {
let factory = try CmxRouteTransportFactory([
CmxRouteTransportFactoryRegistration(
kind: .iroh,
factory: RequestTaggedTransportFactory()
),
])
let route = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil)
)
let request = CmxByteTransportRequest(
route: route,
expectedPeerDeviceID: "mac-device-a",
authorizationMode: .transportAdmission
)
let transport = try factory.makeTransport(for: request)
#expect((transport as? TaggedTransport)?.tag == "mac-device-a:admission")
}
@Test func routeTransportFactoryRejectsUnsupportedRouteKind() throws {
let factory = try CmxRouteTransportFactory([
CmxRouteTransportFactoryRegistration(
@@ -446,33 +472,10 @@ import Testing
let route = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(id: "node-1", relayHint: nil, directAddrs: [], relayURL: nil)
endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil)
)
#expect(throws: CmxRouteTransportFactoryError.unsupportedRouteKind(.iroh)) {
_ = try factory.makeTransport(for: route)
}
}
private struct TaggedTransportFactory: CmxByteTransportFactory {
var tag: String
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport {
TaggedTransport(tag: tag, route: route)
}
}
private struct TaggedTransport: CmxByteTransport {
var tag: String
var route: CmxAttachRoute
func connect() async throws {}
func receive() async throws -> Data? {
nil
}
func send(_ data: Data) async throws {}
func close() async {}
}
@@ -0,0 +1,57 @@
import Testing
@testable import CMUXMobileCore
@Suite
struct MobileHostRPCWorkQuotaTests {
@Test
func permitsBoundedConcurrencyThenRejectsAnotherRequest() {
let quota = MobileHostRPCWorkQuota(
maximumConcurrentRequestCount: 3,
maximumAggregateFrameByteCount: 100
)
#expect(quota.allowsAdmission(
frameByteCount: 1,
activeFrameByteCounts: [10, 20]
))
#expect(!quota.allowsAdmission(
frameByteCount: 1,
activeFrameByteCounts: [10, 20, 30]
))
}
@Test
func boundsAggregateDecodedBytesAcrossConcurrentRequests() {
let quota = MobileHostRPCWorkQuota(
maximumConcurrentRequestCount: 10,
maximumAggregateFrameByteCount: 100
)
#expect(quota.allowsAdmission(
frameByteCount: 40,
activeFrameByteCounts: [25, 35]
))
#expect(!quota.allowsAdmission(
frameByteCount: 41,
activeFrameByteCounts: [25, 35]
))
#expect(!quota.allowsAdmission(
frameByteCount: 101,
activeFrameByteCounts: []
))
}
@Test
func defaultBudgetAllowsOneMaximumFrameWithoutIntegerOverflow() {
let quota = MobileHostRPCWorkQuota()
#expect(quota.allowsAdmission(
frameByteCount: MobileSyncFrameCodec.defaultMaximumFrameByteCount,
activeFrameByteCounts: []
))
#expect(!quota.allowsAdmission(
frameByteCount: 1,
activeFrameByteCounts: [Int.max]
))
}
}
@@ -199,6 +199,39 @@ import Testing
}
}
@Test func frameCodecRejectsAZeroLengthFrameFloodAtTheCallerLimit() throws {
let emptyFrame = try MobileSyncFrameCodec.encodeFrame(Data())
var buffer = Data()
for _ in 0..<10_000 {
buffer.append(emptyFrame)
}
do {
_ = try MobileSyncFrameCodec.decodeFrames(
from: &buffer,
maximumDecodedFrameCount: 16
)
Issue.record("Expected the decoded frame count limit to fail closed")
} catch let error as MobileSyncFrameCodecError {
#expect(error == .tooManyFrames(16))
#expect(buffer.count == (10_000 - 16) * emptyFrame.count)
}
}
@Test func frameCodecDefaultFrameCountLimitIsFinite() throws {
let emptyFrame = try MobileSyncFrameCodec.encodeFrame(Data())
var buffer = Data()
for _ in 0...MobileSyncFrameCodec.defaultMaximumDecodedFrameCount {
buffer.append(emptyFrame)
}
#expect(throws: MobileSyncFrameCodecError.tooManyFrames(
MobileSyncFrameCodec.defaultMaximumDecodedFrameCount
)) {
_ = try MobileSyncFrameCodec.decodeFrames(from: &buffer)
}
}
private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -179,8 +179,8 @@ import Testing
#expect(delta.clearedRows == [1])
#expect(delta.styles == frame.styles)
#expect(delta.rowSpans == [.init(row: 1, column: 0, styleID: 1, text: "green")])
#expect(try #require(String(data: delta.vtPatchBytes(), encoding: .utf8))
.contains("\u{1B}[0;38;2;0;255;0;48;2;0;0;0mgreen"))
let patch = try #require(String(data: delta.vtPatchBytes(), encoding: .utf8))
#expect(patch.contains("\u{1B}[0;38;2;0;255;0;48;2;0;0;0mgreen"))
}
@Test func renderGridFilteredDeltaKeepsOnlyReplayRestoredModeState() throws {
@@ -28,6 +28,9 @@ public final class HostBrowserSignInFlow {
private let clock: any Clock<Duration>
private let browserAttemptTimeout: TimeInterval
private let slowSignInThreshold: TimeInterval
private let signOutCoordinator: HostBrowserSignOutCoordinator
private let callbackStateGenerator = HostBrowserCallbackStateGenerator()
private let deadline: HostBrowserDeadline
private let log = AuthDebugLog()
@ObservationIgnored private var activeSession: (any HostBrowserAuthSession)?
@@ -54,7 +57,12 @@ public final class HostBrowserSignInFlow {
openExternalURL: @escaping @MainActor (URL) -> Bool,
clock: any Clock<Duration> = ContinuousClock(),
browserAttemptTimeout: TimeInterval = 10 * 60,
slowSignInThreshold: TimeInterval = 30
slowSignInThreshold: TimeInterval = 30,
beginSignOut: @escaping @MainActor @Sendable () -> Void = {},
onSignedOut: @escaping @Sendable (
_ accessToken: String?,
_ refreshToken: String?
) async -> Void = { _, _ in }
) {
self.coordinator = coordinator
self.tokenStore = tokenStore
@@ -66,6 +74,11 @@ public final class HostBrowserSignInFlow {
self.clock = clock
self.browserAttemptTimeout = browserAttemptTimeout
self.slowSignInThreshold = slowSignInThreshold
deadline = HostBrowserDeadline(clock: clock)
signOutCoordinator = HostBrowserSignOutCoordinator(
beginSignOut: beginSignOut,
signOut: { await coordinator.signOut(onSignedOut: onSignedOut) }
)
}
/// Start a browser sign-in without awaiting the result (Settings button).
@@ -82,7 +95,7 @@ public final class HostBrowserSignInFlow {
/// The hosted sign-in URL for a manual fallback.
public var manualSignInURL: URL {
let state = makeCallbackState()
let state = callbackStateGenerator.make()
pendingManualCallbackState = state
return makeSignInURL(state)
}
@@ -101,7 +114,7 @@ public final class HostBrowserSignInFlow {
log.log("auth.browser.signIn.result result=alreadySignedIn")
return true
}
let result = await awaitWithDeadline(startAttempt(), timeout: timeout)
let result = await deadline.resolve(startAttempt(), timeout: timeout)
log.log("auth.browser.signIn.result signedIn=\(result)")
return result
}
@@ -147,11 +160,12 @@ public final class HostBrowserSignInFlow {
/// Sign out and prevent a late callback from resurrecting the session.
public func signOut() async {
if await signOutCoordinator.joinActive() { return }
log.log("auth.browser.signOut.begin signingIn=\(isSigningIn) activeAttempt=\(activeAttemptID.map(String.init) ?? "nil") generation=\(signOutGeneration)")
signOutGeneration &+= 1
lastFailure = nil
cancelActiveAttempt()
await coordinator.signOut()
await signOutCoordinator.run()
log.log("auth.browser.signOut.end generation=\(signOutGeneration)")
}
@@ -162,39 +176,7 @@ public final class HostBrowserSignInFlow {
await self.signOut()
return true
}
_ = await awaitWithDeadline(attempt, timeout: timeout)
}
/// Await `attempt`, resolving `false` at the deadline without cancelling it.
private func awaitWithDeadline(_ attempt: Task<Bool, Never>, timeout: TimeInterval) async -> Bool {
let clamped = max(0, min(timeout, 24 * 60 * 60))
let clock = self.clock
let stream = AsyncStream<Bool>(bufferingPolicy: .bufferingOldest(1)) { continuation in
let deadlineTask = Task {
do {
try await clock.sleep(for: .seconds(clamped))
} catch {
return
}
guard !Task.isCancelled else { return }
continuation.yield(false)
continuation.finish()
}
let attemptWaitTask = Task {
let result = await attempt.value
continuation.yield(result)
continuation.finish()
deadlineTask.cancel()
}
continuation.onTermination = { @Sendable _ in
deadlineTask.cancel()
attemptWaitTask.cancel()
}
}
for await result in stream {
return result
}
return false
_ = await deadline.resolve(attempt, timeout: timeout)
}
// MARK: - Attempt lifecycle
@@ -209,7 +191,7 @@ public final class HostBrowserSignInFlow {
let attemptID = nextAttemptID
let manualCallbackState = pendingManualCallbackState
pendingManualCallbackState = nil
let callbackState = manualCallbackState ?? makeCallbackState()
let callbackState = manualCallbackState ?? callbackStateGenerator.make()
activeAttemptID = attemptID
activeCallbackState = callbackState
// The CLI's manual fallback shares this attempt's state so a late
@@ -247,10 +229,11 @@ public final class HostBrowserSignInFlow {
}
private func runBrowserSession(attemptID: UInt64) async -> HostBrowserAuthSessionResult? {
await withCheckedContinuation { continuation in
await withCheckedContinuation {
(continuation: CheckedContinuation<HostBrowserAuthSessionResult?, Never>) in
activeSessionContinuation = continuation
activeSessionContinuationAttemptID = attemptID
let callbackState = activeCallbackState ?? makeCallbackState()
let callbackState = activeCallbackState ?? callbackStateGenerator.make()
let signInURL = makeSignInURL(callbackState)
let scheme = callbackScheme()
log.log("auth.browser.session.create id=\(attemptID) signInURL=\(signInURL.absoluteString) callbackScheme=\(scheme)")
@@ -455,9 +438,11 @@ public final class HostBrowserSignInFlow {
log.log("auth.callback.coordinator.complete.end attempt=\(attemptID.map(String.init) ?? "external") signedIn=\(coordinator.isAuthenticated)")
guard signOutGeneration == generation else {
// Sign-out ran while the validation round trip was in flight. The
// user's intent wins: tear the just-published session back down.
// user's intent wins. Join the prepared flow-level operation so
// this rollback cannot clear auth before composition-owned state
// has been durably quarantined.
log.log("auth.callback.coordinator.rollback attempt=\(attemptID.map(String.init) ?? "external") reason=signOutRaced generation=\(signOutGeneration)")
await coordinator.signOut()
await signOut()
await tokenStore.clearTokensIfCurrent(
accessToken: payload.accessToken,
refreshToken: payload.refreshToken
@@ -488,7 +473,4 @@ public final class HostBrowserSignInFlow {
continuation.resume(returning: result)
}
private func makeCallbackState() -> String {
UUID().uuidString.lowercased()
}
}
@@ -0,0 +1,82 @@
import Foundation
@MainActor
final class HostBrowserSignOutCoordinator {
private let beginSignOut: @MainActor @Sendable () -> Void
private let signOut: @Sendable () async -> Void
private var nextOperationID: UInt64 = 0
private var activeOperation: (id: UInt64, task: Task<Void, Never>)?
init(
beginSignOut: @escaping @MainActor @Sendable () -> Void,
signOut: @escaping @Sendable () async -> Void
) {
self.beginSignOut = beginSignOut
self.signOut = signOut
}
func joinActive() async -> Bool {
guard let activeOperation else { return false }
await activeOperation.task.value
return true
}
func run() async {
if let activeOperation {
await activeOperation.task.value
return
}
nextOperationID &+= 1
let operationID = nextOperationID
let task = Task { @MainActor [beginSignOut, signOut] in
beginSignOut()
await signOut()
}
activeOperation = (operationID, task)
await task.value
if activeOperation?.id == operationID {
activeOperation = nil
}
}
}
struct HostBrowserCallbackStateGenerator: Sendable {
func make() -> String {
UUID().uuidString.lowercased()
}
}
struct HostBrowserDeadline: Sendable {
let clock: any Clock<Duration>
/// Resolves `false` at the deadline without cancelling the underlying task.
func resolve(_ attempt: Task<Bool, Never>, timeout: TimeInterval) async -> Bool {
let clamped = max(0, min(timeout, 24 * 60 * 60))
let stream = AsyncStream<Bool>(bufferingPolicy: .bufferingOldest(1)) { continuation in
let deadlineTask = Task {
do {
try await clock.sleep(for: .seconds(clamped))
} catch {
return
}
guard !Task.isCancelled else { return }
continuation.yield(false)
continuation.finish()
}
let attemptWaitTask = Task {
let result = await attempt.value
continuation.yield(result)
continuation.finish()
deadlineTask.cancel()
}
continuation.onTermination = { @Sendable _ in
deadlineTask.cancel()
attemptWaitTask.cancel()
}
}
for await result in stream {
return result
}
return false
}
}
@@ -0,0 +1,8 @@
public extension AuthCoordinator {
/// Monotonic process-local evidence that a local sign-out began.
///
/// Lifecycle consumers use this revision instead of relying on observing
/// the transient signed-out UI state, which can be coalesced when another
/// account signs in immediately afterward.
var signOutRevision: UInt64 { signOutEpoch }
}
@@ -0,0 +1,26 @@
import Foundation
extension AuthCoordinator {
/// Races an operation against its phase deadline on the injected clock.
func runPhase<T: Sendable>(
_ phase: AuthPhase,
timeout: Duration,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
if phase == .validateSession {
return try await runValidationPhase(timeout: timeout, operation)
}
if phase == .fetchUser || phase == .listTeams {
return try await runTokenTouchingPhase(phase, timeout: timeout, operation)
}
return try await withAuthPhaseTimeout(
phase,
duration: timeout,
clock: clock,
log: log,
registry: phaseTimeoutRegistry,
blocksRetriesWhileTimedOutOperationActive: phase == .sendCode,
operation: operation
)
}
}
@@ -31,6 +31,13 @@ extension AuthCoordinator {
try await self.accessTokenWithoutStateClear()
}
} catch AuthError.unauthorized {
// A session transition owns the temporarily empty token store. This
// method is a reader, so it cannot publish a signed-out verdict or
// bump sessionGeneration out from under that writer. Callers retry
// after restore/sign-in reaches its terminal state.
if sessionTokenTransitionIsActive {
throw AuthError.networkError
}
if let devToken = await devAuthAccessTokenFallback() {
return devToken
}
@@ -138,6 +145,9 @@ extension AuthCoordinator {
try await self.forceRefreshAccessTokenWithoutStateClear()
}
} catch AuthError.unauthorized {
if sessionTokenTransitionIsActive {
throw AuthError.networkError
}
clearAuthState(preservePendingCode: true)
throw AuthError.unauthorized
}
@@ -101,6 +101,16 @@ public final class AuthCoordinator {
@ObservationIgnored var signOutEpoch: UInt64 = 0
/// Monotonic sign-in attempt count, allocating each flow's attempt id.
@ObservationIgnored var signInAttemptCounter: UInt64 = 0
/// Sign-in attempts that currently own a possible write to the token store.
///
/// This ownership spans the whole flow, not just the credential-exchange
/// task: a token consumer can run after an exchange starts but before its
/// tokens land, or after the exchange returns while user/team publication
/// is still finishing. In either window an empty token read is transient;
/// only the owning sign-in may decide whether the session transition
/// succeeds or fails. Token readers consult this registry instead of clearing
/// coordinator state out from under the active writer.
@ObservationIgnored var activeSignInFlows: [UInt64: SignInFlowContext] = [:]
/// The highest attempt id whose credential exchange has written the token
/// store (recorded when the flow reaches its completion step, immediately
/// after the exchange's write). The last writer owns the store: a stale
@@ -125,11 +135,21 @@ public final class AuthCoordinator {
private func beginSignInFlow() async throws -> SignInFlowContext {
signInAttemptCounter &+= 1
let flow = SignInFlowContext(generation: sessionGeneration, attempt: signInAttemptCounter, signOutEpoch: signOutEpoch)
try await waitForSessionTokenWorkToQuiesceBeforeSignIn()
guard flow.generation == sessionGeneration, flow.signOutEpoch == signOutEpoch else {
throw CancellationError()
activeSignInFlows[flow.attempt] = flow
do {
try await waitForSessionTokenWorkToQuiesceBeforeSignIn()
guard flow.generation == sessionGeneration, flow.signOutEpoch == signOutEpoch else {
throw CancellationError()
}
return flow
} catch {
activeSignInFlows[flow.attempt] = nil
throw error
}
return flow
}
private func finishSignInFlow(_ flow: SignInFlowContext) {
activeSignInFlows[flow.attempt] = nil
}
/// Creates an auth coordinator.
@@ -253,6 +273,7 @@ public final class AuthCoordinator {
// Captured before the first await so a sign-out landing anywhere in
// this flow (connectivity probe, exchange, user fetch) wins.
let flow = try await beginSignInFlow()
defer { finishSignInFlow(flow) }
try await requireOnline()
isLoading = true
defer { isLoading = false }
@@ -275,6 +296,7 @@ public final class AuthCoordinator {
// Captured before the first await so a sign-out landing anywhere in
// this flow (connectivity probe, exchange, user fetch) wins.
let flow = try await beginSignInFlow()
defer { finishSignInFlow(flow) }
try await requireOnline()
if setLoading { isLoading = true }
defer { if setLoading { isLoading = false } }
@@ -303,6 +325,7 @@ public final class AuthCoordinator {
// Captured before the first await so a sign-out landing anywhere in
// this flow (connectivity probe, OAuth exchange, user fetch) wins.
let flow = try await beginSignInFlow()
defer { finishSignInFlow(flow) }
try await requireOnline()
isLoading = true
defer { isLoading = false }
@@ -403,6 +426,7 @@ public final class AuthCoordinator {
// covers the validation round trip; the seeding flow keeps its own
// sign-out race guard for the seeded tokens.
let flow = try await beginSignInFlow()
defer { finishSignInFlow(flow) }
isLoading = true
defer { isLoading = false }
do {
@@ -619,6 +643,30 @@ public final class AuthCoordinator {
apply(.cleared())
}
/// Whether one coordinator-owned transition can legitimately observe an
/// empty token store before it reaches its terminal state.
///
/// Consumers such as analytics, presence, or push may request a token at
/// any time. They are readers, so they must not turn temporary emptiness
/// into `clearAuthState()` while launch restore or a sign-in owns the store.
/// Returning a retryable error leaves the transition's single owner in
/// charge. Interactive sign-out is included while it captures credentials;
/// its own local-first clear remains authoritative.
var sessionTokenTransitionIsActive: Bool {
let currentSignInOwnsStore = activeSignInFlows.values.contains { flow in
flow.generation == sessionGeneration && flow.signOutEpoch == signOutEpoch
}
// A sign-out makes an in-flight validation stale before it clears the
// published flags. Keep reads transient during credential capture, then
// stop treating that stale validation as an owner once local-first clear
// publishes the signed-out state.
let currentValidationOwnsStore = isRevalidatingSession
&& (isRestoringSession || isAuthenticated)
return currentSignInOwnsStore
|| currentValidationOwnsStore
|| isCapturingSignOutCredentials
}
func preserveCachedSessionAfterValidationFailure() {
sessionCache.setHasTokens(true)
let cachedUser = currentUser ?? loadCachedUser()
@@ -652,31 +700,6 @@ public final class AuthCoordinator {
}
}
/// Race `operation` against the phase deadline on the injected clock,
/// dispatching token-touching and side-effect phases to coordinator-owned
/// helpers so sign-out can still cancel late work after a timeout.
func runPhase<T: Sendable>(
_ phase: AuthPhase,
timeout: Duration,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
if phase == .validateSession {
return try await runValidationPhase(timeout: timeout, operation)
}
if phase == .fetchUser || phase == .listTeams {
return try await runTokenTouchingPhase(phase, timeout: timeout, operation)
}
return try await withAuthPhaseTimeout(
phase,
duration: timeout,
clock: clock,
log: log,
registry: phaseTimeoutRegistry,
blocksRetriesWhileTimedOutOperationActive: phase == .sendCode,
operation: operation
)
}
func apply(_ state: CMUXAuthState) {
currentUser = state.currentUser
isAuthenticated = state.isAuthenticated
@@ -235,6 +235,7 @@ import Testing
#expect(store.bool(forKey: "has_tokens"))
#expect(await client.accessToken() != nil)
#expect(await client.refreshToken() != nil)
#expect(coordinator.activeSignInFlows.isEmpty)
}
@Test func staleAutoLoginCancellationDoesNotWipeANewerSession() async throws {
@@ -290,4 +291,132 @@ import Testing
#expect(await client.accessToken() != nil)
#expect(await client.refreshToken() != nil)
}
@Test func tokenReadDuringAutoLoginDoesNotCancelTheSessionTransition() async throws {
// The app can ask for a bearer token while launch auto-login still owns
// the initially empty token store (analytics flush is one real caller).
// Empty storage is transient in that window, not proof that the session
// is dead. A token read must fail retryably without clearing coordinator
// state, so the in-flight auto-login remains the sole session writer.
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let client = GateableValidationAuthClient(user: user)
let store = FakeKeyValueStore()
let coordinator = AuthCoordinator(
client: client,
sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"),
userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"),
teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"),
anchor: FakeAnchor(),
config: .test,
launch: AuthLaunchOptions(
clearAuthRequested: false,
mockDataEnabled: false,
environment: [
"CMUX_UITEST_STACK_EMAIL": "[email protected]",
"CMUX_UITEST_STACK_PASSWORD": "pw",
],
includesDevAuth: false
)
)
await client.armCredentialGate()
coordinator.start()
await client.credentialDidPark()
await #expect(throws: AuthError.networkError) {
_ = try await coordinator.accessToken()
}
await client.releaseParkedCredential()
await coordinator.awaitBootstrapped()
#expect(coordinator.isAuthenticated)
#expect(coordinator.currentUser == user)
#expect(store.bool(forKey: "has_tokens"))
#expect(await client.accessToken() != nil)
#expect(await client.refreshToken() != nil)
#expect(coordinator.activeSignInFlows.isEmpty)
}
@Test func tokenReadsDuringManualSignInDoNotCancelTheSessionTransition() async throws {
// Interactive sign-in owns the same store boundary as launch auto-login.
// Both normal and forced access-token readers must stay retryable until
// that owner either publishes the session or fails it.
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let client = GateableValidationAuthClient(user: user)
let store = FakeKeyValueStore()
let coordinator = AuthCoordinator(
client: client,
sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"),
userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"),
teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"),
anchor: FakeAnchor(),
config: .test,
launch: .plain()
)
await client.armCredentialGate()
let signIn = Task {
try await coordinator.signInWithPassword(email: "[email protected]", password: "pw")
}
await client.credentialDidPark()
await #expect(throws: AuthError.networkError) {
_ = try await coordinator.accessToken()
}
await #expect(throws: AuthError.networkError) {
_ = try await coordinator.forceRefreshAccessToken()
}
await client.releaseParkedCredential()
try await signIn.value
#expect(coordinator.isAuthenticated)
#expect(coordinator.currentUser == user)
#expect(store.bool(forKey: "has_tokens"))
#expect(await client.accessToken() != nil)
#expect(await client.refreshToken() != nil)
#expect(coordinator.activeSignInFlows.isEmpty)
}
@Test func signOutInvalidatesParkedSignInOwnershipForTokenReads() async throws {
// A parked sign-in stops owning the store as soon as sign-out advances
// the generation and sign-out epoch. Token reads after local-first clear
// must report the definitive signed-out state, not stay retryable until
// the stale exchange happens to quiesce.
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let client = GateableValidationAuthClient(user: user)
let store = FakeKeyValueStore()
let coordinator = AuthCoordinator(
client: client,
sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"),
userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"),
teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"),
anchor: FakeAnchor(),
config: .test,
launch: .plain()
)
await client.armCredentialGate()
let signIn = Task {
try await coordinator.signInWithPassword(email: "[email protected]", password: "pw")
}
await client.credentialDidPark()
await coordinator.signOut()
await #expect(throws: AuthError.unauthorized) {
_ = try await coordinator.accessToken()
}
await client.releaseParkedCredential()
await #expect(throws: AuthError.cancelled) {
try await signIn.value
}
#expect(!coordinator.isAuthenticated)
#expect(coordinator.currentUser == nil)
#expect(!store.bool(forKey: "has_tokens"))
#expect(coordinator.activeSignInFlows.isEmpty)
}
}
@@ -32,7 +32,12 @@ struct HostBrowserSignInFlowHarness {
browserAttemptTimeout: TimeInterval = 5 * 60,
slowSignInThreshold: TimeInterval = 30,
clock: (any Clock<Duration>)? = nil,
openSucceeds: Bool = true
openSucceeds: Bool = true,
beginSignOut: @escaping @MainActor @Sendable () -> Void = {},
onSignedOut: @escaping @Sendable (
_ accessToken: String?,
_ refreshToken: String?
) async -> Void = { _, _ in }
) {
let store = FakeKeyValueStore()
// The fake client reads and clears the SAME token store the flow seeds,
@@ -61,7 +66,9 @@ struct HostBrowserSignInFlowHarness {
openExternalURL: { openedURLRecorder.append($0) },
clock: clock ?? ContinuousClock(),
browserAttemptTimeout: browserAttemptTimeout,
slowSignInThreshold: slowSignInThreshold
slowSignInThreshold: slowSignInThreshold,
beginSignOut: beginSignOut,
onSignedOut: onSignedOut
)
self.coordinator = coordinator
self.client = client
@@ -8,6 +8,62 @@ import Testing
/// the sign-out-vs-callback race guards, deadlines, and attempt cancellation.
@MainActor
@Suite(.serialized) struct HostBrowserSignInFlowTests {
@Test func concurrentSignOutBeginsOnceAndClearsLocalAuthBeforeTeardownCompletes() async {
let teardownStarted = TestPhaseSignal()
let teardownBlocker = TestContinuationBlocker()
let recorder = HostBrowserSignOutOrderingRecorder()
let harness = HostBrowserSignInFlowHarness(
beginSignOut: {
recorder.record(.prepare)
},
onSignedOut: { _, _ in
await recorder.record(.signedOut)
await teardownStarted.markStarted()
await teardownBlocker.wait()
}
)
await harness.tokenStore.setTokens(
accessToken: "access-1",
refreshToken: "refresh-1"
)
let firstSignOut = Task { await harness.flow.signOut() }
await teardownStarted.waitUntilStarted()
let racedSignOut = Task { await harness.flow.signOut() }
#expect(await harness.tokenStore.getStoredAccessToken() == nil)
#expect(await harness.tokenStore.getStoredRefreshToken() == nil)
#expect(recorder.values() == [.prepare, .signedOut])
await teardownBlocker.release()
await firstSignOut.value
await racedSignOut.value
#expect(recorder.values() == [.prepare, .signedOut])
#expect(await harness.tokenStore.getStoredAccessToken() == nil)
#expect(await harness.tokenStore.getStoredRefreshToken() == nil)
}
@Test func signOutRunsCompositionTeardownWithCapturedCredentials() async {
let recorder = HostBrowserSignOutHookRecorder()
let harness = HostBrowserSignInFlowHarness(
onSignedOut: { accessToken, refreshToken in
await recorder.record(accessToken, refreshToken)
}
)
await harness.tokenStore.setTokens(
accessToken: "access-1",
refreshToken: "refresh-1"
)
await harness.flow.signOut()
let calls = await recorder.values()
#expect(calls.count == 1)
#expect(calls.first?.accessToken == "access-1")
#expect(calls.first?.refreshToken == "refresh-1")
}
@Test func browserCallbackSignsInAndSeedsTokens() async throws {
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let harness = HostBrowserSignInFlowHarness(user: user)
@@ -427,49 +483,4 @@ import Testing
#expect(harness.flow.lastFailure == nil)
}
@Test func signOutDuringCallbackValidationStillRevokesWithCapturedCredentials() async {
// flow.signOut() advances the flow's sign-out generation BEFORE the
// coordinator captures the teardown credentials with raw store reads.
// If the parked callback validation resumes inside that capture
// window, a flow-side seed clear runs first, the capture reads an
// empty store, and the best-effort server teardown (push unregister,
// session revocation) silently loses its credentials even though the
// device is online. The coordinator owns the local clear AFTER the
// capture; the flow must not clear the shared store underneath it.
let clock = ManualTestClock()
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let harness = HostBrowserSignInFlowHarness(user: user, clock: clock)
await harness.client.closeUserGate()
let attempt = Task { await harness.flow.signIn(timeout: 60) }
await harness.waitForSession()
await clock.waitUntilSleepers(count: 3)
harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0])))
await harness.waitForPendingUserRequest()
// Sign-out parks inside its credential capture, before its local
// clear.
await harness.client.armStoredAccessTokenGate()
let signOut = Task { await harness.flow.signOut() }
await harness.client.storedAccessTokenDidPark()
// The parked validation resumes and fails as cancelled while
// sign-out is still inside the capture window.
await harness.client.openUserGate()
clock.advance(by: .seconds(60))
#expect(await attempt.value == false)
// Sign-out proceeds: capture, local-first clear, bounded revocation.
await harness.client.releaseStoredAccessTokenGate()
await signOut.value
#expect(harness.coordinator.isAuthenticated == false)
#expect(await harness.tokenStore.getStoredRefreshToken() == nil)
#expect(await harness.tokenStore.getStoredAccessToken() == nil)
// The teardown must authenticate as the signed-out session.
let revoked = await harness.client.revokedCredentials
#expect(revoked.count == 1)
#expect(revoked.first?.access == "access-1")
#expect(revoked.first?.refresh == "refresh-1")
}
}
@@ -0,0 +1,73 @@
import CMUXAuthCore
import Foundation
import Testing
@testable import CmuxAuthRuntime
@MainActor
struct HostBrowserSignOutTests {
@Test func signOutDuringCallbackValidationStillRevokesWithCapturedCredentials() async {
// If callback validation resumes inside credential capture, the flow
// must leave local clearing to the coordinator so teardown retains the
// exact signed-out credentials.
let clock = ManualTestClock()
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let harness = HostBrowserSignInFlowHarness(user: user, clock: clock)
await harness.client.closeUserGate()
let attempt = Task { await harness.flow.signIn(timeout: 60) }
await harness.waitForSession()
await clock.waitUntilSleepers(count: 3)
harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0])))
await harness.waitForPendingUserRequest()
await harness.client.armStoredAccessTokenGate()
let signOut = Task { await harness.flow.signOut() }
await harness.client.storedAccessTokenDidPark()
await harness.client.openUserGate()
clock.advance(by: .seconds(60))
#expect(await attempt.value == false)
await harness.client.releaseStoredAccessTokenGate()
await signOut.value
#expect(harness.coordinator.isAuthenticated == false)
#expect(await harness.tokenStore.getStoredRefreshToken() == nil)
#expect(await harness.tokenStore.getStoredAccessToken() == nil)
let revoked = await harness.client.revokedCredentials
#expect(revoked.count == 1)
#expect(revoked.first?.access == "access-1")
#expect(revoked.first?.refresh == "refresh-1")
}
}
actor HostBrowserSignOutHookRecorder {
typealias Call = (accessToken: String?, refreshToken: String?)
private var calls: [Call] = []
func record(_ accessToken: String?, _ refreshToken: String?) {
calls.append((accessToken, refreshToken))
}
func values() -> [Call] {
calls
}
}
@MainActor
final class HostBrowserSignOutOrderingRecorder {
enum Event: Equatable, Sendable {
case prepare
case signedOut
}
private var events: [Event] = []
func record(_ event: Event) {
events.append(event)
}
func values() -> [Event] {
events
}
}
@@ -0,0 +1,15 @@
{
"originHash" : "d4377183d37f5e8c6c0b88d2f9ab4f45898712cb2e4c4ac4ea22b35db763267c",
"pins" : [
{
"identity" : "iroh-ffi",
"kind" : "remoteSourceControl",
"location" : "https://github.com/manaflow-ai/iroh-ffi.git",
"state" : {
"revision" : "42ed4ceb19922af9eb736253142b831f73029d9f",
"version" : "1.0.2-cmux.1"
}
}
],
"version" : 3
}
@@ -0,0 +1,54 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CmuxIrohTransport",
platforms: [
.iOS(.v18),
.macOS(.v14),
],
products: [
.library(
name: "CmuxIrohTransport",
targets: ["CmuxIrohTransport"]
),
],
dependencies: [
.package(path: "../CMUXMobileCore"),
.package(
url: "https://github.com/manaflow-ai/iroh-ffi.git",
exact: "1.0.2-cmux.1"
),
],
targets: [
.target(
name: "CmuxIrohTransport",
dependencies: [
"CMUXMobileCore",
.product(name: "IrohLib", package: "iroh-ffi"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
],
linkerSettings: [
.linkedFramework("Security"),
]
),
.testTarget(
name: "CmuxIrohTransportTests",
dependencies: [
"CmuxIrohTransport",
"CMUXMobileCore",
.product(name: "IrohLib", package: "iroh-ffi"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
),
]
)
@@ -0,0 +1,25 @@
# CmuxIrohTransport
`CmuxIrohTransport` owns cmux's versioned Iroh application protocol. The package
is shared by macOS and iOS and keeps generated Iroh FFI handles behind injected
transport seams.
The first bytes on every QUIC stream are a bounded binary header identifying
the lane. A connection begins with an authenticated control stream. Subsequent
server-event, terminal, and artifact streams reuse the authenticated QUIC
connection and retain independent cancellation and backpressure.
Mac admission verifies signed authority and the live QUIC EndpointID before
broker traffic. First-time offline pairing also verifies and consumes its
one-use proof before discovery. Authenticated refreshes are coalesced and reused
for at most 30 seconds. Confirmed revocation closes only the affected
connection, while exact connectivity failure preserves local authority until
its signed expiry. Cached grants therefore retain a maximum seven-day
disconnected revoke window; first-pair sessions use the earlier of their two
one-day attestation expiries.
Run the package behavior tests without launching either app:
```sh
swift test --package-path Packages/Shared/CmuxIrohTransport
```
@@ -0,0 +1,171 @@
/// Complete account-synchronized relay configuration.
///
/// The active mode, dormant managed selection, and saved custom definitions
/// have independent lifecycles. Switching modes therefore never destroys the
/// configuration a user may switch back to later.
public struct CmxIrohAccountRelayConfiguration: Codable, Equatable, Sendable {
public enum Mode: String, Codable, Equatable, Sendable {
case automatic
case managed
case custom
}
private enum CodingKeys: String, CodingKey {
case mode
case selectedManagedRelayIDs = "selectedManagedRelayIds"
case customRelays
}
public let mode: Mode
public let selectedManagedRelayIDs: Set<String>
public let customRelays: [CmxIrohCustomRelayDefinition]
public init(
mode: Mode,
selectedManagedRelayIDs: Set<String>,
customRelays: [CmxIrohCustomRelayDefinition]
) throws {
guard selectedManagedRelayIDs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount,
selectedManagedRelayIDs.allSatisfy(Self.isSafeID),
customRelays.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount,
Set(customRelays.map(\.id)).count == customRelays.count,
Set(customRelays.map(\.url)).count == customRelays.count,
selectedManagedRelayIDs.isDisjoint(with: Set(customRelays.map(\.id))),
mode != .managed || !selectedManagedRelayIDs.isEmpty,
mode != .custom || !customRelays.isEmpty else {
throw CmxIrohRelayPolicyError.invalidSelection
}
self.mode = mode
self.selectedManagedRelayIDs = selectedManagedRelayIDs
self.customRelays = customRelays
}
/// Safe empty configuration used before an account has saved a preference.
public static var automatic: Self {
Self(
validatedMode: .automatic,
selectedManagedRelayIDs: [],
customRelays: []
)
}
public static func managed(_ relayIDs: Set<String>) throws -> Self {
try Self(mode: .managed, selectedManagedRelayIDs: relayIDs, customRelays: [])
}
public static func custom(_ relays: [CmxIrohCustomRelayDefinition]) throws -> Self {
try Self(mode: .custom, selectedManagedRelayIDs: [], customRelays: relays)
}
/// Active preference derived from the independent configuration fields.
public var activePreference: CmxIrohAccountRelayPreference {
switch mode {
case .automatic:
.automatic
case .managed:
.managed(selectedManagedRelayIDs)
case .custom:
.custom(customRelays)
}
}
/// Replaces only the active mode or managed selection.
public func updatingActivePreference(
_ preference: CmxIrohAccountRelayPreference
) throws -> Self {
switch preference {
case .automatic:
try Self(
mode: .automatic,
selectedManagedRelayIDs: selectedManagedRelayIDs,
customRelays: customRelays
)
case let .managed(relayIDs):
try Self(
mode: .managed,
selectedManagedRelayIDs: relayIDs,
customRelays: customRelays
)
case .custom:
try Self(
mode: .custom,
selectedManagedRelayIDs: selectedManagedRelayIDs,
customRelays: customRelays
)
}
}
/// Replaces saved custom metadata without implicitly changing active mode.
/// Removing the final active custom relay safely returns to automatic mode.
public func replacingCustomRelays(
_ relays: [CmxIrohCustomRelayDefinition]
) throws -> Self {
try Self(
mode: mode == .custom && relays.isEmpty ? .automatic : mode,
selectedManagedRelayIDs: selectedManagedRelayIDs,
customRelays: relays
)
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let mode = try container.decode(Mode.self, forKey: .mode)
let orderedManagedIDs = try container.decodeIfPresent(
[String].self,
forKey: .selectedManagedRelayIDs
) ?? []
guard Set(orderedManagedIDs).count == orderedManagedIDs.count else {
throw DecodingError.dataCorruptedError(
forKey: .selectedManagedRelayIDs,
in: container,
debugDescription: "Duplicate managed relay identifier"
)
}
do {
try self.init(
mode: mode,
selectedManagedRelayIDs: Set(orderedManagedIDs),
customRelays: container.decodeIfPresent(
[CmxIrohCustomRelayDefinition].self,
forKey: .customRelays
) ?? []
)
} catch {
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath, debugDescription: "Invalid relay configuration")
)
}
}
public func encode(to encoder: any Encoder) throws {
_ = try Self(
mode: mode,
selectedManagedRelayIDs: selectedManagedRelayIDs,
customRelays: customRelays
)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(mode, forKey: .mode)
try container.encode(selectedManagedRelayIDs.sorted(), forKey: .selectedManagedRelayIDs)
try container.encode(customRelays, forKey: .customRelays)
}
private static func isSafeID(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 95].contains(byte)
}
}
private init(
validatedMode mode: Mode,
selectedManagedRelayIDs: Set<String>,
customRelays: [CmxIrohCustomRelayDefinition]
) {
self.mode = mode
self.selectedManagedRelayIDs = selectedManagedRelayIDs
self.customRelays = customRelays
}
}
@@ -0,0 +1,101 @@
/// Account-synchronized preference for managed or user-defined relays.
public enum CmxIrohAccountRelayPreference: Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case mode
case relayIDs = "selectedManagedRelayIds"
case relays = "customRelays"
}
private enum Mode: String, Codable {
case automatic
case managed
case custom
}
/// Allow every relay authorized by the latest verified managed policy.
case automatic
/// Allow only the listed stable identifiers from the managed policy.
case managed(Set<String>)
/// Use only the listed custom relays, with no managed-provider fallback.
case custom([CmxIrohCustomRelayDefinition])
/// Decodes and validates one account preference.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(Mode.self, forKey: .mode) {
case .automatic:
self = .automatic
case .managed:
let orderedIDs = try container.decode([String].self, forKey: .relayIDs)
let ids = Set(orderedIDs)
guard ids.count == orderedIDs.count, Self.isValidManagedIDs(ids) else {
throw DecodingError.dataCorruptedError(
forKey: .relayIDs,
in: container,
debugDescription: "Invalid managed relay selection"
)
}
self = .managed(ids)
case .custom:
let relays = try container.decode([CmxIrohCustomRelayDefinition].self, forKey: .relays)
guard Self.isValidCustomRelays(relays) else {
throw DecodingError.dataCorruptedError(
forKey: .relays,
in: container,
debugDescription: "Invalid custom relay selection"
)
}
self = .custom(relays)
}
}
/// Encodes the canonical broker preference schema.
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .automatic:
try container.encode(Mode.automatic, forKey: .mode)
case let .managed(ids):
guard Self.isValidManagedIDs(ids) else {
throw EncodingError.invalidValue(
self,
.init(codingPath: encoder.codingPath, debugDescription: "Invalid managed selection")
)
}
try container.encode(Mode.managed, forKey: .mode)
try container.encode(ids.sorted(), forKey: .relayIDs)
case let .custom(relays):
guard Self.isValidCustomRelays(relays) else {
throw EncodingError.invalidValue(
self,
.init(codingPath: encoder.codingPath, debugDescription: "Invalid custom selection")
)
}
try container.encode(Mode.custom, forKey: .mode)
try container.encode(relays, forKey: .relays)
}
}
private static func isValidManagedIDs(_ ids: Set<String>) -> Bool {
(1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(ids.count)
&& ids.allSatisfy(isSafeID)
}
private static func isValidCustomRelays(_ relays: [CmxIrohCustomRelayDefinition]) -> Bool {
(1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(relays.count)
&& Set(relays.map(\.id)).count == relays.count
&& Set(relays.map(\.url)).count == relays.count
}
private static func isSafeID(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 95].contains(byte)
}
}
}
@@ -0,0 +1,38 @@
/// Admission policy for active Iroh sessions owned by one broker binding.
///
/// The host keeps the authoritative connection collection. This value only
/// evaluates that collection, so quota state cannot drift when a connection is
/// closed, revoked, or removed in bulk.
public struct CmxIrohActiveBindingConnectionQuota: Sendable {
/// Two sessions permit a live client to overlap its replacement connection
/// during route migration or reconnect without monopolizing the host pool.
public static let recommendedMaximumActiveConnectionsPerBinding = 2
public let maximumActiveConnectionsPerBinding: Int
public init(
maximumActiveConnectionsPerBinding: Int = Self
.recommendedMaximumActiveConnectionsPerBinding
) {
precondition(maximumActiveConnectionsPerBinding > 0)
self.maximumActiveConnectionsPerBinding = maximumActiveConnectionsPerBinding
}
/// Returns whether one more session for `bindingID` fits within the quota.
///
/// The caller must evaluate and insert while holding the same synchronization
/// boundary so concurrent admissions cannot both consume the final slot.
public func allowsAdmission<ActiveBindingIDs: Sequence>(
for bindingID: String,
activeBindingIDs: ActiveBindingIDs
) -> Bool where ActiveBindingIDs.Element == String {
var matchingConnectionCount = 0
for activeBindingID in activeBindingIDs where activeBindingID == bindingID {
matchingConnectionCount += 1
if matchingConnectionCount >= maximumActiveConnectionsPerBinding {
return false
}
}
return true
}
}
@@ -0,0 +1,128 @@
public import Foundation
/// Encodes fixed eight-byte frames for the acknowledged admission barrier.
public struct CmxIrohAdmissionAckCodec: Sendable {
/// The exact number of bytes consumed by every admission frame.
public static let frameByteCount = 8
private static let magic = Data("CMXA".utf8)
private static let version: UInt8 = 1
/// Creates an admission-frame codec.
public init() {}
/// Encodes the server's initial admission decision.
///
/// - Parameter decision: The accepted or coded-denial result.
/// - Returns: Exactly ``frameByteCount`` bytes.
public func encode(_ decision: CmxIrohAdmissionDecision) -> Data {
let frame: CmxIrohAdmissionFrame = switch decision {
case .accepted:
.acceptedPendingNatTraversal
case let .denied(code):
.denied(code: code)
}
return encodeFrame(frame)
}
/// Encodes one admission-barrier frame.
///
/// - Parameter frame: The role-specific admission frame.
/// - Returns: Exactly ``frameByteCount`` bytes.
public func encodeFrame(_ frame: CmxIrohAdmissionFrame) -> Data {
let status: UInt8
let code: UInt16
switch frame {
case .acceptedPendingNatTraversal:
status = 0
code = 0
case .acceptedRelayOnly:
status = 4
code = 0
case let .denied(denialCode):
status = 1
code = denialCode
case .clientReady:
status = 2
code = 0
case .serverReady:
status = 3
code = 0
}
var frame = Self.magic
frame.append(Self.version)
frame.append(status)
let bigEndian = code.bigEndian
withUnsafeBytes(of: bigEndian) { frame.append(contentsOf: $0) }
return frame
}
/// Decodes the first complete server decision.
///
/// - Parameter data: Bytes beginning at the server decision.
/// - Returns: The validated decision.
/// - Throws: ``CmxIrohAdmissionAckCodecError`` for malformed input.
public func decodePrefix(_ data: Data) throws -> CmxIrohAdmissionDecision {
switch try decodeFramePrefix(data) {
case .acceptedPendingNatTraversal, .acceptedRelayOnly:
return .accepted
case let .denied(code):
return .denied(code: code)
case let frame:
throw CmxIrohAdmissionAckCodecError.invalidDecisionFrame(frame)
}
}
/// Decodes the first complete role-specific admission frame.
///
/// - Parameter data: Bytes beginning at the admission frame.
/// - Returns: The validated frame.
/// - Throws: ``CmxIrohAdmissionAckCodecError`` for malformed input.
public func decodeFramePrefix(_ data: Data) throws -> CmxIrohAdmissionFrame {
guard data.count >= Self.frameByteCount else {
throw CmxIrohAdmissionAckCodecError.incompleteFrame
}
var cursor = CmxIrohBinaryCursor(data: data.prefix(Self.frameByteCount))
guard try cursor.readData(byteCount: Self.magic.count) == Self.magic else {
throw CmxIrohAdmissionAckCodecError.invalidMagic
}
let version = try cursor.readUInt8()
guard version == Self.version else {
throw CmxIrohAdmissionAckCodecError.unsupportedVersion(version)
}
let status = try cursor.readUInt8()
let code = try cursor.readUInt16()
switch status {
case 0:
guard code == 0 else {
throw CmxIrohAdmissionAckCodecError.invalidAcceptedCode(code)
}
return .acceptedPendingNatTraversal
case 1:
return .denied(code: code)
case 2:
guard code == 0 else {
throw CmxIrohAdmissionAckCodecError.invalidReadyCode(
status: status,
code: code
)
}
return .clientReady
case 3:
guard code == 0 else {
throw CmxIrohAdmissionAckCodecError.invalidReadyCode(
status: status,
code: code
)
}
return .serverReady
case 4:
guard code == 0 else {
throw CmxIrohAdmissionAckCodecError.invalidAcceptedCode(code)
}
return .acceptedRelayOnly
default:
throw CmxIrohAdmissionAckCodecError.invalidStatus(status)
}
}
}
@@ -0,0 +1,23 @@
/// Binary framing failures for a control-stream admission frame.
public enum CmxIrohAdmissionAckCodecError: Error, Equatable, Sendable {
/// Fewer than eight response bytes are available.
case incompleteFrame
/// The response did not begin with the cmux admission marker.
case invalidMagic
/// The response version is unsupported.
case unsupportedVersion(UInt8)
/// The response status discriminator is unknown.
case invalidStatus(UInt8)
/// An accepted response carried a nonzero denial code.
case invalidAcceptedCode(UInt16)
/// A ready frame carried a nonzero code.
case invalidReadyCode(status: UInt8, code: UInt16)
/// A ready frame appeared where an initial server decision was required.
case invalidDecisionFrame(CmxIrohAdmissionFrame)
}
@@ -0,0 +1,19 @@
/// Local authorization result for an authenticated Iroh connection.
public enum CmxIrohAdmissionAuthorization: Equatable, Sendable {
/// The exact TLS-bound iOS binding may use the application transport.
case accepted(
CmxIrohAdmittedPeer,
onlineLease: CmxIrohOnlineAdmissionLease?
)
/// Admission failed with a non-sensitive protocol code.
case denied(code: UInt16)
var wireDecision: CmxIrohAdmissionDecision {
switch self {
case .accepted:
.accepted
case let .denied(code):
.denied(code: code)
}
}
}
@@ -0,0 +1,9 @@
public import CMUXMobileCore
/// Fail-closed authorization seam for the first control stream on a connection.
public protocol CmxIrohAdmissionAuthorizing: Sendable {
func authorize(
credential: CmxIrohAdmissionCredential,
authenticatedPeerID: CmxIrohPeerIdentity
) async -> CmxIrohAdmissionAuthorization
}
@@ -0,0 +1,127 @@
public import CMUXMobileCore
public import Foundation
/// Mac admission policy combining online grants, offline sessions, and local revoke state.
public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing {
private let offlineSessions: CmxIrohOfflinePairingSessions
private let onlineRegistry: CmxIrohOnlineAdmissionRegistry
private let now: @Sendable () -> Date
private var acceptor: CmxIrohGrantPeer
private var pairingEnabled: Bool
private var revokedBindingIDs: Set<String> = []
private var policyRevision: UInt64 = 0
private var policyMutationCount = 0
public init(
acceptor: CmxIrohGrantPeer,
pairingEnabled: Bool,
offlineSessions: CmxIrohOfflinePairingSessions,
onlineRegistry: CmxIrohOnlineAdmissionRegistry,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.acceptor = acceptor
self.pairingEnabled = pairingEnabled
self.offlineSessions = offlineSessions
self.onlineRegistry = onlineRegistry
self.now = now
}
/// Atomically replaces authenticated broker policy after a registry refresh.
public func update(
keys: CmxIrohGrantVerificationKeySet,
acceptor: CmxIrohGrantPeer,
pairingEnabled: Bool
) async {
beginPolicyMutation()
defer { endPolicyMutation() }
await onlineRegistry.update(keys: keys, acceptor: acceptor)
await offlineSessions.setPairingEnabled(pairingEnabled)
self.acceptor = acceptor
self.pairingEnabled = pairingEnabled
}
/// Replaces the root-verified managed fleet without restarting admission.
func updateManagedRelayURLs(_ relayURLs: Set<String>) async {
beginPolicyMutation()
defer { endPolicyMutation() }
await onlineRegistry.updateManagedRelayURLs(relayURLs)
}
/// Applies local revoke before the backend round trip completes.
public func revoke(bindingID: String) async {
beginPolicyMutation()
defer { endPolicyMutation() }
revokedBindingIDs.insert(bindingID)
await offlineSessions.revoke(bindingID: bindingID)
await onlineRegistry.revoke(bindingID: bindingID)
}
public func authorize(
credential: CmxIrohAdmissionCredential,
authenticatedPeerID: CmxIrohPeerIdentity
) async -> CmxIrohAdmissionAuthorization {
guard policyMutationCount == 0,
pairingEnabled,
acceptor.platform == .mac,
!revokedBindingIDs.contains(acceptor.bindingID) else {
return .denied(code: 1)
}
let revision = policyRevision
do {
switch credential.kind {
case .pairGrant:
guard let token = credential.pairGrantToken else {
return .denied(code: 1)
}
switch await onlineRegistry.authorizePairGrant(
token,
authenticatedPeerID: authenticatedPeerID
) {
case let .accepted(lease):
return checkedAuthorization(lease, revision: revision)
case .denied:
return .denied(code: 1)
}
case .offlinePairing:
let pair = try await offlineSessions.verifyAndConsume(
credential: credential,
authenticatedPeerID: authenticatedPeerID,
now: now()
)
guard policyMutationCount == 0, policyRevision == revision else {
return .denied(code: 1)
}
switch await onlineRegistry.authorizeOfflinePair(pair) {
case let .accepted(lease):
return checkedAuthorization(lease, revision: revision)
case .denied:
return .denied(code: 1)
}
}
} catch {
return .denied(code: 1)
}
}
private func checkedAuthorization(
_ lease: CmxIrohOnlineAdmissionLease,
revision: UInt64
) -> CmxIrohAdmissionAuthorization {
guard policyMutationCount == 0,
policyRevision == revision,
!revokedBindingIDs.contains(lease.peer.bindingID),
!revokedBindingIDs.contains(acceptor.bindingID) else {
return .denied(code: 1)
}
return .accepted(lease.peer, onlineLease: lease)
}
private func beginPolicyMutation() {
policyRevision &+= 1
policyMutationCount += 1
}
private func endPolicyMutation() {
policyMutationCount -= 1
}
}
@@ -0,0 +1,100 @@
public import Foundation
/// A bounded admission proof sent only on the first control stream.
public struct CmxIrohAdmissionCredential: Equatable, Sendable {
/// The credential's validation path.
public let kind: CmxIrohAdmissionCredentialKind
/// The backend-signed pair grant for ``CmxIrohAdmissionCredentialKind/pairGrant``.
public let pairGrantToken: String?
/// The caller's backend-signed endpoint attestation for offline pairing.
public let endpointAttestation: String?
/// The one-use invitation selected by a local pairing QR.
public let invitationID: CmxIrohResourceID?
/// The 32-byte proof derived from the invitation secret and both EndpointIDs.
public let offlineProof: Data?
private init(
kind: CmxIrohAdmissionCredentialKind,
pairGrantToken: String?,
endpointAttestation: String?,
invitationID: CmxIrohResourceID?,
offlineProof: Data?
) {
self.kind = kind
self.pairGrantToken = pairGrantToken
self.endpointAttestation = endpointAttestation
self.invitationID = invitationID
self.offlineProof = offlineProof
}
/// Creates a credential from a backend-signed pair grant.
///
/// - Parameter token: A compact EdDSA JWS no larger than 12 KiB.
/// - Returns: A validated pair-grant credential.
/// - Throws: ``CmxIrohAdmissionCredentialError/invalidSignedToken`` for malformed input.
public static func pairGrant(_ token: String) throws -> CmxIrohAdmissionCredential {
guard Self.isValidCompactJWS(token) else {
throw CmxIrohAdmissionCredentialError.invalidSignedToken
}
return CmxIrohAdmissionCredential(
kind: .pairGrant,
pairGrantToken: token,
endpointAttestation: nil,
invitationID: nil,
offlineProof: nil
)
}
/// Creates a first-pair credential that preserves same-account authorization offline.
///
/// QR possession supplies the one-use invitation. The endpoint attestation
/// independently proves the caller's cached Stack account binding.
///
/// - Parameters:
/// - endpointAttestation: A compact backend-signed endpoint-attestation JWS.
/// - invitationID: The opaque one-use invitation identifier from the QR.
/// - proof: A 32-byte proof bound to both EndpointIDs.
/// - Returns: A validated offline-pairing credential.
/// - Throws: ``CmxIrohAdmissionCredentialError`` when a field is malformed.
public static func offlinePairing(
endpointAttestation: String,
invitationID: CmxIrohResourceID,
proof: Data
) throws -> CmxIrohAdmissionCredential {
guard Self.isValidCompactJWS(endpointAttestation) else {
throw CmxIrohAdmissionCredentialError.invalidSignedToken
}
guard proof.count == 32 else {
throw CmxIrohAdmissionCredentialError.invalidOfflineProofLength(proof.count)
}
return CmxIrohAdmissionCredential(
kind: .offlinePairing,
pairGrantToken: nil,
endpointAttestation: endpointAttestation,
invitationID: invitationID,
offlineProof: proof
)
}
private static func isValidCompactJWS(_ value: String) -> Bool {
let bytes = Array(value.utf8)
guard (5 ... 12 * 1_024).contains(bytes.count) else { return false }
let segments = value.split(separator: ".", omittingEmptySubsequences: false)
guard segments.count == 3, segments.allSatisfy({ !$0.isEmpty }) else { return false }
return segments.joined().utf8.allSatisfy { byte in
switch byte {
case UInt8(ascii: "A") ... UInt8(ascii: "Z"),
UInt8(ascii: "a") ... UInt8(ascii: "z"),
UInt8(ascii: "0") ... UInt8(ascii: "9"),
UInt8(ascii: "_"), UInt8(ascii: "-"):
true
default:
false
}
}
}
}
@@ -0,0 +1,8 @@
/// Validation failures for Iroh admission credentials.
public enum CmxIrohAdmissionCredentialError: Error, Equatable, Sendable {
/// A compact JWS is missing, malformed, or exceeds its wire limit.
case invalidSignedToken
/// The offline proof must contain exactly 32 bytes.
case invalidOfflineProofLength(Int)
}
@@ -0,0 +1,8 @@
/// The proof used to admit an Iroh control stream.
public enum CmxIrohAdmissionCredentialKind: Equatable, Sendable {
/// A backend-signed grant binding the two exact EndpointIDs.
case pairGrant
/// Cached same-account endpoint attestation plus a one-use local invitation.
case offlinePairing
}
@@ -0,0 +1,8 @@
/// The server's initial response to a control-stream admission proof.
public enum CmxIrohAdmissionDecision: Equatable, Sendable {
/// The proof passed, but application lanes await the NAT authorization barrier.
case accepted
/// Admission failed with a non-sensitive protocol code.
case denied(code: UInt16)
}
@@ -0,0 +1,18 @@
/// One fixed-size control frame in the acknowledged admission barrier.
public enum CmxIrohAdmissionFrame: Equatable, Sendable {
/// The server accepted the credential, but NAT traversal remains gated.
case acceptedPendingNatTraversal
/// The server accepted the credential and intentionally keeps this
/// connection relay-bound without authorizing NAT traversal.
case acceptedRelayOnly
/// The server denied admission with a non-sensitive protocol code.
case denied(code: UInt16)
/// The client authorized NAT traversal on its exact connection.
case clientReady
/// The server authorized NAT traversal and is ready for application lanes.
case serverReady
}
@@ -0,0 +1,47 @@
public import CMUXMobileCore
/// The exact iOS binding admitted by the Mac after TLS and grant verification.
public struct CmxIrohAdmittedPeer: Equatable, Sendable {
public let bindingID: String
public let deviceID: String
public let endpointID: CmxIrohPeerIdentity
public let identityGeneration: Int
public let platform: CmxIrohPlatform
init(
bindingID: String,
deviceID: String,
endpointID: CmxIrohPeerIdentity,
identityGeneration: Int,
platform: CmxIrohPlatform
) {
self.bindingID = bindingID
self.deviceID = deviceID
self.endpointID = endpointID
self.identityGeneration = identityGeneration
self.platform = platform
}
/// Copies a peer tuple that a verifier has already authenticated.
/// Construction alone does not grant access; server admission also binds
/// this tuple to the live QUIC TLS identity before exposing it to the host.
public init(peer: CmxIrohGrantPeer) {
self.init(
bindingID: peer.bindingID,
deviceID: peer.deviceID,
endpointID: peer.endpointID,
identityGeneration: peer.identityGeneration,
platform: peer.platform
)
}
init(attestation: CmxIrohEndpointAttestationClaims) {
self.init(
bindingID: attestation.bindingID,
deviceID: attestation.deviceID,
endpointID: attestation.endpointID,
identityGeneration: attestation.identityGeneration,
platform: attestation.platform
)
}
}
@@ -0,0 +1,47 @@
public import CMUXMobileCore
/// One authenticated iOS peer connection exposed to the Mac application layer.
///
/// The control transport preserves the existing mobile RPC protocol while the
/// lane methods expose independent terminal, event, and artifact streams on the
/// same admitted QUIC connection. Only ``CmxIrohHostRuntime`` constructs this
/// value, after binding the admission credential to the live TLS EndpointID.
public struct CmxIrohAdmittedServerSession: Sendable {
/// The exact iOS binding authenticated for this connection.
public let peer: CmxIrohAdmittedPeer
/// The existing mobile RPC byte stream on the connection's control lane.
public let controlTransport: any CmxByteTransport
private let session: CmxIrohServerSession
init(
peer: CmxIrohAdmittedPeer,
session: CmxIrohServerSession
) {
self.peer = peer
self.session = session
controlTransport = CmxIrohServerByteTransport(session: session)
}
/// Accepts one client-created terminal or artifact lane.
public func acceptBidirectionalLane() async throws -> (
lane: CmxIrohLane,
stream: CmxIrohBidirectionalStream
) {
try await session.acceptBidirectionalLane()
}
/// Opens one server-event or artifact lane to the admitted iOS peer.
public func openSendLane(
_ lane: CmxIrohLane,
priority: Int32
) async throws -> any CmxIrohSendStream {
try await session.openSendLane(lane, priority: priority)
}
/// Closes the complete peer connection and every child stream.
public func close() async {
await session.close()
}
}
@@ -0,0 +1,66 @@
import CryptoKit
public import Foundation
/// Persists the broker-facing app-instance UUID for one active account and tag.
public actor CmxIrohAppInstanceRepository {
private static let activeScopeKey = "cmux.iroh.app-instance.scope.v1"
private static let identifierKey = "cmux.iroh.app-instance.id.v1"
private let store: any CmxIrohInstallStateStoring
private let makeUUID: @Sendable () -> UUID
public init(
store: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore(),
makeUUID: @escaping @Sendable () -> UUID = { UUID() }
) {
self.store = store
self.makeUUID = makeUUID
}
/// Returns a stable lowercase UUID and rotates it when account or tag changes.
public func appInstanceID(accountID: String, tag: String) throws -> String {
guard !accountID.isEmpty,
accountID.utf8.count <= 1_024,
Self.isSafeTag(tag) else {
throw CmxIrohIdentityRepositoryError.invalidScope
}
let scope = Self.scope(accountID: accountID, tag: tag)
if store.string(forKey: Self.activeScopeKey) == scope,
let existing = store.string(forKey: Self.identifierKey),
Self.isCanonicalUUID(existing) {
return existing
}
let identifier = makeUUID().uuidString.lowercased()
guard Self.isCanonicalUUID(identifier) else {
throw CmxIrohIdentityRepositoryError.randomGenerationFailed(-1)
}
store.set(scope, forKey: Self.activeScopeKey)
store.set(identifier, forKey: Self.identifierKey)
return identifier
}
/// Removes the active app instance during sign-out or local revocation.
public func deactivate() {
store.set(nil, forKey: Self.activeScopeKey)
store.set(nil, forKey: Self.identifierKey)
}
private static func scope(accountID: String, tag: String) -> String {
let transcript = Data("cmux/iroh/app-instance-scope/v1\0\(accountID)\0\(tag)".utf8)
return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined()
}
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeTag(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -0,0 +1,21 @@
/// The independently readable and writable halves of one bidirectional stream.
public struct CmxIrohBidirectionalStream: Sendable {
/// The peer-to-local stream half.
public let receiveStream: any CmxIrohReceiveStream
/// The local-to-peer stream half.
public let sendStream: any CmxIrohSendStream
/// Creates a bidirectional stream pair.
///
/// - Parameters:
/// - receiveStream: The readable half.
/// - sendStream: The writable half.
public init(
receiveStream: any CmxIrohReceiveStream,
sendStream: any CmxIrohSendStream
) {
self.receiveStream = receiveStream
self.sendStream = sendStream
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Bounds-checked reader for one small Iroh stream-header payload.
struct CmxIrohBinaryCursor {
private let data: Data
private(set) var offset: Int = 0
init(data: Data) {
self.data = data
}
var remainingByteCount: Int {
data.count - offset
}
mutating func readUInt8() throws -> UInt8 {
let bytes = try readData(byteCount: 1)
return bytes[bytes.startIndex]
}
mutating func readUInt16() throws -> UInt16 {
let bytes = try readData(byteCount: 2)
return bytes.reduce(UInt16.zero) { partial, byte in
(partial << 8) | UInt16(byte)
}
}
mutating func readUInt32() throws -> UInt32 {
let bytes = try readData(byteCount: 4)
return bytes.reduce(UInt32.zero) { partial, byte in
(partial << 8) | UInt32(byte)
}
}
mutating func readUInt64() throws -> UInt64 {
let bytes = try readData(byteCount: 8)
return bytes.reduce(UInt64.zero) { partial, byte in
(partial << 8) | UInt64(byte)
}
}
mutating func readData(byteCount: Int) throws -> Data {
guard byteCount >= 0, byteCount <= remainingByteCount else {
throw CmxIrohStreamHeaderCodecError.invalidPayload
}
let start = data.index(data.startIndex, offsetBy: offset)
let end = data.index(start, offsetBy: byteCount)
offset += byteCount
return data[start ..< end]
}
mutating func readString(byteCount: Int) throws -> String {
let bytes = try readData(byteCount: byteCount)
guard let value = String(data: bytes, encoding: .utf8) else {
throw CmxIrohStreamHeaderCodecError.invalidPayload
}
return value
}
}
@@ -0,0 +1,64 @@
import Darwin
/// A numeric IP socket address used for a required Iroh UDP bind.
public struct CmxIrohBindAddress: Equatable, Sendable {
/// The unbracketed IPv4 or IPv6 literal.
public let ipAddress: String
/// The stable, nonzero UDP port.
public let port: UInt16
let socketAddress: String
/// Creates a validated stable bind address.
///
/// Host names and scoped IPv6 literals are intentionally unsupported because
/// the Iroh FFI parses this value as Rust's numeric `SocketAddr`.
///
/// - Parameters:
/// - ipAddress: An unbracketed numeric IPv4 or IPv6 literal.
/// - port: A nonzero UDP port.
/// - Throws: ``CmxIrohBindAddressError`` for unsupported input.
public init(
ipAddress: String,
port: UInt16
) throws {
guard port != 0 else {
throw CmxIrohBindAddressError.zeroPort
}
let bytes = Array(ipAddress.utf8)
guard (1 ... 64).contains(bytes.count),
bytes.allSatisfy({ byte in
(UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a") ... UInt8(ascii: "f")).contains(byte)
|| (UInt8(ascii: "A") ... UInt8(ascii: "F")).contains(byte)
|| byte == UInt8(ascii: ".")
|| byte == UInt8(ascii: ":")
})
else {
throw CmxIrohBindAddressError.invalidIPAddress
}
var ipv4 = in_addr()
let isIPv4 = ipAddress.withCString {
inet_pton(AF_INET, $0, &ipv4) == 1
}
if isIPv4 {
self.ipAddress = ipAddress
self.port = port
socketAddress = "\(ipAddress):\(port)"
return
}
var ipv6 = in6_addr()
let isIPv6 = ipAddress.withCString {
inet_pton(AF_INET6, $0, &ipv6) == 1
}
guard isIPv6 else {
throw CmxIrohBindAddressError.invalidIPAddress
}
self.ipAddress = ipAddress
self.port = port
socketAddress = "[\(ipAddress)]:\(port)"
}
}
@@ -0,0 +1,8 @@
/// Validation failures for a stable Iroh UDP bind address.
public enum CmxIrohBindAddressError: Error, Equatable, Sendable {
/// The host is not an unbracketed numeric IPv4 or IPv6 literal.
case invalidIPAddress
/// Port zero belongs to the endpoint's ephemeral bind policy.
case zeroPort
}
@@ -0,0 +1,9 @@
/// Broker capability for idempotently revoking an account-owned binding.
public protocol CmxIrohBindingRevoking: Sendable {
/// Revokes one binding after authenticating its owning account.
///
/// Repeating a confirmed request for the same binding must remain safe.
///
/// - Parameter bindingID: The broker-owned lowercase binding UUID.
func revoke(bindingID: String) async throws
}
@@ -0,0 +1,36 @@
/// Stable identity for one interface-scoped Bonjour result.
public struct CmxIrohBonjourServiceID: Equatable, Hashable, Sendable {
public let serviceName: String
public let interfaceIndex: UInt32
public init(serviceName: String, interfaceIndex: UInt32) {
self.serviceName = serviceName
self.interfaceIndex = interfaceIndex
}
}
public enum CmxIrohBonjourPublisherEvent: Equatable, Sendable {
case registered(CmxIrohBonjourServiceID)
case policyDenied
case failed(Int32)
}
public enum CmxIrohBonjourBrowserEvent: Equatable, Sendable {
case resolved(CmxIrohBonjourServiceID, CmxIrohBonjourResolvedService)
case removed(CmxIrohBonjourServiceID)
case policyDenied
case failed(Int32)
}
/// Replaces all interface-scoped registrations atomically from the caller's view.
public protocol CmxIrohBonjourPublishing: Sendable {
func events() async -> AsyncStream<CmxIrohBonjourPublisherEvent>
func replace(with advertisements: [CmxIrohLANAdvertisement]) async throws
func stop() async
}
/// Browses only the declared cmux Iroh service and reports resolved TXT records.
public protocol CmxIrohBonjourBrowsing: Sendable {
func events() async -> AsyncStream<CmxIrohBonjourBrowserEvent>
func stop() async
}
@@ -0,0 +1,116 @@
public import CMUXMobileCore
import Foundation
/// The exact broker binding tuple needed to recover one registered endpoint.
public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case bindingID
case deviceID
case appInstanceID
case tag
case platform
case endpointID
case identityGeneration
}
/// The broker-owned binding UUID.
public let bindingID: String
/// The account device UUID associated with the installation.
public let deviceID: String
/// The installation's broker-facing app-instance UUID.
public let appInstanceID: String
/// The build tag registered with the broker.
public let tag: String
/// The endpoint's platform role.
public let platform: CmxIrohPlatform
/// The cryptographic endpoint identity bound by the broker.
public let endpointID: CmxIrohPeerIdentity
/// The monotonically increasing endpoint identity generation.
public let identityGeneration: Int
/// Creates validated broker binding metadata.
///
/// - Parameters:
/// - bindingID: The broker-owned lowercase binding UUID.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - tag: The safe build tag sent during registration.
/// - platform: The endpoint's platform role.
/// - endpointID: The registered Iroh endpoint identity.
/// - identityGeneration: The positive endpoint identity generation.
/// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input.
public init(
bindingID: String,
deviceID: String,
appInstanceID: String,
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
identityGeneration: Int
) throws {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeTag(tag),
(1 ... Int(Int32.max)).contains(identityGeneration) else {
throw CmxIrohBrokerCredentialRepositoryError.invalidBinding
}
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.tag = tag
self.platform = platform
self.endpointID = endpointID
self.identityGeneration = identityGeneration
}
/// Copies the exact recovery tuple from a validated broker response.
///
/// - Parameter binding: The binding returned by registration or discovery.
public init(binding: CmxIrohBrokerBinding) {
bindingID = binding.bindingID
deviceID = binding.deviceID
appInstanceID = binding.appInstanceID
tag = binding.tag
platform = binding.platform
endpointID = binding.endpointID
identityGeneration = binding.identityGeneration
}
/// Decodes and revalidates persisted broker binding metadata.
///
/// - Parameter decoder: The decoder containing one binding tuple.
/// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
bindingID: container.decode(String.self, forKey: .bindingID),
deviceID: container.decode(String.self, forKey: .deviceID),
appInstanceID: container.decode(String.self, forKey: .appInstanceID),
tag: container.decode(String.self, forKey: .tag),
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
identityGeneration: container.decode(Int.self, forKey: .identityGeneration)
)
}
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeTag(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -0,0 +1,366 @@
import CryptoKit
public import Foundation
/// Persists one active account's broker binding and relay capability.
public actor CmxIrohBrokerCredentialRepository {
private static let activeScopeKey = "cmux.iroh.broker-credentials.scope.v1"
private static let bindingKey = "cmux.iroh.broker-credentials.binding.v1"
private let secureStore: any CmxIrohSecureCredentialStoring
private let installState: any CmxIrohInstallStateStoring
private var lifecycleEpoch: UInt64 = 0
private var deactivationCount = 0
private var activeStorageMutationCount = 0
private var storageMutationDrainWaiters: [CheckedContinuation<Void, Never>] = []
/// Creates a broker credential repository with injectable persistence.
///
/// - Parameters:
/// - secureStore: Device-only Keychain storage for relay capabilities.
/// - installState: Non-secret defaults storage for the active binding tuple.
public init(
secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore(),
installState: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore()
) {
self.secureStore = secureStore
self.installState = installState
}
/// Loads binding metadata for one exact account and app instance.
///
/// Activating a different scope first removes all state from the prior
/// account or app instance so returning to it cannot resurrect credentials.
///
/// - Parameters:
/// - accountID: The authenticated account identifier.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - Returns: The active binding metadata, or `nil` when registration is required.
/// - Throws: A scope-validation or secure-storage error.
public func loadBinding(
accountID: String,
appInstanceID: String
) async throws -> CmxIrohBrokerBindingMetadata? {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: appInstanceID,
epoch: epoch
)
return try await loadBinding(
scope: scope,
appInstanceID: appInstanceID,
epoch: epoch
)
}
/// Saves an exact broker binding, invalidating relay credentials if it changed.
///
/// - Parameters:
/// - binding: The binding tuple returned by registration or discovery.
/// - accountID: The authenticated account identifier.
/// - Throws: A scope-validation, encoding, or secure-storage error.
public func saveBinding(
_ binding: CmxIrohBrokerBindingMetadata,
accountID: String
) async throws {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: binding.appInstanceID,
epoch: epoch
)
let existing = try await loadBinding(
scope: scope,
appInstanceID: binding.appInstanceID,
epoch: epoch
)
if existing != binding {
try await deleteSecureRecord(account: scope, epoch: epoch)
}
let encoded = try JSONEncoder().encode(binding)
try requireCurrent(epoch)
installState.set(String(decoding: encoded, as: UTF8.self), forKey: Self.bindingKey)
}
/// Loads a fresh relay credential for one exact binding and managed fleet.
///
/// Stale, corrupt, wrong-binding, and wrong-fleet capabilities are deleted
/// and returned as a cache miss.
///
/// - Parameters:
/// - accountID: The authenticated account identifier.
/// - binding: The exact active binding tuple.
/// - expectedRelayFleet: The complete configured managed relay fleet.
/// - now: The validation time.
/// - Returns: A validated relay credential, or `nil` when a new mint is required.
/// - Throws: A scope-validation or secure-storage error.
public func loadRelayCredential(
accountID: String,
binding: CmxIrohBrokerBindingMetadata,
expectedRelayFleet: Set<String>,
now: Date
) async throws -> CmxIrohRelayTokenResponse? {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: binding.appInstanceID,
epoch: epoch
)
guard try await loadBinding(
scope: scope,
appInstanceID: binding.appInstanceID,
epoch: epoch
) == binding else {
try await deleteSecureRecord(account: scope, epoch: epoch)
return nil
}
guard let data = try await readSecureRecord(account: scope, epoch: epoch),
let stored = try? JSONDecoder().decode(
CmxIrohStoredRelayCredential.self,
from: data
),
stored.version == CmxIrohStoredRelayCredential.currentVersion,
stored.binding == binding,
hasExactFleet(stored.response.relayFleet, expected: expectedRelayFleet),
(try? stored.response.relayConfigurations(now: now))?.count
== expectedRelayFleet.count else {
try await deleteSecureRecord(account: scope, epoch: epoch)
return nil
}
try requireCurrent(epoch)
return stored.response
}
/// Saves a fresh relay credential for one exact binding and managed fleet.
///
/// - Parameters:
/// - response: The relay token response returned by the trust broker.
/// - accountID: The authenticated account identifier.
/// - binding: The exact active binding tuple.
/// - expectedRelayFleet: The complete configured managed relay fleet.
/// - now: The validation time.
/// - Throws: A validation, encoding, or secure-storage error.
public func saveRelayCredential(
_ response: CmxIrohRelayTokenResponse,
accountID: String,
binding: CmxIrohBrokerBindingMetadata,
expectedRelayFleet: Set<String>,
now: Date
) async throws {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: binding.appInstanceID,
epoch: epoch
)
guard let storedBinding = try await loadBinding(
scope: scope,
appInstanceID: binding.appInstanceID,
epoch: epoch
) else {
throw CmxIrohBrokerCredentialRepositoryError.bindingNotStored
}
guard storedBinding == binding else {
try await deleteSecureRecord(account: scope, epoch: epoch)
throw CmxIrohBrokerCredentialRepositoryError.bindingMismatch
}
guard hasExactFleet(response.relayFleet, expected: expectedRelayFleet) else {
throw CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch
}
guard (try? response.relayConfigurations(now: now))?.count
== expectedRelayFleet.count else {
throw CmxIrohBrokerCredentialRepositoryError.invalidRelayCredential
}
let record = CmxIrohStoredRelayCredential(binding: binding, response: response)
try await writeSecureRecord(
JSONEncoder().encode(record),
account: scope,
accessibility: .afterFirstUnlockThisDeviceOnly,
epoch: epoch
)
}
/// Removes a relay credential while preserving its broker binding.
///
/// - Parameters:
/// - accountID: The authenticated account identifier.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - Throws: A scope-validation or secure-storage error.
public func deleteRelayCredential(
accountID: String,
appInstanceID: String
) async throws {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: appInstanceID,
epoch: epoch
)
try await deleteSecureRecord(account: scope, epoch: epoch)
}
/// Removes a broker binding and every capability scoped to it.
///
/// - Parameters:
/// - accountID: The authenticated account identifier.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - Throws: A scope-validation or secure-storage error.
public func deleteBinding(
accountID: String,
appInstanceID: String
) async throws {
let epoch = try beginOperation()
let scope = try await prepareScope(
accountID: accountID,
appInstanceID: appInstanceID,
epoch: epoch
)
try await deleteSecureRecord(account: scope, epoch: epoch)
try requireCurrent(epoch)
installState.set(nil, forKey: Self.bindingKey)
}
/// Removes all broker state during sign-out or local app-instance revocation.
///
/// - Throws: A secure-storage error.
public func deactivate() async throws {
lifecycleEpoch &+= 1
deactivationCount += 1
defer { deactivationCount -= 1 }
await waitForStorageMutations()
try await secureStore.deleteAll()
installState.set(nil, forKey: Self.bindingKey)
installState.set(nil, forKey: Self.activeScopeKey)
}
private func prepareScope(
accountID: String,
appInstanceID: String,
epoch: UInt64
) async throws -> String {
try requireCurrent(epoch)
guard !accountID.isEmpty,
accountID.utf8.count <= 1_024,
Self.isCanonicalUUID(appInstanceID) else {
throw CmxIrohBrokerCredentialRepositoryError.invalidScope
}
let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID)
guard installState.string(forKey: Self.activeScopeKey) != scope else {
return scope
}
try await deleteAllSecureRecords(epoch: epoch)
try requireCurrent(epoch)
installState.set(nil, forKey: Self.bindingKey)
installState.set(scope, forKey: Self.activeScopeKey)
return scope
}
private func loadBinding(
scope: String,
appInstanceID: String,
epoch: UInt64
) async throws -> CmxIrohBrokerBindingMetadata? {
try requireCurrent(epoch)
guard let encoded = installState.string(forKey: Self.bindingKey) else {
return nil
}
guard let binding = try? JSONDecoder().decode(
CmxIrohBrokerBindingMetadata.self,
from: Data(encoded.utf8)
), binding.appInstanceID == appInstanceID else {
installState.set(nil, forKey: Self.bindingKey)
try await deleteSecureRecord(account: scope, epoch: epoch)
return nil
}
try requireCurrent(epoch)
return binding
}
private func beginOperation() throws -> UInt64 {
guard deactivationCount == 0 else { throw CancellationError() }
return lifecycleEpoch
}
private func requireCurrent(_ epoch: UInt64) throws {
guard deactivationCount == 0,
lifecycleEpoch == epoch else { throw CancellationError() }
}
private func readSecureRecord(
account: String,
epoch: UInt64
) async throws -> Data? {
try requireCurrent(epoch)
let data = try await secureStore.read(account: account)
try requireCurrent(epoch)
return data
}
private func writeSecureRecord(
_ data: Data,
account: String,
accessibility: CmxIrohSecureCredentialAccessibility,
epoch: UInt64
) async throws {
try requireCurrent(epoch)
activeStorageMutationCount += 1
defer { finishStorageMutation() }
try await secureStore.write(
data,
account: account,
accessibility: accessibility
)
try requireCurrent(epoch)
}
private func deleteSecureRecord(
account: String,
epoch: UInt64
) async throws {
try requireCurrent(epoch)
activeStorageMutationCount += 1
defer { finishStorageMutation() }
try await secureStore.delete(account: account)
try requireCurrent(epoch)
}
private func deleteAllSecureRecords(epoch: UInt64) async throws {
try requireCurrent(epoch)
activeStorageMutationCount += 1
defer { finishStorageMutation() }
try await secureStore.deleteAll()
try requireCurrent(epoch)
}
private func finishStorageMutation() {
activeStorageMutationCount -= 1
guard activeStorageMutationCount == 0 else { return }
let waiters = storageMutationDrainWaiters
storageMutationDrainWaiters.removeAll(keepingCapacity: false)
for waiter in waiters { waiter.resume() }
}
private func waitForStorageMutations() async {
guard activeStorageMutationCount > 0 else { return }
await withCheckedContinuation { continuation in
storageMutationDrainWaiters.append(continuation)
}
}
private func hasExactFleet(_ fleet: [String], expected: Set<String>) -> Bool {
(1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(expected.count)
&& fleet.count == expected.count
&& Set(fleet) == expected
}
private static func scope(accountID: String, appInstanceID: String) -> String {
let transcript = Data(
"cmux/iroh/broker-credential-scope/v1\0\(accountID)\0\(appInstanceID)".utf8
)
return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined()
}
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
}
@@ -0,0 +1,23 @@
/// Validation failures owned by durable Iroh broker state.
public enum CmxIrohBrokerCredentialRepositoryError: Error, Equatable, Sendable {
/// The account or app-instance scope is malformed.
case invalidScope
/// Broker binding metadata is malformed.
case invalidBinding
/// Binding metadata does not belong to the requested app-instance scope.
case bindingScopeMismatch
/// Relay credentials were saved before their exact binding metadata.
case bindingNotStored
/// The supplied binding differs from the active broker binding.
case bindingMismatch
/// The credential does not cover exactly the configured managed relay fleet.
case relayFleetMismatch
/// The relay token or its lifetime is malformed or no longer fresh.
case invalidRelayCredential
}
@@ -0,0 +1,324 @@
public import CMUXMobileCore
public import Foundation
/// One active endpoint binding returned by the authenticated trust broker.
public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case bindingID = "binding_id"
case deviceID = "device_id"
case appInstanceID = "app_instance_id"
case tag
case platform
case displayName = "display_name"
case endpointID = "endpoint_id"
case identityGeneration = "identity_generation"
case pairingEnabled = "pairing_enabled"
case capabilities
case pathHints = "path_hints"
case lastSeenAt = "last_seen_at"
}
public let bindingID: String
public let deviceID: String
public let appInstanceID: String
public let tag: String
public let platform: CmxIrohPlatform
public let displayName: String?
public let endpointID: CmxIrohPeerIdentity
public let identityGeneration: Int
public let pairingEnabled: Bool
public let capabilities: [String]
public let pathHints: [CmxIrohPathHint]
public let lastSeenAt: String
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let bindingID = try container.decode(String.self, forKey: .bindingID)
let deviceID = try container.decode(String.self, forKey: .deviceID)
let appInstanceID = try container.decode(String.self, forKey: .appInstanceID)
let tag = try container.decode(String.self, forKey: .tag)
let endpointID = try container.decode(String.self, forKey: .endpointID)
let identityGeneration = try container.decode(Int.self, forKey: .identityGeneration)
let capabilities = try container.decode([String].self, forKey: .capabilities)
let displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
let pathHints = try container.decode([CmxIrohPathHint].self, forKey: .pathHints)
let lastSeenAt = try container.decode(String.self, forKey: .lastSeenAt)
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken),
displayName.map(Self.isSafeDisplayName) ?? true,
pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount,
pathHints.filter({ $0.kind == .relayURL }).count <= 2,
pathHints.allSatisfy(Self.isBrokerHint),
!pathHints.enumerated().contains(where: { index, hint in
pathHints[..<index].contains(hint)
}),
CmxIrohISO8601Date.parse(lastSeenAt) != nil else {
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath, debugDescription: "Invalid Iroh binding")
)
}
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.tag = tag
platform = try container.decode(CmxIrohPlatform.self, forKey: .platform)
self.displayName = displayName
self.endpointID = try CmxIrohPeerIdentity(endpointID: endpointID)
self.identityGeneration = identityGeneration
pairingEnabled = try container.decode(Bool.self, forKey: .pairingEnabled)
self.capabilities = capabilities
self.pathHints = pathHints
self.lastSeenAt = lastSeenAt
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(bindingID, forKey: .bindingID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(appInstanceID, forKey: .appInstanceID)
try container.encode(tag, forKey: .tag)
try container.encode(platform, forKey: .platform)
try container.encodeIfPresent(displayName, forKey: .displayName)
try container.encode(endpointID.endpointID, forKey: .endpointID)
try container.encode(identityGeneration, forKey: .identityGeneration)
try container.encode(pairingEnabled, forKey: .pairingEnabled)
try container.encode(capabilities, forKey: .capabilities)
try container.encode(pathHints, forKey: .pathHints)
try container.encode(lastSeenAt, forKey: .lastSeenAt)
}
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
private static func isSafeDisplayName(_ value: String) -> Bool {
!value.isEmpty
&& value.utf16.count <= 128
&& !value.unicodeScalars.contains(where: {
$0.value <= 0x1f || $0.value == 0x7f
})
}
private static func isBrokerHint(_ hint: CmxIrohPathHint) -> Bool {
guard hint.isSafeForCurrentWireFormat,
hint.kind != .relayIdentifier,
let observedAt = hint.observedAt,
let expiresAt = hint.expiresAt,
expiresAt > observedAt,
expiresAt <= observedAt.addingTimeInterval(CmxIrohPathHint.maximumPrivateHintTTL)
else {
return false
}
return true
}
}
/// Broker-published Ed25519 key used to verify grants and attestations locally.
public struct CmxIrohGrantVerificationKey: Codable, Equatable, Sendable {
public let kid: String
public let alg: String
public let spkiDerBase64: String
private enum CodingKeys: String, CodingKey {
case kid
case alg
case spkiDerBase64 = "spki_der_base64"
}
}
/// Current and previous broker keys accepted during a staged signing-key rotation.
public struct CmxIrohGrantVerificationKeySet: Codable, Equatable, Sendable {
public let version: Int
public let currentKeyID: String
public let keys: [CmxIrohGrantVerificationKey]
private enum CodingKeys: String, CodingKey {
case version
case currentKeyID = "current_kid"
case keys
}
}
/// Same-account LAN rendezvous material. It is never advertised directly in mDNS.
public struct CmxIrohLANRendezvous: Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case generation
case key
}
public let generation: Int
public let key: String
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let generation = try container.decode(Int.self, forKey: .generation)
let key = try container.decode(String.self, forKey: .key)
guard (1 ... Int(Int32.max)).contains(generation),
Self.decodeBase64URL(key)?.count == 32 else {
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath, debugDescription: "Invalid LAN rendezvous")
)
}
self.generation = generation
self.key = key
}
private static func decodeBase64URL(_ value: String) -> Data? {
guard !value.isEmpty,
value.utf8.allSatisfy({ byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| byte == 45 || byte == 95
}) else {
return nil
}
let padding = String(repeating: "=", count: (4 - value.count % 4) % 4)
let standard = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/") + padding
guard let data = Data(base64Encoded: standard),
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "") == value else {
return nil
}
return data
}
}
/// Authenticated registry snapshot used for endpoint discovery and grant verification.
public struct CmxIrohDiscoveryResponse: Decodable, Equatable, Sendable {
public let routeContractVersion: Int
public let bindings: [CmxIrohBrokerBinding]
public let relayFleet: [String]
public let lanRendezvous: CmxIrohLANRendezvous
public let grantVerificationKeys: CmxIrohGrantVerificationKeySet
private enum CodingKeys: String, CodingKey {
case routeContractVersion = "route_contract_version"
case bindings
case relayFleet = "relay_fleet"
case lanRendezvous = "lan_rendezvous"
case grantVerificationKeys = "grant_verification_keys"
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let routeContractVersion = try container.decode(Int.self, forKey: .routeContractVersion)
let bindings = try container.decode([CmxIrohBrokerBinding].self, forKey: .bindings)
let relayFleet = try container.decode([String].self, forKey: .relayFleet)
guard bindings.count <= 32,
Set(bindings.map(\.bindingID)).count == bindings.count,
(1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(
relayFleet.count
),
Set(relayFleet).count == relayFleet.count,
relayFleet.allSatisfy(Self.isCanonicalRelayURL) else {
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath, debugDescription: "Invalid Iroh registry")
)
}
self.routeContractVersion = routeContractVersion
self.bindings = bindings
self.relayFleet = relayFleet
lanRendezvous = try container.decode(CmxIrohLANRendezvous.self, forKey: .lanRendezvous)
grantVerificationKeys = try container.decode(
CmxIrohGrantVerificationKeySet.self,
forKey: .grantVerificationKeys
)
}
private static func isCanonicalRelayURL(_ value: String) -> Bool {
guard let components = URLComponents(string: value),
components.scheme == "https",
let host = components.host,
host == host.lowercased(),
!host.isEmpty,
components.port == nil,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path == "/" else {
return false
}
return components.string == value
}
}
/// Registration response. Relay bootstrap failure never rolls back the binding.
public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
public let binding: CmxIrohBrokerBinding
public let relay: CmxIrohRegistrationRelay
}
/// Result of the registration route's best-effort initial relay mint.
public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable {
case issued(CmxIrohRelayTokenResponse)
case unavailable
case notRequested
private enum CodingKeys: String, CodingKey { case status }
public init(from decoder: any Decoder) throws {
let status = try decoder.container(keyedBy: CodingKeys.self)
.decode(String.self, forKey: .status)
switch status {
case "issued":
self = try .issued(CmxIrohRelayTokenResponse(from: decoder))
case "unavailable":
self = .unavailable
case "not_requested":
self = .notRequested
default:
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath, debugDescription: "Unknown relay status")
)
}
}
}
/// Backend-signed seven-day permission for one iOS initiator and Mac acceptor.
public struct CmxIrohPairGrantResponse: Codable, Equatable, Sendable {
public let grant: String
public let expiresAt: String
private enum CodingKeys: String, CodingKey {
case grant
case expiresAt = "expires_at"
}
}
/// Backend-signed endpoint/account proof cached for offline same-account pairing.
public struct CmxIrohEndpointAttestationResponse: Codable, Equatable, Sendable {
public let attestationVersion: Int
public let attestation: String
public let expiresAt: String
public let grantVerificationKeys: CmxIrohGrantVerificationKeySet
private enum CodingKeys: String, CodingKey {
case attestationVersion = "attestation_version"
case attestation
case expiresAt = "expires_at"
case grantVerificationKeys = "grant_verification_keys"
}
}

Some files were not shown because too many files have changed in this diff Show More