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:
co-authored by
lawrencecchen
cmux reload-cloud
Aziz Albahar
parent
8ae4711352
commit
3822f1dd47
@@ -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
|
||||
|
||||
|
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+15
-5
@@ -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``.
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
}
|
||||
+410
@@ -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"
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
+27
@@ -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
|
||||
|
||||
+13
@@ -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
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+140
@@ -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
|
||||
}
|
||||
|
||||
+80
-17
@@ -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)
|
||||
|
||||
+75
@@ -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])
|
||||
}
|
||||
+109
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -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)
|
||||
)
|
||||
}
|
||||
+61
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+124
@@ -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
|
||||
}
|
||||
}
|
||||
+453
@@ -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
-15
@@ -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
|
||||
|
||||
+215
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
@@ -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 {}
|
||||
}
|
||||
|
||||
+57
@@ -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]
|
||||
))
|
||||
}
|
||||
}
|
||||
+33
@@ -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: "-")
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+27
-45
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -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
|
||||
}
|
||||
}
|
||||
+8
@@ -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 }
|
||||
}
|
||||
+26
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
|
||||
+52
-29
@@ -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
|
||||
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+56
-45
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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
|
||||
```
|
||||
+171
@@ -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
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+128
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+127
@@ -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
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+8
@@ -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)
|
||||
}
|
||||
+18
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
+66
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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)"
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+366
@@ -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
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+324
@@ -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
Reference in New Issue
Block a user