From 98c03a61b286bd3198c2511daae976abc2b57377 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:48:11 -0700 Subject: [PATCH 01/71] test(iroh): host must not publish its binding before the home relay is usable Failing regression for the advertise-before-ready warm-up race in https://github.com/manaflow-ai/cmux/issues/9724: start() publishes the binding and route hints while the relay credential is still installing, so clients burn doomed dials against a Mac that cannot accept them yet. --- ...ohHostRuntimeStartupPublicationTests.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift new file mode 100644 index 000000000000..fd52cb5d75ef --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -0,0 +1,76 @@ +import CMUXMobileCore +import Foundation +import Testing + +@testable import CmuxIrohTransport + +extension CmxIrohHostRuntimeTests { + /// Regression for the advertise-before-ready warm-up race + /// (https://github.com/manaflow-ai/cmux/issues/9724): a Mac must not + /// publish its binding or route hints while its home relay is still + /// warming up, because clients immediately burn doomed dials against an + /// endpoint that cannot yet accept them. The binding and route may only + /// be published once the relay is usable, with the post-relay hints, and + /// exactly once. + @Test("binding publication waits for a usable home relay") + func bindingPublicationWaitsForUsableHomeRelay() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let readyBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + bindingID: fixture.binding.bindingID, + publicHintObservedAt: now, + publicHintExpiresAt: now.addingTimeInterval(60 * 60) + ) + let relayHint = try #require(readyBinding.pathHints.first) + let readyDiscovery = try HostRuntimeFixture.discovery( + binding: readyBinding, + relays: HostRuntimeFixture.relayURLs + ) + // The endpoint gains its usable relay hint only after the relay + // credential coordinator installs the first credential, exactly like + // a cold production launch. + let endpoint = TestIrohEndpoint( + identity: fixture.endpointID, + pathHintsAfterRelayReplacement: [relayHint] + ) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + subsequentRegistrationBindings: [readyBinding], + subsequentDiscoveries: [readyDiscovery] + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // No usable home relay exists yet: the Mac must not be + // discoverable-but-undialable. + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await runtime.snapshot().state == .active) + + // The relay comes up through the normal credential installation path. + // Publication must follow, exactly once, with the post-relay hints. + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + let republished = await routes.values() + #expect(republished.map(\.binding.bindingID) == [fixture.binding.bindingID]) + #expect(republished.map(\.pathHints) == [[relayHint]]) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + + await runtime.stop() + } +} From 21bba9ccb65d4bf6605ae5bb435eb29ff5ac6d49 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:55:39 -0700 Subject: [PATCH 02/71] iroh host: cache-first activation and register-when-ready publication Fixes the warm-up race in https://github.com/manaflow-ai/cmux/issues/9724: start() serialized a live broker resolve, relay credential activation, and a relay wait while the Mac was already advertised, so phones burned 5-15 s of doomed dials on every launch. Cache-first: when the persisted last-good policy still cryptographically verifies for this exact account, device, endpoint, identity generation, and host settings (validateCachedPolicy), start() activates admission, attestation, LAN rendezvous, and the endpoint relay bootstrap from it immediately and returns active with no broker round. First launch, an invalid cache, and relay-only debug hosts keep the blocking resolve. Register-when-ready: handleBinding/handleRoute are never invoked with pre-relay state. When the home relay is not yet usable, a generation-guarded ready gate activates the relay coordinator, waits the bounded waitForUsableHomeRelay(), then runs one live reconcile through the existing coalescing refresh machinery, publishing fresh post-relay hints exactly once. A cached route identity is refreshed, never unpublished. A cache-first reconcile that finds its binding replaced server-side adopts the authenticated result in place: admission update already propagates the acceptor everywhere, and only the relay credential coordinator pins a binding id, so it is recreated. Renewal and requested refreshes keep failing closed on replaced bindings. --- .../CmxConnectivityEngine.swift | 5 + .../CmxIrohHostRuntime+PolicyRefresh.swift | 95 ++++++++- .../CmxIrohHostRuntime+SignOut.swift | 4 + .../CmxIrohHostRuntime.swift | 184 +++++++++++++++--- .../CmxIrohHostRuntimeConfiguration.swift | 5 +- ...CmxIrohHostRuntimeFailedRestartTests.swift | 4 +- ...CmxIrohHostRuntimeLifecycleRaceTests.swift | 29 ++- .../CmxIrohHostRuntimeLifecycleTests.swift | 42 ++-- .../CmxIrohHostRuntimePolicyTests.swift | 73 ++++--- ...IrohHostRuntimeRequestedRefreshTests.swift | 2 +- ...ohHostRuntimeStartupPublicationTests.swift | 116 +++++++++++ .../CmxIrohHostRuntimeTestSupport.swift | 28 +++ 12 files changed, 497 insertions(+), 90 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index 784a545bb1f1..9357fcef6b6e 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift @@ -306,6 +306,11 @@ public actor CmxConnectivityEngine { await supervisor.hasConfiguredRelay() } + /// Returns whether the active endpoint generation reports a usable home relay. + public func hasUsableHomeRelay() async -> Bool { + await supervisor.hasUsableHomeRelay() + } + /// Waits for the active endpoint generation to report relay readiness. public func waitForUsableHomeRelay( timeout: Duration = .seconds(15) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 77bf8b8d3600..392ac3c1e44f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -272,6 +272,33 @@ extension CmxIrohHostRuntime { return discovery } + /// Returns a start policy from the persisted last-good broker policy when + /// it still cryptographically verifies for this exact account, identity, + /// endpoint, and host settings. Any mismatch is a silent cache miss so + /// activation falls back to the blocking authenticated resolve. + func validatedCachedStartPolicy( + expectedEndpointID: CmxIrohPeerIdentity + ) -> ResolvedPolicy? { + guard let cached = configuration.cachedHostPolicy else { return nil } + do { + try validateCachedPolicy(cached, endpointID: expectedEndpointID) + } catch { + return nil + } + return ResolvedPolicy( + registration: nil, + discovery: nil, + binding: cached.binding, + pairingEnabled: cached.pairingEnabled, + grantVerificationKeys: cached.grantVerificationKeys, + attestation: cached.endpointAttestation, + relayBootstrap: configuration.cachedRelayCredential, + lanRendezvous: cached.lanRendezvous, + routePathHints: [], + registrationRetryAfterSeconds: nil + ) + } + func cachedPolicy( after error: any Error, expectedEndpointID: CmxIrohPeerIdentity, @@ -541,7 +568,7 @@ extension CmxIrohHostRuntime { let previousBinding = localBinding else { return } do { let endpointID = try await connectivityEngine.localEndpointIdentity() - if !forcePublication { + if !forcePublication, !initialPublicationPending { let state = try await registrationPublicationState( engine: connectivityEngine, expectedEndpointID: endpointID @@ -560,9 +587,16 @@ extension CmxIrohHostRuntime { revision: revision, allowCachedFallback: false ) - guard policy.binding.bindingID == previousBinding.bindingID else { - throw CmxIrohHostRuntimeError.invalidLocalBinding + if policy.binding.bindingID != previousBinding.bindingID { + guard allowsReplacedBindingAdoption else { + throw CmxIrohHostRuntimeError.invalidLocalBinding + } + // A cache-first activation discovered its persisted binding + // was replaced server-side. Adopt the authenticated result in + // place, exactly as the blocking activation path would have. + try await adoptReplacedBinding(policy: policy, revision: revision) } + allowsReplacedBindingAdoption = false await admissionController.update( keys: policy.grantVerificationKeys, acceptor: grantPeer(for: policy.binding), @@ -570,6 +604,13 @@ extension CmxIrohHostRuntime { ) try requireCurrent(revision) localBinding = policy.binding + if currentSnapshot.bindingID != policy.binding.bindingID { + currentSnapshot = CmxIrohHostRuntimeSnapshot( + state: currentSnapshot.state, + endpointID: currentSnapshot.endpointID, + bindingID: policy.binding.bindingID + ) + } endpointAttestation = policy.attestation ?? endpointAttestation lanRendezvous = policy.lanRendezvous guard let registration = policy.registration, @@ -593,6 +634,7 @@ extension CmxIrohHostRuntime { revision: revision ) registrationRefreshFailureCount = 0 + initialPublicationPending = false completedSuccessfully = true scheduleRegistrationRenewal( binding: registration.binding, @@ -634,6 +676,53 @@ extension CmxIrohHostRuntime { } } + /// Rebinds binding-scoped components to an authenticated replacement + /// binding. `CmxIrohAdmissionController.update` already propagates the new + /// acceptor to online and offline admission; only the relay credential + /// coordinator pins a binding ID at activation and must be recreated. + private func adoptReplacedBinding( + policy: ResolvedPolicy, + revision: UInt64 + ) async throws { + try requireCurrent(revision) + guard let connectivityEngine else { + throw CmxIrohHostRuntimeError.inactive + } + guard let coordinator = relayCoordinator else { return } + relayActivationTask?.cancel() + relayActivationTask = nil + await coordinator.deactivate() + if relayCoordinator === coordinator { + relayCoordinator = nil + } + try requireCurrent(revision) + let binding = policy.binding + guard let profile = currentEndpointRelayProfile, + profile.source == .managed, + !profile.allowedRelayURLs.isEmpty else { return } + let replacement = CmxIrohRelayCredentialCoordinator( + supervisor: connectivityEngine, + broker: broker, + managedRelayURLs: managedRelayURLs, + selectedRelayURLs: profile.allowedRelayURLs, + credentialDidInstall: { [handleRelayCredential] response in + await handleRelayCredential(response, binding) + } + ) + relayCoordinator = replacement + do { + try await replacement.activate( + bindingID: binding.bindingID, + endpointIdentity: binding.endpointID, + bootstrap: policy.relayBootstrap + ) + } catch { + // The replacement coordinator owns bounded retry. An unavailable + // relay credential must not fail the adopted live policy. + } + try requireCurrent(revision) + } + static func seconds(_ date: Date) -> Int64? { let value = date.timeIntervalSince1970 guard value.isFinite, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift index 8ec12fc57b44..24bedd4d19df 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift @@ -88,6 +88,10 @@ extension CmxIrohHostRuntime { registrationRefreshFailureCount = 0 relayActivationTask?.cancel() relayActivationTask = nil + initialPublicationTask?.cancel() + initialPublicationTask = nil + initialPublicationPending = false + allowsReplacedBindingAdoption = false lanPublicationGeneration &+= 1 lanPublicationTask?.cancel() lanPublicationTask = nil diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index c70ac5fbb0a6..28807d5eb25c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -93,6 +93,14 @@ public actor CmxIrohHostRuntime { var offlineSessions: CmxIrohOfflinePairingSessions? var connectivityEventTask: Task? var relayActivationTask: Task? + var initialPublicationTask: Task? + /// True while activation still owes the first ready publication. It makes + /// the next refresh round publish even when reachability is unchanged. + var initialPublicationPending = false + /// True only between a cache-first activation and its first completed live + /// resolve, allowing that resolve to adopt a replaced broker binding in + /// place instead of failing closed. + var allowsReplacedBindingAdoption = false var lanPublicationTask: Task? var lanPublicationGeneration: UInt64 = 0 var registrationRefreshTask: Task? @@ -161,7 +169,11 @@ public actor CmxIrohHostRuntime { } - /// Activates connectivity and resolves authenticated broker policy before any cached fallback. + /// Activates connectivity, restoring a verified cached policy immediately + /// when one matches this binding, and reconciles authenticated broker + /// policy in the background. Publication of the binding and route hints + /// waits for a usable home relay so the Mac is never + /// discoverable-but-undialable. public func start() async throws { guard lifecyclePhase.allowsStart else { throw CmxIrohHostRuntimeError.alreadyActive @@ -173,6 +185,8 @@ public actor CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 + initialPublicationPending = false + allowsReplacedBindingAdoption = false currentSnapshot = CmxIrohHostRuntimeSnapshot( state: .starting, endpointID: nil, @@ -208,11 +222,26 @@ public actor CmxIrohHostRuntime { throw CmxIrohHostRuntimeError.invalidLocalBinding } - let policy = try await resolveInitialPolicy( - engine: connectivityEngine, - expectedEndpointID: endpointID, - revision: revision - ) + let requiresRelayReadiness = !protocolConfiguration + .allowsNATTraversalAfterAdmission + // A verified same-binding cached policy activates admission and + // the endpoint immediately; the authenticated broker round then + // runs in the background and reconciles. First launch (no cache), + // an invalid cache, and relay-required debug hosts keep the + // blocking resolve. + let cachedStartPolicy = requiresRelayReadiness + ? nil + : validatedCachedStartPolicy(expectedEndpointID: endpointID) + let policy: ResolvedPolicy + if let cachedStartPolicy { + policy = cachedStartPolicy + } else { + policy = try await resolveInitialPolicy( + engine: connectivityEngine, + expectedEndpointID: endpointID, + revision: revision + ) + } try requireCurrent(revision) let offlineSessions = CmxIrohOfflinePairingSessions( @@ -278,8 +307,6 @@ public actor CmxIrohHostRuntime { bindingID: policy.binding.bindingID ) var publishedPolicy = policy - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission if requiresRelayReadiness { if let relayCoordinator { try await relayCoordinator.activate( @@ -318,9 +345,19 @@ public actor CmxIrohHostRuntime { // into `readyPolicy`; do not immediately publish a third copy. registrationRefreshPending = false } + let publishInline: Bool + if requiresRelayReadiness { + publishInline = true + } else { + publishInline = await initialPublicationReady( + engine: connectivityEngine + ) + try requireCurrent(revision) + } let publishedFreshBinding: Bool if let registration = publishedPolicy.registration, - let discovery = publishedPolicy.discovery { + let discovery = publishedPolicy.discovery, + publishInline { await handleBinding(registration, discovery, publishedPolicy.attestation) try requireCurrent(revision) if let routeRevision = discovery.revision { @@ -337,28 +374,48 @@ public actor CmxIrohHostRuntime { } else { publishedFreshBinding = false } - await handleRoute( - publishedPolicy.binding, - publishedPolicy.routePathHints - ) - try requireCurrent(revision) + if publishedFreshBinding || publishedPolicy.registration == nil { + // Fresh relay-ready hints, or a cached authority whose local + // route identity is refreshed rather than unpublished. A live + // policy without a usable home relay publishes nothing yet: + // the Mac must not be discoverable-but-undialable. + await handleRoute( + publishedPolicy.binding, + publishedPolicy.routePathHints + ) + try requireCurrent(revision) + } registrationRefreshEnabled = true if !publishedFreshBinding { - // Cached authority keeps offline admission and LAN discovery - // available, but it cannot describe this endpoint generation's - // live direct port. Give the lifecycle-owned retry loop the - // incomplete activation so the broker is refreshed without - // creating a second endpoint or relying on another network event. registrationRefreshPending = false - scheduleRegistrationRetry( - revision: revision, - retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds - ) + if requiresRelayReadiness { + // Cached authority keeps offline admission and LAN + // discovery available, but it cannot describe this endpoint + // generation's live direct port. Give the lifecycle-owned + // retry loop the incomplete activation. + scheduleRegistrationRetry( + revision: revision, + retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds + ) + } else { + // The ready gate activates the relay, waits for a usable + // home relay, and then runs the one live reconcile that + // publishes fresh path hints. + initialPublicationPending = true + allowsReplacedBindingAdoption = cachedStartPolicy != nil + scheduleInitialPublication( + binding: publishedPolicy.binding, + endpointID: endpointID, + bootstrap: publishedPolicy.relayBootstrap, + retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds, + revision: revision + ) + } } else if registrationRefreshPending { registrationRefreshPending = false scheduleRegistrationRefresh(revision: revision) } - if let relayCoordinator, !requiresRelayReadiness { + if let relayCoordinator, !requiresRelayReadiness, publishedFreshBinding { scheduleRelayActivation( relayCoordinator, binding: policy.binding, @@ -557,6 +614,85 @@ public actor CmxIrohHostRuntime { await handleLANPolicy(context, directAddresses) } + /// Returns whether the binding may be published immediately: the home + /// relay is already usable, or this endpoint will never own a relay. + private func initialPublicationReady( + engine: CmxConnectivityEngine + ) async -> Bool { + if await engine.hasUsableHomeRelay() { return true } + guard relayCoordinator == nil else { return false } + return !(await engine.hasConfiguredRelay()) + } + + func scheduleInitialPublication( + binding: CmxIrohBrokerBindingMetadata, + endpointID: CmxIrohPeerIdentity, + bootstrap: CmxIrohRelayTokenResponse?, + retryAfterSeconds: Int?, + revision: UInt64 + ) { + initialPublicationTask?.cancel() + initialPublicationTask = Task { [weak self] in + await self?.runInitialPublication( + binding: binding, + endpointID: endpointID, + bootstrap: bootstrap, + retryAfterSeconds: retryAfterSeconds, + revision: revision + ) + } + } + + private func runInitialPublication( + binding: CmxIrohBrokerBindingMetadata, + endpointID: CmxIrohPeerIdentity, + bootstrap: CmxIrohRelayTokenResponse?, + retryAfterSeconds: Int?, + revision: UInt64 + ) async { + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + if let coordinator = relayCoordinator { + // Credential installation happens before the readiness wait so a + // cached relay bootstrap makes the home relay usable without a + // broker round. The coordinator owns bounded retry on failure. + try? await coordinator.activate( + bindingID: binding.bindingID, + endpointIdentity: endpointID, + bootstrap: bootstrap + ) + } + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + if let connectivityEngine, await connectivityEngine.hasConfiguredRelay() { + do { + try await connectivityEngine.waitForUsableHomeRelay() + } catch is CancellationError { + return + } catch { + // The bounded readiness window elapsed or the generation moved + // on. Publication proceeds so a relay outage cannot leave this + // Mac permanently unpublished on its LAN and direct paths. + } + } + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + if let retryAfterSeconds { + // A broker cooldown observed during activation keeps its validated + // floor; the lifecycle retry loop owns the next live round. + scheduleRegistrationRetry( + revision: revision, + retryAfterSeconds: retryAfterSeconds + ) + return + } + guard initialPublicationPending else { return } + scheduleRegistrationRefresh(revision: revision) + } + func scheduleRelayActivation( _ coordinator: CmxIrohRelayCredentialCoordinator, binding: CmxIrohBrokerBindingMetadata, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift index 9b16048a5e20..f0280a4821fb 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift @@ -22,7 +22,10 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { /// `nil` preserves automatic use of the complete managed fleet. public let endpointRelayProfile: CmxIrohEndpointRelayProfile? public let cachedRelayCredential: CmxIrohRelayTokenResponse? - /// A previously verified offline policy considered only after broker connectivity failure. + /// A previously verified last-good policy. When it still verifies for this + /// exact binding it activates the host immediately (cache-first) while the + /// live broker resolve reconciles in the background; it also remains the + /// verified fallback after broker connectivity failure. public let cachedHostPolicy: CmxIrohCachedHostPolicy? /// Creates stable inputs for one Mac host runtime lifecycle. diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift index 5ec428dcee2f..c550f10b0446 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift @@ -16,8 +16,8 @@ extension CmxIrohHostRuntimeTests { func nonTransientRefreshRejectionFailsClosedThenRestarts() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let firstEndpoint = TestIrohEndpoint(identity: fixture.endpointID) - let restartEndpoint = TestIrohEndpoint(identity: fixture.endpointID) + let firstEndpoint = try fixture.relayReadyEndpoint() + let restartEndpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift index df5fd369cfa4..7d488c322057 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift @@ -46,10 +46,17 @@ extension CmxIrohHostRuntimeTests { } ) try await runtime.start() + // Native iroh reports the home relay online once the installed + // credential connects; the readiness gate publishes only after this. + for _ in 0 ..< 20_000 { + if await !endpoint.observedRelayUpdates().isEmpty { break } + await Task.yield() + } + await endpoint.emit(.online) let republished = await broker.waitForRegistrationCount( 2, - timeout: .seconds(1) + timeout: .seconds(5) ) #expect( republished, @@ -75,14 +82,16 @@ extension CmxIrohHostRuntimeTests { #expect(initialDirectPorts == expectedDirectPorts) #expect(refreshedDirectPorts == expectedDirectPorts) + // The pre-relay state is never published; exactly one publication + // carries the newly usable relay address. + await runtime.waitForInitialPublicationForTesting() let published = await publications.values() - #expect(published.count == 2) - #expect(published[0].registration.pathHints.isEmpty) - #expect(published[0].discovered.pathHints.isEmpty) - #expect(published[1].registration.pathHints == [relayHint]) - #expect(published[1].discovered.pathHints == [relayHint]) - #expect(published[1].registration.endpointID == fixture.endpointID) - #expect(published[1].registration.bindingID == fixture.binding.bindingID) + let publication = try #require(published.first) + #expect(published.count == 1) + #expect(publication.registration.pathHints == [relayHint]) + #expect(publication.discovered.pathHints == [relayHint]) + #expect(publication.registration.endpointID == fixture.endpointID) + #expect(publication.registration.bindingID == fixture.binding.bindingID) let snapshot = await runtime.snapshot() #expect(snapshot.state == .active) @@ -95,7 +104,7 @@ extension CmxIrohHostRuntimeTests { @Test func validatedBindingPublishesBeforeRelayCredentialInstallationCompletes() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let gate = HostRuntimeSuspensionGate() let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( @@ -123,7 +132,7 @@ extension CmxIrohHostRuntimeTests { @Test func validatedBindingPublishesBeforeLANAdvertisementCompletes() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let gate = HostRuntimeSuspensionGate() let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift index cd49a7c3705f..51b099e77182 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift @@ -30,7 +30,7 @@ extension CmxIrohHostRuntimeTests { ) ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -91,7 +91,7 @@ extension CmxIrohHostRuntimeTests { func unchangedReachabilityRenewsRegistrationBeforeHintExpiry() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -125,7 +125,7 @@ extension CmxIrohHostRuntimeTests { func registrationRenewalHonorsBrokerRetryAfterFloor() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -160,7 +160,7 @@ extension CmxIrohHostRuntimeTests { func registrationRenewalBacksOffConsecutiveFailures() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -201,7 +201,7 @@ extension CmxIrohHostRuntimeTests { func successfulRegistrationRenewalResetsBackoff() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -403,7 +403,7 @@ extension CmxIrohHostRuntimeTests { @Test func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let store = TestControllableSecureCredentialStore() let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) let deactivations = HostRuntimeDeactivationRecorder() @@ -447,7 +447,7 @@ extension CmxIrohHostRuntimeTests { @Test func successfulSignOutClearsRegistrationPublicationState() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let store = TestControllableSecureCredentialStore() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -474,7 +474,7 @@ extension CmxIrohHostRuntimeTests { @Test func requiredBindPolicyIsForwardedToTheEndpointGeneration() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -504,12 +504,12 @@ extension CmxIrohHostRuntimeTests { } @Test - func connectivityFailureUsesVerifiedCacheOnlyAfterOnlineAttempt() async throws { + func cacheFirstActivationBecomesActiveDespiteBrokerConnectivityFailure() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now let cachedPolicy = try cachedFixture.policy() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -527,11 +527,17 @@ extension CmxIrohHostRuntimeTests { handleBinding: { _, _, _ in await bindings.record() } ) + // The verified cache activates admission immediately; the failed + // background reconcile keeps cached authority active without a fresh + // publication until a live round succeeds. try await runtime.start() - #expect(await broker.observedRegistrationCount() == 1) + #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) + await runtime.waitForInitialPublicationForTesting() + #expect(await broker.observedRegistrationCount() == 1) + #expect(await runtime.snapshot().state == .active) #expect(await bindings.count() == 0) await runtime.stop() } @@ -573,7 +579,6 @@ extension CmxIrohHostRuntimeTests { try await runtime.start() - #expect(await broker.observedRegistrationCount() == 1) #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) #expect(await routes.values() == [ @@ -588,8 +593,7 @@ extension CmxIrohHostRuntimeTests { let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now let currentPort: UInt16 = 55_123 - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, + let endpoint = try fixture.relayReadyEndpoint( directAddresses: ["0.0.0.0:\(currentPort)"] ) let factory = TestIrohEndpointFactory(endpoints: [endpoint]) @@ -682,7 +686,7 @@ extension CmxIrohHostRuntimeTests { @Test func endpointNetworkChangeRequestsImmediateLANRefresh() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let recorder = HostRuntimeLANRefreshRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -707,7 +711,7 @@ extension CmxIrohHostRuntimeTests { @Test func repeatedUnchangedNetworkEventsDoNotContactBroker() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -742,7 +746,7 @@ extension CmxIrohHostRuntimeTests { @Test func changedDirectPortPublishesImmediately() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -768,7 +772,7 @@ extension CmxIrohHostRuntimeTests { @Test func endpointOnlineRequestsImmediateReachabilityRefresh() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let recorder = HostRuntimeLANRefreshRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -809,7 +813,7 @@ extension CmxIrohHostRuntimeTests { _ failure: CmxIrohTrustBrokerClientError ) async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift index 045738364aeb..61dabc88c3e5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift @@ -32,7 +32,7 @@ extension CmxIrohHostRuntimeTests { ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, @@ -118,7 +118,7 @@ extension CmxIrohHostRuntimeTests { ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, @@ -179,7 +179,7 @@ extension CmxIrohHostRuntimeTests { lanGeneration: 1, revision: 1 ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: revisionTwo, @@ -318,19 +318,20 @@ extension CmxIrohHostRuntimeTests { .rejected(statusCode: 400, code: "invalid_request"), .invalidResponse, ]) - func terminalBrokerFailureNeverUsesCachedPolicy( + func terminalBrokerFailureFailsClosedAfterCacheFirstActivation( _ failure: CmxIrohTrustBrokerClientError ) async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, registrationError: failure ) + let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: factory, broker: broker, @@ -339,18 +340,19 @@ extension CmxIrohHostRuntimeTests { ), pendingRevocations: fixture.pendingRevocations(), now: { now }, - handleTransport: { session, _ in await session.close() } + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } ) - do { - try await runtime.start() - Issue.record("Expected terminal broker failure") - } catch let error as CmxIrohTrustBrokerClientError { - #expect(error == failure) - } + // Cache-first activation succeeds locally, but the background live + // reconcile discovers the terminal rejection and fails closed: a Mac + // the broker refuses must not keep serving on cached authority. + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await bindings.count() == 0) } @Test @@ -367,7 +369,7 @@ extension CmxIrohHostRuntimeTests { ) let cachedFixture = try fixture.cachedPolicyFixture(binding: cachedMetadata) let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -386,8 +388,13 @@ extension CmxIrohHostRuntimeTests { handleBinding: { _, _, _ in await bindings.record() } ) + // Cache-first activation starts on the persisted binding; the live + // reconcile discovers it was replaced server-side and adopts the + // authenticated binding in place, publishing it exactly once. try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == fixture.binding.bindingID) #expect(await bindings.count() == 1) await runtime.stop() @@ -459,7 +466,7 @@ extension CmxIrohHostRuntimeTests { } @Test - func confirmedOnlineBindingChangePreventsDiscoveryConnectivityFallback() async throws { + func halfConfirmedBindingChangeIsNeverAdoptedNorPublished() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now @@ -467,13 +474,14 @@ extension CmxIrohHostRuntimeTests { endpointID: fixture.endpointID.endpointID, bindingID: "123e4567-e89b-42d3-a456-426614174099" ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: changedBinding, discovery: fixture.discovery, discoveryError: .connectivity ) + let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: factory, broker: broker, @@ -482,18 +490,24 @@ extension CmxIrohHostRuntimeTests { ), pendingRevocations: fixture.pendingRevocations(), now: { now }, - handleTransport: { session, _ in await session.close() } + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } ) - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } + // The reconcile registers a replaced binding but its discovery round + // fails on connectivity. The half-confirmed binding is confirmed + // proof that cached authority is stale, so the runtime fails closed + // without adopting or publishing the incomplete policy. + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + #expect(await runtime.snapshot().state == .failed) + #expect(await bindings.count() == 0) #expect(await endpoint.observedCloseCallCount() == 1) } @Test - func routeContractMismatchNeverUsesCachedPolicy() async throws { + func routeContractMismatchFailsClosedAfterCacheFirstActivation() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now @@ -502,7 +516,7 @@ extension CmxIrohHostRuntimeTests { relays: Array(fixture.managedRelays), routeContractVersion: 2 ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -519,17 +533,17 @@ extension CmxIrohHostRuntimeTests { handleTransport: { session, _ in await session.close() } ) - await #expect(throws: CmxIrohHostRuntimeError.routeContractMismatch) { - try await runtime.start() - } + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + #expect(await runtime.snapshot().state == .failed) #expect(await endpoint.observedCloseCallCount() == 1) } @Test func discoverySubstitutionFailsClosedAndClosesEndpoint() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let substituted = try HostRuntimeFixture.discovery( binding: fixture.binding, @@ -553,12 +567,11 @@ extension CmxIrohHostRuntimeTests { handleTransport: { session, _ in await session.close() } ) - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.observedCloseCallCount() == 1) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift index d13387d7a1fc..9aa06cfe1d97 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift @@ -27,7 +27,7 @@ extension CmxIrohHostRuntimeTests { let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index fd52cb5d75ef..69abedaac41f 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -4,6 +4,17 @@ import Testing @testable import CmuxIrohTransport +extension CmxIrohHostRuntime { + /// Awaits the startup ready gate and every refresh round it scheduled, so + /// tests observe the settled post-reconcile state deterministically. + func waitForInitialPublicationForTesting() async { + await initialPublicationTask?.value + while let task = registrationRefreshTask { + await task.value + } + } +} + extension CmxIrohHostRuntimeTests { /// Regression for the advertise-before-ready warm-up race /// (https://github.com/manaflow-ai/cmux/issues/9724): a Mac must not @@ -73,4 +84,109 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } + + /// Cache-first activation: a persisted verified policy for the same + /// binding makes the Mac dialable immediately. The live broker round is + /// only a background reconcile, so start() completes while the broker has + /// not yet answered at all. + @Test("cache-first start becomes ready without a live broker response") + func cacheFirstStartBecomesReadyWithoutALiveBrokerResponse() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let cachedPolicy = try cachedFixture.policy() + let registrationGate = HostRuntimeRegistrationGate() + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationHook: { + await registrationGate.waitOnce() + return true + } + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + try fixture.relayReadyEndpoint(), + ]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + #expect(await runtime.snapshot().state == .active) + #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) + #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) + // The cached route identity is refreshed, never unpublished, but no + // fresh binding may be published while the live round is unanswered. + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + ]) + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await runtime.snapshot().state == .active) + #expect(await bindings.count() == 0) + + await registrationGate.open() + await runtime.waitForInitialPublicationForTesting() + + #expect(await bindings.count() == 1) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// The late live policy reconciles onto a cache-first activation: the + /// same binding is re-verified and the fresh broker route hints replace + /// the empty cached ones. + @Test("late live policy reconciles a cache-first activation") + func lateLivePolicyReconcilesCacheFirstActivation() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) + let discoveredHint = try #require(fixture.binding.pathHints.first) + let cachedFixture = try fixture.cachedPolicyFixture() + let cachedPolicy = try cachedFixture.policy() + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + try fixture.relayReadyEndpoint(), + ]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + #expect(await broker.observedRegistrationCount() == 1) + #expect(await bindings.count() == 1) + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + .init( + binding: CmxIrohBrokerBindingMetadata(binding: fixture.binding), + pathHints: [discoveredHint] + ), + ]) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift index 812e494a9801..39431794b752 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift @@ -97,6 +97,34 @@ struct HostRuntimeFixture { ) } + /// A public relay hint usable on the supervisor's wall clock for one hour. + /// An endpoint born with it reports a usable home relay immediately, so + /// activation publishes the binding inline instead of waiting on the ready + /// gate. Fixtures that pin `now` far in the future exclude it from + /// registration payloads automatically, keeping those payloads unchanged. + static func usableRelayHint() throws -> CmxIrohPathHint { + let observed = Date() + return try CmxIrohPathHint( + kind: .relayURL, + value: relayURLs[2], + source: .native, + privacyScope: .publicInternet, + observedAt: observed, + expiresAt: observed.addingTimeInterval(60 * 60) + ) + } + + /// An endpoint whose home relay is usable from birth. + func relayReadyEndpoint( + directAddresses: [String] = [] + ) throws -> TestIrohEndpoint { + TestIrohEndpoint( + identity: endpointID, + directAddresses: directAddresses, + pathHints: [try Self.usableRelayHint()] + ) + } + static let relayURLs = [ "https://aps1-1.relay.lawrence.cmux.iroh.link/", "https://euc1-1.relay.lawrence.cmux.iroh.link/", From 6ed798b7a2abc60727b7935baac4d3fa7ab9ca9e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:22:37 -0700 Subject: [PATCH 03/71] test(iroh): failing tests for unbounded dials and zero-route refresh fallbacks Behavior the client transport must have but does not yet (red on this commit, fixed in the next): - CmxIrohClientSession: an admission barrier that never answers must fail at the dial bound and be superseded by the next attempt (cmux#9724 16.2s dial, cmux#8531 silent redial hang). - CmxIrohRegistryContextProvider: when the staleness-forced discovery refresh fails, dial with the last verified snapshot instead of refusing to dial. - CmxIrohRelayPolicyService.restore: a recently-expired last-good policy must keep its routes dialable instead of publishing a zero-route managed profile (cmux#10375). - CmxIrohRelayCredentialCoordinator.refreshIfNeeded: a failed mint with a last-good installed credential must not throw; the bounded retry loop continues in the background (cmux#10375). --- .../CmxIrohClientSessionDialBoundTests.swift | 157 ++++++++++++++++++ ...egistryContextProviderStalenessTests.swift | 34 ++++ ...xIrohRelayCredentialCoordinatorTests.swift | 35 ++++ .../CmxIrohRelayPolicyServiceTests.swift | 33 ++++ 4 files changed, 259 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift new file mode 100644 index 000000000000..6718b1cd2c42 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift @@ -0,0 +1,157 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Bounded-dial behavior (cmux#9724, cmux#8531): a dial attempt whose +/// admission barrier never answers must fail at the configured dial bound and +/// leave the session redialable, instead of holding the reconnect owner for +/// an unbounded time. A half-ready Mac accepts the QUIC connection but never +/// serves the admission frames; that exact shape produced the unbounded +/// 16.2-second dial in the cmux#9724 trace. +@Suite +struct CmxIrohClientSessionDialBoundTests { + let localIdentity: CmxIrohPeerIdentity + let remoteIdentity: CmxIrohPeerIdentity + let credential: CmxIrohAdmissionCredential + + init() throws { + localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "ab", count: 32) + ) + remoteIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "cd", count: 32) + ) + credential = try .pairGrant("e30.e30.AA") + } + + @Test("an admission barrier that never answers fails at the dial bound") + func admissionBarrierThatNeverAnswersFailsAtTheDialBound() async throws { + let control = CmxIrohBidirectionalStream( + receiveStream: TestHangingIrohReceiveStream(), + sendStream: TestIrohSendStream() + ) + let connection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [control] + ) + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [.connection(connection)] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + let failure = await boundedConnectFailure(session, within: .seconds(2)) + #expect(failure as? CmxIrohClientSessionError == .dialTimedOut) + #expect(await connection.observedCloseCallCount() >= 1) + await session.close() + } + + @Test("a timed-out admission is superseded by the next connect attempt") + func timedOutAdmissionIsSupersededByTheNextConnectAttempt() async throws { + let hangingControl = CmxIrohBidirectionalStream( + receiveStream: TestHangingIrohReceiveStream(), + sendStream: TestIrohSendStream() + ) + let hangingConnection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [hangingControl] + ) + let goodConnection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [answeringControlStream()] + ) + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [ + .connection(hangingConnection), + .connection(goodConnection), + ] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + let failure = await boundedConnectFailure(session, within: .seconds(2)) + #expect(failure as? CmxIrohClientSessionError == .dialTimedOut) + + // The timed-out attempt must have been retired cleanly: the very next + // attempt on the same session dials again and admits. + try await session.connect() + + #expect(await endpoint.observedDialedAddresses().count == 2) + #expect(await hangingConnection.observedCloseCallCount() >= 1) + await session.close() + } + + // MARK: - Support + + /// Runs `connect()` under a test watchdog so the red state (an unbounded + /// admission hang) fails this test quickly instead of hanging the suite. + private func boundedConnectFailure( + _ session: CmxIrohClientSession, + within limit: Duration + ) async -> (any Error)? { + let connectTask = Task { try await session.connect() } + let watchdog = Task { + try? await ContinuousClock().sleep(for: limit) + connectTask.cancel() + } + defer { watchdog.cancel() } + do { + _ = try await connectTask.value + return nil + } catch { + return error + } + } + + private func answeringControlStream() -> CmxIrohBidirectionalStream { + let codec = CmxIrohAdmissionAckCodec() + let accepted = codec.encodeFrame(.acceptedPendingNatTraversal) + let serverReady = codec.encodeFrame(.serverReady) + return CmxIrohBidirectionalStream( + receiveStream: TestIrohReceiveStream(buffer: accepted + serverReady), + sendStream: TestIrohSendStream() + ) + } + + private func publicRelayHint() throws -> CmxIrohPathHint { + try CmxIrohPathHint( + kind: .relayURL, + value: "https://use1-1.relay.lawrence.cmux.iroh.link/", + source: .native, + privacyScope: .publicInternet + ) + } +} + +/// A control stream that never yields admission bytes. The hang is +/// cancellable, mirroring the production stream contract the phase bound +/// relies on (`TestIrohDialResult.hang` models the same shape for dials). +actor TestHangingIrohReceiveStream: CmxIrohReceiveStream { + private var stoppedCodes: [UInt64] = [] + + func receive(maximumByteCount _: Int) async throws -> Data? { + try await Task.sleep(for: .seconds(3_600)) + return nil + } + + func stop(errorCode: UInt64) { + stoppedCodes.append(errorCode) + } + + func observedStoppedCodes() -> [UInt64] { + stoppedCodes + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift index 9fa448ad5975..68e61dff9800 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift @@ -383,6 +383,40 @@ struct CmxIrohRegistryContextProviderStalenessTests { #expect(context.dialPlan.publicPaths == [relay]) } + @Test + func refreshFailureAfterStalenessFallsBackToLastVerifiedSnapshot() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + )] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + // A timed-out dial marked the peer stale, so the next attempt must + // try one fresh fetch first. + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try nonEmptyPlan(fixture, hints: [relay]), + failure: .timedOut + ) + await broker.setDiscoverError(CmxIrohTrustBrokerClientError.connectivity) + + // The forced refresh failed. Dialing with the last verified snapshot + // beats not dialing at all (cmux#9724): the staleness mark survives, + // so a later attempt still refetches once the broker recovers. + let context = try await provider.context(for: fixture.request(hints: [])) + + #expect(await broker.discoveryRequestCount() == 1) + #expect(context.dialPlan.publicPaths == [relay]) + } + @Test func cooldownDuringEmptyPlanRefetchKeepsResolvedContextInsteadOfSpinning() async throws { let fixture = try RegistryFixture() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift index c258d8206c01..e6ed9e03f3af 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift @@ -343,6 +343,41 @@ struct CmxIrohRelayCredentialCoordinatorTests { await coordinator.deactivate() } + @Test + func refreshFailureKeepsLastGoodCredentialWithoutThrowing() async throws { + let fixture = try RelayCoordinatorFixture() + let endpoint = TestIrohEndpoint(identity: fixture.identity) + let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) + let broker = TestRelayTokenBroker(steps: [.failure]) + let clock = TestRelayClock(now: fixture.now) + let coordinator = CmxIrohRelayCredentialCoordinator( + supervisor: supervisor, + broker: broker, + managedRelayURLs: Set(fixture.relayURLs), + clock: clock, + jitter: { _, refreshAfter in refreshAfter }, + retryJitter: { 0 } + ) + try await coordinator.activate( + bindingID: fixture.bindingID, + endpointIdentity: fixture.identity, + bootstrap: try fixture.response() + ) + // The installed credential is due for refresh but far from expiry. + clock.setNowWithoutResuming(fixture.refreshAfter.addingTimeInterval(1)) + + // A transient mint failure must not fail the caller while the + // last-good credential is installed (cmux#10375). The bounded retry + // loop keeps refreshing in the background; only the relay itself can + // reject the installed credential. + try await coordinator.refreshIfNeeded() + + #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) + #expect(await endpoint.observedRelayUpdates().count == 1) + #expect(await broker.observedEndpointIDs() == [fixture.identity]) + await coordinator.deactivate() + } + @Test func rateLimitRetryNeverPrecedesValidatedServerFloor() async throws { let fixture = try RelayCoordinatorFixture() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift index 272faa3df11a..dadeed557f8c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift @@ -335,6 +335,39 @@ struct CmxIrohRelayPolicyServiceTests { #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) } + @Test + func restoreKeepsRecentlyExpiredLastGoodPolicyRoutesForDialing() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + relayCredential: fixture.relayCredential(), + now: fixture.now + ) + + // The one-hour policy expired five minutes ago and the broker refresh + // failed (cmux#10375). Restoring must keep the last-good catalog + // dialable instead of publishing a zero-route managed profile; the + // relay itself remains the authority on credential validity. + let restored = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + relayCredential: nil, + now: fixture.now.addingTimeInterval(3_600 + 300) + ) + + #expect(restored.source == .managed) + #expect(restored.usedCachedPolicy) + #expect(restored.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) + #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) + } + @Test func implicitRevisionZeroStillRejectsEquivocation() async throws { let fixture = RelayPolicyServiceTestFixture() From 01c9410e49e5d01ef3d73f2dc709a4e5b033f5db Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:20:26 -0700 Subject: [PATCH 04/71] fix(iroh): bound the admission barrier, dial cached hints on refresh failure, fail open on credential refresh Three client-transport behavior changes for iOS dialing (cmux#9724, cmux#8531, cmux#10375): 1. Bounded dials. The admission barrier after QUIC connect (control stream open, admission frames, NAT-traversal authorize, server ready) was unbounded; a half-ready Mac that accepts the connection and never answers admission produced the 16.2s hang in the #9724 trace. The barrier now runs under the same bounded race as the connect phases and fails typed as dialTimedOut, so the redial machinery supersedes it. The per-phase deadline is injected end to end: CmxIrohClientRuntimeConfiguration.dialPhaseTimeout (default 5s) -> CmxConnectivityEngine -> every CmxIrohClientSession. 2. Hint refresh fallback. A failed or timed-out dial already marks the peer's discovery stale and forces a broker refetch on the next attempt. When that refetch itself fails, the provider now dials the last verified snapshot's hints instead of refusing to dial; the staleness mark survives so a later attempt still refetches. Broker cooldowns still propagate unchanged when no last-good snapshot exists. 3. Fail-open credential refresh. CmxIrohRelayPolicyService.restore grants a bounded expired-policy reuse grace (default 6h, injectable): the cache re-verifies the record at its final valid instant, so signature, rollback, and claim checks run unweakened and only the expiry gate is graced; the graced state reports .policyExpired without zeroing routes. Beyond the grace or on any verification rejection, restore still fails closed. CmxIrohRelayCredentialCoordinator.refreshIfNeeded no longer throws on a failed mint while a last-good credential is installed; the bounded backoff retry loop keeps refreshing in the background and the relay stays the authority on token validity. --- .../CmxConnectivityEngine.swift | 14 +- .../CmxIrohClientRuntime.swift | 3 +- .../CmxIrohClientRuntimeConfiguration.swift | 12 +- .../CmxIrohClientSession.swift | 123 +++++++++++------- .../CmxIrohRegistryContextProvider.swift | 22 ++++ .../CmxIrohRelayCredentialCoordinator.swift | 8 ++ .../CmxIrohRelayPolicyCache.swift | 35 ++++- .../CmxIrohRelayPolicyService.swift | 29 ++++- .../CmxIrohRelayPolicyServiceTests.swift | 70 +++++++++- 9 files changed, 255 insertions(+), 61 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index 784a545bb1f1..5f7b27c70973 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift @@ -26,6 +26,9 @@ public actor CmxConnectivityEngine { private let installRouteSnapshot: RouteSnapshotInstaller? private let diagnosticLog: DiagnosticLog? private let clock: any CmxIrohRelayClock + /// Deadline for each dial phase (public paths, private fallback, and the + /// admission barrier) of every peer session this engine creates. + private let dialPhaseTimeout: Duration private var desiredActive = false private var lifecycleRevision: UInt64 = 0 private var endpointGeneration: UInt64? @@ -59,7 +62,8 @@ public actor CmxConnectivityEngine { authority: (any CmxConnectivityAuthorityServing)? = nil, installRouteSnapshot: RouteSnapshotInstaller? = nil, diagnosticLog: DiagnosticLog? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() + clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), + dialPhaseTimeout: Duration = .seconds(5) ) { precondition((authority == nil) == (installRouteSnapshot == nil)) supervisor = CmxIrohEndpointSupervisor( @@ -72,6 +76,7 @@ public actor CmxConnectivityEngine { self.installRouteSnapshot = installRouteSnapshot self.diagnosticLog = diagnosticLog self.clock = clock + self.dialPhaseTimeout = dialPhaseTimeout } /// Creates a stopped endpoint-only engine for a host acceptor. @@ -90,6 +95,7 @@ public actor CmxConnectivityEngine { installRouteSnapshot = nil diagnosticLog = nil clock = CmxIrohSystemRelayClock() + dialPhaseTimeout = .seconds(5) } init( @@ -99,7 +105,8 @@ public actor CmxConnectivityEngine { authority: (any CmxConnectivityAuthorityServing)? = nil, installRouteSnapshot: RouteSnapshotInstaller? = nil, diagnosticLog: DiagnosticLog? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() + clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), + dialPhaseTimeout: Duration = .seconds(5) ) { precondition((authority == nil) == (installRouteSnapshot == nil)) self.supervisor = supervisor @@ -109,6 +116,7 @@ public actor CmxConnectivityEngine { self.installRouteSnapshot = installRouteSnapshot self.diagnosticLog = diagnosticLog self.clock = clock + self.dialPhaseTimeout = dialPhaseTimeout } /// Returns the current immutable UI-safe state. @@ -533,6 +541,7 @@ public actor CmxConnectivityEngine { let protocolConfiguration = protocolConfiguration let diagnosticLog = diagnosticLog let clock = clock + let dialPhaseTimeout = dialPhaseTimeout let peer = CmxConnectivityPeerSession( peerID: peerID, buildSession: { request in @@ -551,6 +560,7 @@ public actor CmxConnectivityEngine { basedOn: context ) }, + dialPhaseTimeout: dialPhaseTimeout, protocolConfiguration: protocolConfiguration, diagnostics: diagnosticLog ) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift index 9c3818e05d2b..444d10faecb8 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift @@ -172,7 +172,8 @@ public actor CmxIrohClientRuntime { supervisor: supervisor, contextProvider: contextRouter, protocolConfiguration: protocolConfiguration, - diagnosticLog: diagnosticLog + diagnosticLog: diagnosticLog, + dialPhaseTimeout: configuration.dialPhaseTimeout ) self.supervisor = supervisor self.connectivityEngine = connectivityEngine diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift index 893a1262e811..fe4813303713 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift @@ -44,6 +44,12 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// flight. A signed registration refresh follows after activation. public let cachedBinding: CmxIrohBrokerBindingMetadata? + /// Deadline for each dial phase (public paths, private fallback, and the + /// admission barrier) of every peer dial this runtime performs. A phase + /// that never answers fails typed at this bound and hands control back to + /// recovery instead of holding the reconnect owner (cmux#9724). + public let dialPhaseTimeout: Duration + /// Creates an immutable iOS client lifecycle configuration. /// /// Broker-facing validation occurs when ``CmxIrohClientRuntime/start()`` @@ -60,6 +66,8 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// - endpointRelayProfile: An optional local selection or custom override. /// - cachedRelayCredential: A validated cached relay capability. /// - cachedBinding: A previously verified exact local binding tuple. + /// - dialPhaseTimeout: The per-phase dial deadline, injectable so tests + /// can shrink it. public init( accountID: String, deviceID: String, @@ -72,7 +80,8 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { managedRelayURLs: Set, endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, - cachedBinding: CmxIrohBrokerBindingMetadata? = nil + cachedBinding: CmxIrohBrokerBindingMetadata? = nil, + dialPhaseTimeout: Duration = .seconds(5) ) { self.accountID = accountID self.deviceID = cmxCanonicalDeviceID(deviceID) @@ -86,5 +95,6 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { self.endpointRelayProfile = endpointRelayProfile self.cachedRelayCredential = cachedRelayCredential self.cachedBinding = cachedBinding + self.dialPhaseTimeout = dialPhaseTimeout } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift index e812caa454e6..9c73968bbe57 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift @@ -431,56 +431,71 @@ public actor CmxIrohClientSession { do { try Task.checkCancellation() - guard await establishedConnection.remoteIdentity() == targetIdentity else { - throw CmxIrohClientSessionError.remoteIdentityMismatch + // A half-ready peer can accept the QUIC connection and then never + // serve the admission frames. Bound the whole barrier like a dial + // phase so a silent peer hands control back to recovery instead + // of holding the redial owner open-endedly (cmux#9724). + return try await boundedByDialPhase { [weak self] in + guard let self else { throw CancellationError() } + return try await self.performAdmissionBarrier( + on: establishedConnection + ) + } + } catch { + await establishedConnection.close(errorCode: 1, reason: "admission_failed") + throw error + } + } + + private func performAdmissionBarrier( + on establishedConnection: any CmxIrohConnection + ) async throws -> CmxIrohConnectedControl { + guard await establishedConnection.remoteIdentity() == targetIdentity else { + throw CmxIrohClientSessionError.remoteIdentityMismatch + } + try await establishedConnection.setIncomingStreamLimits( + maximumBidirectionalStreamCount: 0, + maximumUnidirectionalStreamCount: 0 + ) + let stream = try await establishedConnection.openBidirectionalStream() + let header = try CmxIrohStreamHeader( + lane: .control, + credential: credential + ) + try await stream.sendStream.send(headerCodec.encode(header)) + let admission = try await readAdmissionFrame(from: stream.receiveStream) + switch admission.frame { + case .acceptedPendingNatTraversal, .acceptedRelayOnly: + if admission.frame == .acceptedPendingNatTraversal { + try Task.checkCancellation() + try await establishedConnection.authorizeNatTraversal() } - try await establishedConnection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 0, - maximumUnidirectionalStreamCount: 0 + try Task.checkCancellation() + try await stream.sendStream.send( + admissionCodec.encodeFrame(.clientReady) ) - let stream = try await establishedConnection.openBidirectionalStream() - let header = try CmxIrohStreamHeader( - lane: .control, - credential: credential + let confirmation = try await readAdmissionFrame( + from: stream.receiveStream, + initialBuffer: admission.trailingBytes ) - try await stream.sendStream.send(headerCodec.encode(header)) - let admission = try await readAdmissionFrame(from: stream.receiveStream) - switch admission.frame { - case .acceptedPendingNatTraversal, .acceptedRelayOnly: - if admission.frame == .acceptedPendingNatTraversal { - try Task.checkCancellation() - try await establishedConnection.authorizeNatTraversal() - } - try Task.checkCancellation() - try await stream.sendStream.send( - admissionCodec.encodeFrame(.clientReady) - ) - let confirmation = try await readAdmissionFrame( - from: stream.receiveStream, - initialBuffer: admission.trailingBytes - ) - switch confirmation.frame { - case .serverReady: - break - case let .denied(code): - throw CmxIrohClientSessionError.admissionDenied(code: code) - case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: - throw CmxIrohClientSessionError.invalidAdmissionFrame - } - try Task.checkCancellation() - return CmxIrohConnectedControl( - connection: establishedConnection, - stream: stream, - initialReceiveBuffer: confirmation.trailingBytes - ) + switch confirmation.frame { + case .serverReady: + break case let .denied(code): throw CmxIrohClientSessionError.admissionDenied(code: code) - case .clientReady, .serverReady: + case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: throw CmxIrohClientSessionError.invalidAdmissionFrame } - } catch { - await establishedConnection.close(errorCode: 1, reason: "admission_failed") - throw error + try Task.checkCancellation() + return CmxIrohConnectedControl( + connection: establishedConnection, + stream: stream, + initialReceiveBuffer: confirmation.trailingBytes + ) + case let .denied(code): + throw CmxIrohClientSessionError.admissionDenied(code: code) + case .clientReady, .serverReady: + throw CmxIrohClientSessionError.invalidAdmissionFrame } } @@ -498,22 +513,32 @@ public actor CmxIrohClientSession { ) async throws -> any CmxIrohConnection { let endpoint = endpoint let alpn = protocolConfiguration.alpn + return try await boundedByDialPhase { + try await endpoint.connect(to: address, alpn: alpn) + } + } + + /// Races one dial-phase operation against the injected phase bound. The + /// operation must cancel cooperatively; on timeout the loser is cancelled + /// and the phase fails typed so the redial machinery supersedes it + /// instead of wedging behind it (cmux#8531). + private func boundedByDialPhase( + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { let bound = dialPhaseTimeout - return try await withThrowingTaskGroup( - of: (any CmxIrohConnection)?.self - ) { group in + return try await withThrowingTaskGroup(of: Value?.self) { group in group.addTask { - try await endpoint.connect(to: address, alpn: alpn) + try await operation() } group.addTask { try await ContinuousClock().sleep(for: bound) return nil } defer { group.cancelAll() } - guard let first = try await group.next(), let connection = first else { + guard let first = try await group.next(), let value = first else { throw CmxIrohClientSessionError.dialTimedOut } - return connection + return value } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index a5fb93af343a..2c0fd5d50e3d 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -193,6 +193,28 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { ) usedFreshDiscovery = true } catch { + try Task.checkCancellation() + // The refresh failed. Dialing with the last verified snapshot + // beats not dialing at all (cmux#9724): the staleness mark + // survives, so the next attempt still refetches once the + // broker recovers. Verification is not weakened; this + // snapshot passed the same checks when it was fetched. + if let lastGood = authoritativeDiscovery { + do { + return try await resolveContext( + for: request, + targetIdentity: targetIdentity, + routeHints: routeHints, + discovery: lastGood, + at: clock + ) + } catch is CancellationError { + throw CancellationError() + } catch { + // The last-good snapshot no longer authorizes this + // peer; fall through to the offline cache. + } + } guard Self.isConnectivity(error), let cached = try await cachedPolicy( for: request, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift index a2fad66b2758..5e39c1d90275 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift @@ -374,6 +374,14 @@ public actor CmxIrohRelayCredentialCoordinator { ), initialFailureCount: 1 ) + // Fail open while a last-good credential is installed on the + // endpoint: a failed mint must not fail the caller's foreground + // path into zero-route dial churn (cmux#10375). The bounded retry + // loop above keeps refreshing in the background, and the relay is + // the authority that rejects a credential that truly went bad. + if installedCredential != nil, !(error is CancellationError) { + return + } throw error } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift index 6712e257bc9d..df245a1ea796 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift @@ -97,16 +97,47 @@ public actor CmxIrohRelayPolicyCache { /// - Parameters: /// - trustRoot: App-pinned public verification keys. /// - now: Verification time. + /// - expiredPolicyReuseGrace: Bounded fail-open window after the signed + /// expiry in which the last-good policy still loads. The policy is + /// re-verified at its final valid instant, so signature, rollback, + /// and claim checks run unweakened; only the expiry gate is graced + /// (cmux#10375). Zero preserves strict expiry. /// - Returns: The verified policy, or `nil` when no policy is cached. /// - Throws: ``CmxIrohRelayPolicyError`` or a secure-storage error. public func load( trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date + now: Date, + expiredPolicyReuseGrace: TimeInterval = 0 ) async throws -> CmxIrohManagedRelayPolicy? { await acquire() defer { release() } guard let record = try await storedRecord() else { return nil } - let policy = try verifier.verify(record.signedPolicy, trustRoot: trustRoot, now: now) + let policy: CmxIrohManagedRelayPolicy + do { + policy = try verifier.verify( + record.signedPolicy, + trustRoot: trustRoot, + now: now + ) + } catch CmxIrohRelayPolicyError.expired { + // The recorded expiry is cross-checked against the signed claims + // below; a record that overstates it re-fails as expired here or + // as rollback below. + guard expiredPolicyReuseGrace > 0, + let recordedExpiry = record.expiresAt, + now.timeIntervalSince1970 + <= TimeInterval(recordedExpiry) + expiredPolicyReuseGrace else { + throw CmxIrohRelayPolicyError.expired + } + let lastValidInstant = Date( + timeIntervalSince1970: TimeInterval(recordedExpiry) - 1 + ) + policy = try verifier.verify( + record.signedPolicy, + trustRoot: trustRoot, + now: min(now, lastValidInstant) + ) + } guard policy.sequence == record.highestSequence, Self.metadataMatches(policy, record: record) else { throw CmxIrohRelayPolicyError.rollback diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift index 8405a2292b6c..408f2305caaa 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift @@ -6,10 +6,18 @@ public actor CmxIrohRelayPolicyService { private typealias Resolution = CmxIrohRelayPolicyResolutionResult private typealias Resolver = CmxIrohRelayPolicyResolution + /// Bounded fail-open window in which ``restore`` keeps a recently-expired + /// last-good managed policy dialable after a failed refresh (cmux#10375). + /// A failed policy refresh must degrade to the last verified catalog, not + /// to a zero-route profile; the relay itself remains the authority on + /// credential validity and rejects a truly stale token. + public static let defaultExpiredPolicyReuseGrace: TimeInterval = 6 * 60 * 60 + private let policyCache: CmxIrohRelayPolicyCache private let preferenceStore: CmxIrohRelayPreferenceStore private let credentialStore: CmxIrohCustomRelayCredentialStore private let broker: (any CmxIrohRelayPolicyServing)? + private let expiredPolicyReuseGrace: TimeInterval private var currentEffective: CmxIrohEffectiveRelayPolicy? private var currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot.inactive private var continuations: [UUID: AsyncStream.Continuation] = [:] @@ -20,12 +28,15 @@ public actor CmxIrohRelayPolicyService { policyCache: CmxIrohRelayPolicyCache = CmxIrohRelayPolicyCache(), preferenceStore: CmxIrohRelayPreferenceStore = CmxIrohRelayPreferenceStore(), credentialStore: CmxIrohCustomRelayCredentialStore = CmxIrohCustomRelayCredentialStore(), - broker: (any CmxIrohRelayPolicyServing)? = nil + broker: (any CmxIrohRelayPolicyServing)? = nil, + expiredPolicyReuseGrace: TimeInterval = CmxIrohRelayPolicyService + .defaultExpiredPolicyReuseGrace ) { self.policyCache = policyCache self.preferenceStore = preferenceStore self.credentialStore = credentialStore self.broker = broker + self.expiredPolicyReuseGrace = max(0, expiredPolicyReuseGrace) } /// Fetches and installs the broker's current relay bootstrap response. @@ -196,7 +207,15 @@ public actor CmxIrohRelayPolicyService { } do { - guard let policy = try await policyCache.load(trustRoot: trustRoot, now: now) else { + // Restore is the failed-refresh fallback path, so it alone grants + // the bounded expired-policy grace: a recently-expired last-good + // catalog must stay dialable instead of zeroing every relay route + // (cmux#10375). The graced state is visible as `.policyExpired`. + guard let policy = try await policyCache.load( + trustRoot: trustRoot, + now: now, + expiredPolicyReuseGrace: expiredPolicyReuseGrace + ) else { return publishUnavailable( configuration: persisted.requested, revision: persisted.revision, @@ -205,6 +224,8 @@ public actor CmxIrohRelayPolicyService { failure: .policyUnavailable ) } + let policyIsExpired = TimeInterval(policy.expiresAt) + <= now.timeIntervalSince1970 let resolution = await Resolver.resolve( configuration: persisted.requested, revision: persisted.revision, @@ -218,7 +239,9 @@ public actor CmxIrohRelayPolicyService { return commit( Resolution( effective: resolution.effective, - failure: cleanupFailure ?? resolution.failure + failure: policyIsExpired + ? .policyExpired + : cleanupFailure ?? resolution.failure ), operation: operation ) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift index dadeed557f8c..cbe688287df4 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift @@ -288,6 +288,66 @@ struct CmxIrohRelayPolicyServiceTests { #expect(await stores.service.diagnosticsSnapshot().failure == .preferenceRollback) } + @Test + func restoreFailsClosedBeyondTheExpiredPolicyReuseGrace() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + relayCredential: fixture.relayCredential(), + now: fixture.now + ) + + let expired = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + relayCredential: nil, + now: fixture.now.addingTimeInterval( + 3_600 + CmxIrohRelayPolicyService.defaultExpiredPolicyReuseGrace + 1 + ) + ) + + #expect(expired.source == .managedUnavailable) + #expect(expired.endpointRelayProfile.allowedRelayURLs.isEmpty) + #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) + } + + @Test + func expiredPolicyGraceNeverBypassesSignatureVerification() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + relayCredential: fixture.relayCredential(), + now: fixture.now + ) + + // A trust root that rejects the cached policy's signing key must + // fail closed even inside the expiry grace window: the grace covers + // only time, never a rejected credential. + let rejected = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.secondTrustRoot, + relayCredential: nil, + now: fixture.now.addingTimeInterval(3_600 + 300) + ) + + #expect(rejected.source == .managedUnavailable) + #expect(rejected.endpointRelayProfile.allowedRelayURLs.isEmpty) + } + @Test func cacheRestoresUntilSignedExpiryAndSupportsStagedKeyRotation() async throws { let fixture = RelayPolicyServiceTestFixture() @@ -324,14 +384,18 @@ struct CmxIrohRelayPolicyServiceTests { #expect(restored.usedCachedPolicy) #expect(restored.managedSnapshot?.policy.sequence == 2) - let expired = await stores.service.restore( + // Immediately past the signed expiry the last-good policy stays + // dialable inside the bounded reuse grace (cmux#10375); the graced + // state is reported as `.policyExpired` without zeroing routes. + let graced = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, relayCredential: fixture.relayCredential(), now: fixture.now.addingTimeInterval(3_600) ) - #expect(expired.source == .managedUnavailable) - #expect(expired.endpointRelayProfile.allowedRelayURLs.isEmpty) + #expect(graced.source == .managed) + #expect(graced.usedCachedPolicy) + #expect(!graced.endpointRelayProfile.allowedRelayURLs.isEmpty) #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) } From 721d8bc7550cc857c6bbf13c70b74624e484a8b5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:30:09 -0700 Subject: [PATCH 05/71] Delete dead legacy broker relay-token route POST /api/devices/iroh/relay-token has zero callers: repo-wide grep for the path and its relay_token operation hits only the route file and web tests, and full-history git log -S over Packages/ Sources/ ios/ CLI/ cmux-tui/ daemon/ returns no commit in which any client referenced it. The Swift client has fetched relay credentials from POST /api/relay/token since the route was introduced in #7908. Removes the route dir, the relay_token IrohRouteOperation and dispatch, the public broker issueRelayToken (issueRelayTokenForBinding stays for the register bootstrap), its tests, and the stale README sentence. --- web/app/api/devices/iroh/relay-token/route.ts | 6 -- web/services/iroh/README.md | 7 ++- web/services/iroh/routeHandler.ts | 5 +- web/services/iroh/trustBroker.ts | 28 --------- web/tests/iroh-route-handler.test.ts | 29 +-------- web/tests/iroh-trust-broker.test.ts | 63 ------------------- 6 files changed, 8 insertions(+), 130 deletions(-) delete mode 100644 web/app/api/devices/iroh/relay-token/route.ts diff --git a/web/app/api/devices/iroh/relay-token/route.ts b/web/app/api/devices/iroh/relay-token/route.ts deleted file mode 100644 index c7dd0ed2899f..000000000000 --- a/web/app/api/devices/iroh/relay-token/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; - - -export async function POST(request: Request): Promise { - return handleIrohRoute(request, "relay_token"); -} diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 06f728d81b8b..a34b729a1c77 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -55,9 +55,10 @@ an exact authenticated user-id and deployment-environment allowlist match. Registration bootstraps a relay credential only when it creates a binding. Signed refreshes of the same binding return `relay.status = "not_requested"`; -clients retain their existing credential or use the dedicated relay-token route -when its refresh window arrives. Platform is part of the immutable binding -identity and requires explicit revocation before it can change. +clients retain their existing credential or refresh it through +`/api/relay/token` when its refresh window arrives. Platform is part of the +immutable binding identity and requires explicit revocation before it can +change. The n0-hosted relay minter is an optional compatibility path. When `CMUX_IROH_MINT_URL` and `CMUX_IROH_MINT_HMAC_SECRET_B64` are absent, initial diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index be8a16491398..70d768d8738c 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -23,8 +23,7 @@ export type IrohRouteOperation = | "discover" | "endpoint_attestation" | "revoke" - | "pair_grant" - | "relay_token"; + | "pair_grant"; type RouteDependencies = { readonly verify?: typeof verifyRequest; @@ -221,8 +220,6 @@ function invoke( return broker.revoke(userId, body, undefined, clientNamespace, bindingProof); case "pair_grant": return broker.issuePairGrant(userId, body, undefined, clientNamespace, bindingProof); - case "relay_token": - return broker.issueRelayToken(userId, body, undefined, clientNamespace, bindingProof); } } diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 13cdc74166fc..aba6e19359f1 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -145,13 +145,6 @@ export type IrohTrustBrokerShape = { clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; - readonly issueRelayToken: ( - userId: string, - raw: unknown, - now?: Date, - clientNamespace?: string, - bindingProof?: IrohBindingRequestProof, - ) => Effect.Effect; }; export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< @@ -258,25 +251,6 @@ export function makeIrohTrustBroker( }; }); - const issueRelayToken = ( - userId: string, - raw: unknown, - now = new Date(), - clientNamespace = "legacy", - bindingProof?: IrohBindingRequestProof, - ): Effect.Effect => Effect.gen(function* () { - const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); - const caller = yield* authorizeBinding(userId, bindingProof, clientNamespace, now); - if (caller && caller.id !== bindingId) { - return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - const binding = caller ?? (yield* repository.findActiveBindings(userId, [bindingId]))[0]; - if (!binding || (!caller && binding.clientNamespace !== "legacy")) { - return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - return yield* issueRelayTokenForBinding(userId, binding, now); - }); - const discover = ( userId: string, now = new Date(), @@ -722,8 +696,6 @@ export function makeIrohTrustBroker( grant_verification_keys: verificationKeys.keySet, }; }), - - issueRelayToken, }; } diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 4bb17b0a48ca..568f26d2b23d 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; +import { IrohDatabaseError } from "../services/iroh/errors"; import { buildConnectivityInvalidationRequest, handleIrohRoute, @@ -328,13 +328,11 @@ describe("Iroh route boundary", () => { issueEndpointAttestation: namespaced, revoke: namespaced, issuePairGrant: namespaced, - issueRelayToken: namespaced, }); const operations = [ "endpoint_attestation", "revoke", "pair_grant", - "relay_token", ] as const; for (const operation of operations) { const base = authedPost("/api/devices/iroh", {}); @@ -353,32 +351,12 @@ describe("Iroh route boundary", () => { ); expect(response.status).toBe(operation === "revoke" ? 200 : 201); } - expect(received).toEqual(Array(4).fill("dev.cmux.app.demo")); + expect(received).toEqual(Array(3).fill("dev.cmux.app.demo")); expect(receivedBindingIDs).toEqual( - Array(4).fill("123e4567-e89b-42d3-a456-426614174000"), + Array(3).fill("123e4567-e89b-42d3-a456-426614174000"), ); }); - test("maps DB-authoritative quota failures to typed 429 with Retry-After", async () => { - const response = await handleIrohRoute(authedPost("/api/devices/iroh/relay-token", { - bindingId: "30000000-0000-4000-8000-000000000001", - }), "relay_token", { - verify: async () => USER, - broker: broker({ - issueRelayToken: () => Effect.fail(new IrohQuotaExceededError({ - code: "relay_endpoint_10m_quota", - retryAfterSeconds: 417, - })), - }), - }); - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("417"); - expect(await response.json()).toEqual({ - error: "relay_endpoint_10m_quota", - retry_after_seconds: 417, - }); - }); - test("does not expose database implementation details in service failures", async () => { const response = await handleIrohRoute(authedPost("/api/devices/iroh/challenge", {}), "challenge", { verify: async () => USER, @@ -435,7 +413,6 @@ function broker(overrides: Partial = {}): IrohTrustBrokerS issueEndpointAttestation: unavailable, revoke: unavailable, issuePairGrant: unavailable, - issueRelayToken: unavailable, ...overrides, }; } diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 8938329fbcb2..34f31f791a37 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -1704,18 +1704,6 @@ describe("Iroh discovery and grants", () => { bindingBody, ), ), "IrohNotFoundError"); - await expectEffectFailure(fixture.broker.issueRelayToken( - USER_A, - bindingBody, - NOW, - "dev.cmux.app.beta", - fixture.bindingProof( - beta.id, - "POST", - "api/relay/token", - bindingBody, - ), - ), "IrohNotFoundError"); const pairBody = { initiatorBindingId: internal.id, acceptorBindingId: mac.id, @@ -1851,57 +1839,6 @@ describe("Iroh discovery and grants", () => { }); }); -describe("Iroh relay quotas", () => { - test("never calls the minter for an unregistered or revoked binding", async () => { - const fixture = makeFixture(); - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: randomUUID() }, NOW), - "IrohNotFoundError", - ); - const revoked = binding({ userId: USER_A, revokedAt: NOW }); - fixture.repository.bindings.push(revoked); - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: revoked.id }, NOW), - "IrohNotFoundError", - ); - expect(fixture.minter.calls).toBe(0); - }); - - test("treats authenticated relay renewal as binding activity", async () => { - const fixture = makeFixture(); - const active = binding({ - userId: USER_A, - lastSeenAt: new Date(NOW.getTime() - 48 * 60 * 60 * 1_000), - updatedAt: new Date(NOW.getTime() - 48 * 60 * 60 * 1_000), - }); - fixture.repository.bindings.push(active); - - await Effect.runPromise(fixture.broker.issueRelayToken( - USER_A, - { bindingId: active.id }, - NOW, - )); - - expect(active.lastSeenAt).toEqual(NOW); - expect(active.updatedAt).toEqual(NOW); - }); - - test("does not return a relay credential when the binding is revoked during mint", async () => { - const fixture = makeFixture(); - const active = binding({ userId: USER_A }); - fixture.repository.bindings.push(active); - fixture.minter.afterMint = () => { - active.revokedAt = NOW; - }; - - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, NOW), - "IrohNotFoundError", - ); - expect(fixture.repository.relayIssuances[0]?.status).toBe("failed"); - }); -}); - type MutableBinding = IrohBindingRecord & { userId: string; directPortV4: number | null; From 017a52e32d42867f40526f879863f7ce4396cf87 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:38:22 -0700 Subject: [PATCH 06/71] Delete the dormant n0-hosted relay minter compatibility path Production has never set CMUX_IROH_MINT_URL / CMUX_IROH_MINT_HMAC_SECRET_B64, so every registration already took the mint-unconfigured branch and returned relay.status="unavailable"; clients get endpoint-bound fleet credentials from POST /api/relay/token instead. The only importers of the minter client (web/services/iroh/relayMinter.ts, minterUrlPolicy.ts) were trustBroker.ts, env.ts, and their tests; the Rust service services/iroh-relay-minter/ is in no Cargo workspace, package.json, or vercel config, and its only external reference was its own dispatch-only GitHub workflow. register now returns relay unavailable/not_requested directly, preserving the env-unset behavior exactly (minus the failed-issuance audit row). This deliberately removes the dormant n0-hosted fallback option; the registry / relay allow-hook path is the go-forward. Operational follow-up outside this repo: decommission the minter Vercel project and drop the two env vars from the web project. --- .github/workflows/iroh-relay-minter.yml | 39 - services/iroh-relay-minter/.env.example | 5 - services/iroh-relay-minter/.gitignore | 4 - services/iroh-relay-minter/Cargo.lock | 4590 ----------------- services/iroh-relay-minter/Cargo.toml | 53 - services/iroh-relay-minter/README.md | 98 - services/iroh-relay-minter/api/relay-token.rs | 18 - .../iroh-relay-minter/examples/loopback.rs | 37 - .../iroh-relay-minter/rust-toolchain.toml | 4 - services/iroh-relay-minter/src/lib.rs | 929 ---- services/iroh-relay-minter/vercel.json | 3 - .../iroh/relay-minter-request-v1.json | 6 - web/.env.example | 4 - web/app/env.ts | 51 - web/services/connectivity/routeHandler.ts | 1 - web/services/iroh/README.md | 32 +- web/services/iroh/config.ts | 11 - web/services/iroh/errors.ts | 12 +- web/services/iroh/minterUrlPolicy.ts | 40 - web/services/iroh/relayMinter.ts | 207 - web/services/iroh/routeHandler.ts | 3 - web/services/iroh/trustBroker.ts | 80 +- web/tests/client-config-env.test.ts | 107 - web/tests/iroh-model-crypto.test.ts | 189 - web/tests/iroh-trust-broker.test.ts | 52 +- 25 files changed, 26 insertions(+), 6549 deletions(-) delete mode 100644 .github/workflows/iroh-relay-minter.yml delete mode 100644 services/iroh-relay-minter/.env.example delete mode 100644 services/iroh-relay-minter/.gitignore delete mode 100644 services/iroh-relay-minter/Cargo.lock delete mode 100644 services/iroh-relay-minter/Cargo.toml delete mode 100644 services/iroh-relay-minter/README.md delete mode 100644 services/iroh-relay-minter/api/relay-token.rs delete mode 100644 services/iroh-relay-minter/examples/loopback.rs delete mode 100644 services/iroh-relay-minter/rust-toolchain.toml delete mode 100644 services/iroh-relay-minter/src/lib.rs delete mode 100644 services/iroh-relay-minter/vercel.json delete mode 100644 tests/fixtures/iroh/relay-minter-request-v1.json delete mode 100644 web/services/iroh/minterUrlPolicy.ts delete mode 100644 web/services/iroh/relayMinter.ts diff --git a/.github/workflows/iroh-relay-minter.yml b/.github/workflows/iroh-relay-minter.yml deleted file mode 100644 index 8a8f07e850ef..000000000000 --- a/.github/workflows/iroh-relay-minter.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Iroh relay minter - -on: - # CI pause: preserve explicit validation without automatic PR/main runs. - workflow_dispatch: - -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 --component 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 diff --git a/services/iroh-relay-minter/.env.example b/services/iroh-relay-minter/.env.example deleted file mode 100644 index 5f56d2143364..000000000000 --- a/services/iroh-relay-minter/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Configure these only on the isolated relay-minter Vercel project. -IROH_SERVICES_API_SECRET= -CMUX_IROH_MINT_HMAC_SECRET_B64= -# Optional, minter-only overlap key during a bounded HMAC rotation. -CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64= diff --git a/services/iroh-relay-minter/.gitignore b/services/iroh-relay-minter/.gitignore deleted file mode 100644 index 226a93ca6de2..000000000000 --- a/services/iroh-relay-minter/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/target/ -/.vercel/ -/.env* -!/.env.example diff --git a/services/iroh-relay-minter/Cargo.lock b/services/iroh-relay-minter/Cargo.lock deleted file mode 100644 index c31436a8045b..000000000000 --- a/services/iroh-relay-minter/Cargo.lock +++ /dev/null @@ -1,4590 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "attohttpc" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" -dependencies = [ - "base64", - "http", - "log", - "url", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" -dependencies = [ - "cargo-lock", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-lock" -version = "11.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" -dependencies = [ - "semver", - "serde", - "toml", - "url", -] - -[[package]] -name = "cc" -version = "1.2.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "cmux-iroh-relay-minter" -version = "0.1.0" -dependencies = [ - "base64", - "data-encoding", - "futures-util", - "hex", - "hmac", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "iroh", - "iroh-services", - "rcan", - "serde", - "serde_json", - "sha2 0.10.9", - "time", - "tokio", - "vercel_runtime", - "zeroize", -] - -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cordyceps" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" -dependencies = [ - "loom", - "tracing", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto", - "rand_core", - "rustc_version", - "serde", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "data-encoding-macro" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" -dependencies = [ - "data-encoding", - "syn", -] - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "diatomic-waker" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8", - "serdect", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", - "serde", - "sha2 0.11.0", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "enum-assoc" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-buffered" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" -dependencies = [ - "cordyceps", - "diatomic-waker", - "futures-core", - "pin-project-lite", - "spin 0.10.0", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generator" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core", - "wasm-bindgen", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin 0.9.8", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hickory-net" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" -dependencies = [ - "async-trait", - "bytes", - "cfg-if", - "data-encoding", - "futures-channel", - "futures-io", - "futures-util", - "h2", - "hickory-proto", - "http", - "idna", - "ipnet", - "jni 0.22.4", - "rand", - "rustls", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tokio-rustls", - "tracing", - "url", -] - -[[package]] -name = "hickory-proto" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni 0.22.4", - "once_cell", - "prefix-trie", - "rand", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-net", - "hickory-proto", - "ipconfig", - "ipnet", - "jni 0.22.4", - "moka", - "ndk-context", - "once_cell", - "parking_lot", - "rand", - "resolv-conf", - "rustls", - "smallvec", - "system-configuration", - "thiserror 2.0.18", - "tokio", - "tokio-rustls", - "tracing", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-layer", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "identity-hash" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "igd-next" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" -dependencies = [ - "attohttpc", - "bytes", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "rand", - "tokio", - "url", - "xmltree", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -dependencies = [ - "serde", -] - -[[package]] -name = "iroh" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" -dependencies = [ - "backon", - "blake3", - "bytes", - "cfg_aliases", - "ctutils", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-util", - "getrandom 0.4.3", - "hickory-resolver", - "http", - "ipnet", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "iroh-relay", - "n0-error", - "n0-future", - "n0-watcher", - "netwatch", - "noq", - "noq-proto", - "noq-udp", - "papaya", - "pin-project", - "portable-atomic", - "portmapper", - "rand", - "reqwest", - "rustc-hash", - "rustls", - "rustls-pki-types", - "serde", - "smallvec", - "strum", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", -] - -[[package]] -name = "iroh-base" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" -dependencies = [ - "curve25519-dalek", - "data-encoding", - "data-encoding-macro", - "derive_more", - "ed25519-dalek", - "getrandom 0.4.3", - "n0-error", - "rand", - "serde", - "url", - "zeroize", -] - -[[package]] -name = "iroh-dns" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" -dependencies = [ - "arc-swap", - "cfg_aliases", - "derive_more", - "hickory-resolver", - "iroh-base", - "n0-error", - "n0-future", - "ndk-context", - "portable-atomic", - "rand", - "rustls", - "simple-dns", - "strum", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "iroh-metrics" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" -dependencies = [ - "iroh-metrics-derive", - "itoa", - "n0-error", - "portable-atomic", - "ryu", - "serde", - "tracing", -] - -[[package]] -name = "iroh-metrics-derive" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "iroh-relay" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more", - "getrandom 0.4.3", - "hickory-resolver", - "http", - "http-body-util", - "hyper", - "hyper-util", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "lru", - "n0-error", - "n0-future", - "noq", - "noq-proto", - "num_enum", - "pin-project", - "postcard", - "rand", - "reqwest", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "strum", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "vergen-gitcl", - "webpki-roots", - "ws_stream_wasm", -] - -[[package]] -name = "iroh-services" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a88cd95fbd20abd9eadc4df91c722915dc943412b964e915f35b0b0a46a1fd" -dependencies = [ - "anyhow", - "base64", - "built", - "bytes", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-buffered", - "getrandom 0.4.3", - "iroh", - "iroh-metrics", - "iroh-tickets", - "irpc", - "irpc-iroh", - "n0-error", - "n0-future", - "portmapper", - "postcard", - "rand", - "rcan", - "serde", - "serde_json", - "strum", - "thiserror 2.0.18", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "iroh-tickets" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" -dependencies = [ - "data-encoding", - "derive_more", - "iroh-base", - "n0-error", - "postcard", - "serde", -] - -[[package]] -name = "irpc" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" -dependencies = [ - "futures-buffered", - "futures-util", - "irpc-derive", - "n0-error", - "n0-future", - "noq", - "postcard", - "rcgen", - "rustls", - "serde", - "smallvec", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "irpc-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "irpc-iroh" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e" -dependencies = [ - "getrandom 0.3.4", - "iroh", - "iroh-base", - "irpc", - "n0-error", - "n0-future", - "postcard", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "n0-error" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" -dependencies = [ - "n0-error-macros", - "spez", -] - -[[package]] -name = "n0-error-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "n0-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" -dependencies = [ - "cfg_aliases", - "derive_more", - "futures-buffered", - "futures-lite", - "futures-util", - "js-sys", - "pin-project", - "send_wrapper", - "tokio", - "tokio-util", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "n0-watcher" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" -dependencies = [ - "derive_more", - "n0-error", - "n0-future", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "netdev" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", - "objc2", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - -[[package]] -name = "netlink-packet-core" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" -dependencies = [ - "paste", -] - -[[package]] -name = "netlink-packet-route" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" -dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-proto" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" -dependencies = [ - "bytes", - "futures", - "log", - "netlink-packet-core", - "netlink-sys", - "thiserror 2.0.18", -] - -[[package]] -name = "netlink-sys" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" -dependencies = [ - "bytes", - "futures-util", - "libc", - "log", - "tokio", -] - -[[package]] -name = "netwatch" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" -dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more", - "ipnet", - "js-sys", - "libc", - "n0-error", - "n0-future", - "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route", - "netlink-proto", - "netlink-sys", - "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", - "pin-project-lite", - "serde", - "socket2", - "time", - "tokio", - "tokio-util", - "tracing", - "web-sys", - "windows", - "windows-result", - "wmi", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "noq" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" -dependencies = [ - "bytes", - "cfg_aliases", - "derive_more", - "noq-proto", - "noq-udp", - "pin-project-lite", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "web-time", -] - -[[package]] -name = "noq-proto" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" -dependencies = [ - "aes-gcm", - "bytes", - "derive_more", - "enum-assoc", - "getrandom 0.4.3", - "identity-hash", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "sorted-index-buffer", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "noq-udp" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" -dependencies = [ - "cfg_aliases", - "libc", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "bitflags", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-security", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "papaya" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" -dependencies = [ - "equivalent", - "seize", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -dependencies = [ - "serde", -] - -[[package]] -name = "portmapper" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" -dependencies = [ - "base64", - "bytes", - "derive_more", - "hyper-util", - "igd-next", - "iroh-metrics", - "libc", - "n0-error", - "n0-future", - "netwatch", - "num_enum", - "rand", - "serde", - "smallvec", - "socket2", - "time", - "tokio", - "tokio-util", - "tower-layer", - "tracing", - "url", -] - -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "postcard-derive", - "serde", -] - -[[package]] -name = "postcard-derive" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prefix-trie" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" -dependencies = [ - "either", - "ipnet", - "num-traits", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rcan" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a624a4a4742f8c6e58fba99712e606cea0491b76f5b2345f06af5802101027" -dependencies = [ - "anyhow", - "derive_more", - "ed25519-dalek", - "hex", - "n0-future", - "postcard", - "serde", - "serdect", -] - -[[package]] -name = "rcgen" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-automata" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.22.4", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "seize" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct", - "serde", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "simple-dns" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" -dependencies = [ - "bitflags", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sorted-index-buffer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" - -[[package]] -name = "spez" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" -dependencies = [ - "deranged", - "js-sys", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-websockets" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-sink", - "getrandom 0.4.3", - "http", - "httparse", - "rand", - "ring", - "rustls-pki-types", - "sha1_smol", - "simdutf8", - "tokio", - "tokio-rustls", - "tokio-util", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vercel_runtime" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5e7942f725023f1572b7fef91b0aeddc8749a3b9b0b191f59c208c42ed8e62" -dependencies = [ - "base64", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "lazy_static", - "libc", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tower", -] - -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wmi" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" -dependencies = [ - "chrono", - "futures", - "log", - "serde", - "thiserror 2.0.18", - "windows", - "windows-core", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xmltree" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" -dependencies = [ - "xml-rs", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/iroh-relay-minter/Cargo.toml b/services/iroh-relay-minter/Cargo.toml deleted file mode 100644 index f50a71e4290d..000000000000 --- a/services/iroh-relay-minter/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "cmux-iroh-relay-minter" -version = "0.1.0" -edition = "2024" -rust-version = "1.91" -publish = false -autobins = false - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "relay-token" -path = "api/relay-token.rs" - -[dependencies] -base64 = "0.22.1" -data-encoding = "2.9.0" -hex = "0.4.3" -hmac = "0.12.1" -http-body = "1.0.1" -http-body-util = "0.1.3" -hyper = { version = "1.7.0", features = ["http1"] } -iroh = { version = "=1.0.0", default-features = false } -iroh-services = { version = "=1.0.0", default-features = false } -rcan = "=0.4.0" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" -sha2 = "0.10.9" -time = { version = "0.3.44", features = ["formatting", "parsing"] } -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } -vercel_runtime = "=2.0.0" -zeroize = "1.8.1" - -[dev-dependencies] -futures-util = "0.3.31" -hyper-util = { version = "0.1.17", features = ["server", "http1", "tokio"] } -tokio = { version = "1.47.1", features = ["net"] } - -[profile.release] -codegen-units = 1 -lto = "fat" -opt-level = 3 -panic = "abort" -strip = true - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -dbg_macro = "deny" -todo = "deny" -unimplemented = "deny" diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md deleted file mode 100644 index 9d9858cf0684..000000000000 --- a/services/iroh-relay-minter/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Iroh relay-token minter - -This Vercel Rust project is the only cmux service allowed to hold the Iroh -Services project credential. It converts a short-lived request authenticated by -the cmux web trust broker into a 24-hour, endpoint-scoped RCAN containing only -`relay:use`. - -Deploy this directory as a separate Vercel project. Set its Root Directory to -`services/iroh-relay-minter`. Do not add `IROH_SERVICES_API_SECRET` to the cmux -web project because Vercel environment variables are project-wide. - -## Environment - -The minter project requires: - -- `IROH_SERVICES_API_SECRET`: the rotated Iroh Services project secret. It is - parsed by `iroh-services` 1.0.0 and is never returned or logged. -- `CMUX_IROH_MINT_HMAC_SECRET_B64`: 32 to 256 random bytes encoded as canonical - standard base64. Generate a new 32-byte value with `openssl rand -base64 32`. -- `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`: optional minter-only previous key - accepted during a bounded rotation overlap. It must differ from the current - key. The web project never receives this value. - -The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: - -- `CMUX_IROH_MINT_URL=https:///api/relay-token` - -Rotate any Iroh Services credential previously pasted into chat or logs before -putting it in Vercel. A Services credential rotation affects only the minter. - -Rotate the HMAC without an outage in this order: - -1. Deploy the minter with the new key in `CMUX_IROH_MINT_HMAC_SECRET_B64` and - the old key in `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`. -2. Change the web project's `CMUX_IROH_MINT_HMAC_SECRET_B64` to the new key. -3. Keep the previous key for at least five minutes, which covers the 30-second - request timestamp window and deployment propagation. -4. Remove `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64` from the minter. - -The overlap changes only which HMAC key authenticates the existing fixed -method, path, timestamp, and body-hash transcript. It does not expand the -minter route or RCAN capabilities. - -## Wire contract - -The only accepted route is `POST /api/relay-token` with one `Content-Type` -header whose media type is `application/json`, optionally followed by parameters -such as `charset=utf-8`, no query string, and this body: - -```json -{"endpointId":"<64 lowercase hex characters>","lifetimeSeconds":86400} -``` - -The web service sends: - -- `x-cmux-iroh-timestamp`: canonical Unix seconds, within 30 seconds of the - minter clock. -- `x-cmux-iroh-signature`: unpadded base64url HMAC-SHA256 over the transcript - below. - -```text -POST -/api/relay-token - - -``` - -The response is bounded JSON: - -```json -{"token":"","expiresAt":""} -``` - -The RCAN issuer is the Iroh Services project key, the audience is the supplied -EndpointID, the sole capability is `relay:use`, and expiry is 86,400 seconds. -The trust broker stores only issuance audit state and refreshes the relay token -after 12 hours. - -## Local verification - -No production secrets are needed for tests. - -```sh -cargo fmt --check -cargo clippy --all-targets --locked -- -D warnings -cargo test --locked -``` - -For authenticated local dogfood, the example server binds only to loopback and -uses the same request handler as the Vercel function: - -```sh -CMUX_IROH_MINT_DEV_PORT=9460 cargo run --locked --example loopback -``` - -It still requires `IROH_SERVICES_API_SECRET` and -`CMUX_IROH_MINT_HMAC_SECRET_B64` in the process environment. Do not use a -credential copied through chat for a deployed environment; rotate it first. diff --git a/services/iroh-relay-minter/api/relay-token.rs b/services/iroh-relay-minter/api/relay-token.rs deleted file mode 100644 index 1c1da8d8e52a..000000000000 --- a/services/iroh-relay-minter/api/relay-token.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::time::SystemTime; - -use cmux_iroh_relay_minter::{MinterConfig, configuration_error_response, handle_request}; -use vercel_runtime::{Error, Request, Response, ResponseBody, run, service_fn}; - -#[tokio::main] -async fn main() -> Result<(), Error> { - run(service_fn(handler)).await -} - -async fn handler(request: Request) -> Result, Error> { - let config = match MinterConfig::from_env() { - Ok(config) => config, - Err(_) => return Ok(configuration_error_response()), - }; - - Ok(handle_request(request, &config, SystemTime::now()).await) -} diff --git a/services/iroh-relay-minter/examples/loopback.rs b/services/iroh-relay-minter/examples/loopback.rs deleted file mode 100644 index 2d501715eb9d..000000000000 --- a/services/iroh-relay-minter/examples/loopback.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::{convert::Infallible, env, sync::Arc, time::SystemTime}; - -use cmux_iroh_relay_minter::{MinterConfig, handle_request}; -use hyper::{Request, body::Incoming, server::conn::http1, service::service_fn}; -use hyper_util::rt::TokioIo; -use tokio::net::TcpListener; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let port = env::var("CMUX_IROH_MINT_DEV_PORT") - .ok() - .map(|value| value.parse::()) - .transpose()? - .unwrap_or(9460); - let listener = TcpListener::bind(("127.0.0.1", port)).await?; - let config = Arc::new(MinterConfig::from_env()?); - eprintln!("Iroh relay minter listening on http://127.0.0.1:{port}"); - - loop { - let (stream, peer) = listener.accept().await?; - if !peer.ip().is_loopback() { - continue; - } - let config = Arc::clone(&config); - tokio::spawn(async move { - let service = service_fn(move |request: Request| { - let config = Arc::clone(&config); - async move { - Ok::<_, Infallible>(handle_request(request, &config, SystemTime::now()).await) - } - }); - let _ = http1::Builder::new() - .serve_connection(TokioIo::new(stream), service) - .await; - }); - } -} diff --git a/services/iroh-relay-minter/rust-toolchain.toml b/services/iroh-relay-minter/rust-toolchain.toml deleted file mode 100644 index 0f39414be778..000000000000 --- a/services/iroh-relay-minter/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.91.0" -profile = "minimal" -components = ["clippy", "rustfmt"] diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs deleted file mode 100644 index 2672a171f1b0..000000000000 --- a/services/iroh-relay-minter/src/lib.rs +++ /dev/null @@ -1,929 +0,0 @@ -use std::{ - env, fmt, - str::FromStr, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use base64::{ - Engine as _, - engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, -}; -use data_encoding::BASE32_NOPAD; -use hmac::{Hmac, Mac}; -use http_body::Body; -use http_body_util::BodyExt as _; -use hyper::{ - Method, Request, Response, StatusCode, - body::Bytes, - header::{ALLOW, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName}, -}; -use iroh::{EndpointId, SecretKey}; -use iroh_services::{ - ApiSecret, - caps::{Cap, Caps, RelayCap, create_api_token_from_secret_key}, -}; -use rcan::Expires; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use vercel_runtime::ResponseBody; -use zeroize::Zeroizing; - -pub const MINT_PATH: &str = "/api/relay-token"; -pub const RELAY_TOKEN_LIFETIME_SECONDS: u64 = 86_400; -pub const MAX_REQUEST_BYTES: usize = 4 * 1_024; - -const MAX_RESPONSE_BYTES: usize = 32 * 1_024; -const MAX_TOKEN_BYTES: usize = 16 * 1_024; -const CLOCK_SKEW_SECONDS: u64 = 30; -const SERVICES_SECRET_ENV: &str = "IROH_SERVICES_API_SECRET"; -const HMAC_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_SECRET_B64"; -const HMAC_PREVIOUS_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64"; -const TIMESTAMP_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-timestamp"); -const SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-signature"); - -type HmacSha256 = Hmac; - -pub struct MinterConfig { - issuer: SecretKey, - hmac_secret: Zeroizing>, - hmac_previous_secret: Option>>, -} - -impl MinterConfig { - pub fn from_env() -> Result { - let services_secret = Zeroizing::new(bounded_env(SERVICES_SECRET_ENV, 16_384)?); - let api_secret = - ApiSecret::from_str(services_secret.as_str()).map_err(|_| ConfigurationError)?; - let hmac_secret_text = Zeroizing::new(bounded_env(HMAC_SECRET_ENV, 512)?); - let hmac_secret = Zeroizing::new(decode_hmac_secret(hmac_secret_text.as_str())?); - let hmac_previous_secret = optional_bounded_env(HMAC_PREVIOUS_SECRET_ENV, 512)? - .map(Zeroizing::new) - .map(|value| decode_hmac_secret(value.as_str())) - .transpose()? - .map(Zeroizing::new); - if hmac_previous_secret - .as_ref() - .is_some_and(|previous| previous.as_slice() == hmac_secret.as_slice()) - { - return Err(ConfigurationError); - } - - Ok(Self { - issuer: api_secret.secret, - hmac_secret, - hmac_previous_secret, - }) - } -} - -#[derive(Clone, Copy, Debug)] -pub struct ConfigurationError; - -impl fmt::Display for ConfigurationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("relay minter configuration is unavailable") - } -} - -impl std::error::Error for ConfigurationError {} - -pub async fn handle_request( - request: Request, - config: &MinterConfig, - now: SystemTime, -) -> Response -where - B: Body + Unpin, -{ - match process_request(request, config, now).await { - Ok(response) => json_response(StatusCode::OK, &response), - Err(error) => error_response(error.status(), error.code()), - } -} - -pub fn configuration_error_response() -> Response { - error_response(StatusCode::SERVICE_UNAVAILABLE, "configuration_unavailable") -} - -async fn process_request( - request: Request, - config: &MinterConfig, - now: SystemTime, -) -> Result -where - B: Body + Unpin, -{ - if request.method() != Method::POST { - return Err(RequestFailure::Method); - } - if request.uri().path() != MINT_PATH || request.uri().query().is_some() { - return Err(RequestFailure::Path); - } - require_json_content_type(request.headers())?; - validate_content_length(request.headers())?; - - let timestamp_text = single_header(request.headers(), &TIMESTAMP_HEADER) - .ok_or(RequestFailure::Authentication)? - .to_owned(); - let timestamp = parse_timestamp(×tamp_text).ok_or(RequestFailure::Authentication)?; - let now_seconds = unix_seconds(now).ok_or(RequestFailure::Internal)?; - if now_seconds.abs_diff(timestamp) > CLOCK_SKEW_SECONDS { - return Err(RequestFailure::Authentication); - } - - let signature_text = single_header(request.headers(), &SIGNATURE_HEADER) - .ok_or(RequestFailure::Authentication)?; - let signature = decode_signature(signature_text).ok_or(RequestFailure::Authentication)?; - - let body = read_bounded_body(request.into_body()).await?; - verify_configured_hmac(config, ×tamp_text, &body, &signature)?; - - let input: MintRequest = - serde_json::from_slice(&body).map_err(|_| RequestFailure::InvalidBody)?; - if input.lifetime_seconds != RELAY_TOKEN_LIFETIME_SECONDS { - return Err(RequestFailure::InvalidLifetime); - } - let endpoint_id = parse_endpoint_id(&input.endpoint_id)?; - - mint_token(config, endpoint_id, now_seconds) -} - -async fn read_bounded_body(mut body: B) -> Result, RequestFailure> -where - B: Body + Unpin, -{ - if body - .size_hint() - .upper() - .is_some_and(|upper| upper > MAX_REQUEST_BYTES as u64) - { - return Err(RequestFailure::BodyTooLarge); - } - - let mut bytes = Vec::with_capacity( - body.size_hint() - .upper() - .unwrap_or(0) - .min(MAX_REQUEST_BYTES as u64) as usize, - ); - while let Some(frame) = body.frame().await { - let frame = frame.map_err(|_| RequestFailure::InvalidBody)?; - let Ok(data) = frame.into_data() else { - continue; - }; - let Some(next_len) = bytes.len().checked_add(data.len()) else { - return Err(RequestFailure::BodyTooLarge); - }; - if next_len > MAX_REQUEST_BYTES { - return Err(RequestFailure::BodyTooLarge); - } - bytes.extend_from_slice(&data); - } - Ok(bytes) -} - -fn mint_token( - config: &MinterConfig, - endpoint_id: EndpointId, - authenticated_at: u64, -) -> Result { - let capability = Caps::new([Cap::Relay(RelayCap::Use)]); - let rcan = create_api_token_from_secret_key( - config.issuer.clone(), - endpoint_id, - Duration::from_secs(RELAY_TOKEN_LIFETIME_SECONDS), - capability, - ) - .map_err(|_| RequestFailure::Internal)?; - - let Expires::At(expires_at) = rcan.expires() else { - return Err(RequestFailure::Internal); - }; - let expected_expiry = authenticated_at - .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) - .ok_or(RequestFailure::Internal)?; - if expected_expiry.abs_diff(*expires_at) > 2 { - return Err(RequestFailure::Internal); - } - - let mut token = BASE32_NOPAD.encode(&rcan.encode()); - token.make_ascii_lowercase(); - if token.is_empty() - || token.len() > MAX_TOKEN_BYTES - || token.contains('=') - || !token - .bytes() - .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) - { - return Err(RequestFailure::Internal); - } - - let expires_at_i64 = i64::try_from(*expires_at).map_err(|_| RequestFailure::Internal)?; - let expires_at = OffsetDateTime::from_unix_timestamp(expires_at_i64) - .map_err(|_| RequestFailure::Internal)? - .format(&Rfc3339) - .map_err(|_| RequestFailure::Internal)?; - if expires_at.len() > 64 { - return Err(RequestFailure::Internal); - } - - Ok(MintResponse { token, expires_at }) -} - -fn verify_hmac( - secret: &[u8], - timestamp: &str, - body: &[u8], - signature: &[u8; 32], -) -> Result<(), RequestFailure> { - let body_hash = hex::encode(Sha256::digest(body)); - let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); - let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| RequestFailure::Internal)?; - mac.update(transcript.as_bytes()); - mac.verify_slice(signature) - .map_err(|_| RequestFailure::Authentication) -} - -fn verify_configured_hmac( - config: &MinterConfig, - timestamp: &str, - body: &[u8], - signature: &[u8; 32], -) -> Result<(), RequestFailure> { - let current_matches = - verify_hmac(config.hmac_secret.as_slice(), timestamp, body, signature).is_ok(); - let previous_matches = config - .hmac_previous_secret - .as_ref() - .is_some_and(|secret| verify_hmac(secret.as_slice(), timestamp, body, signature).is_ok()); - if current_matches || previous_matches { - Ok(()) - } else { - Err(RequestFailure::Authentication) - } -} - -fn parse_endpoint_id(value: &str) -> Result { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(RequestFailure::InvalidEndpoint); - } - let mut bytes = [0_u8; 32]; - hex::decode_to_slice(value, &mut bytes).map_err(|_| RequestFailure::InvalidEndpoint)?; - EndpointId::from_bytes(&bytes).map_err(|_| RequestFailure::InvalidEndpoint) -} - -fn require_json_content_type(headers: &HeaderMap) -> Result<(), RequestFailure> { - let content_type = single_header(headers, &CONTENT_TYPE).ok_or(RequestFailure::ContentType)?; - let media_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - if media_type.eq_ignore_ascii_case("application/json") { - Ok(()) - } else { - Err(RequestFailure::ContentType) - } -} - -fn validate_content_length(headers: &HeaderMap) -> Result<(), RequestFailure> { - let values = headers.get_all(&CONTENT_LENGTH); - let mut values = values.iter(); - let Some(value) = values.next() else { - return Ok(()); - }; - if values.next().is_some() { - return Err(RequestFailure::InvalidBody); - } - let length = value - .to_str() - .ok() - .and_then(|value| value.parse::().ok()) - .ok_or(RequestFailure::InvalidBody)?; - if length > MAX_REQUEST_BYTES { - Err(RequestFailure::BodyTooLarge) - } else { - Ok(()) - } -} - -fn single_header<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> { - let values = headers.get_all(name); - let mut values = values.iter(); - let value = values.next()?.to_str().ok()?; - if values.next().is_some() { - return None; - } - Some(value) -} - -fn parse_timestamp(value: &str) -> Option { - if value.is_empty() || value.len() > 20 || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - let parsed: u64 = value.parse().ok()?; - (parsed.to_string() == value).then_some(parsed) -} - -fn decode_signature(value: &str) -> Option<[u8; 32]> { - if value.len() != 43 || value.contains('=') { - return None; - } - let decoded = URL_SAFE_NO_PAD.decode(value.as_bytes()).ok()?; - let signature: [u8; 32] = decoded.try_into().ok()?; - (URL_SAFE_NO_PAD.encode(signature) == value).then_some(signature) -} - -fn decode_hmac_secret(value: &str) -> Result, ConfigurationError> { - let decoded = STANDARD - .decode(value.as_bytes()) - .or_else(|_| STANDARD_NO_PAD.decode(value.as_bytes())) - .map_err(|_| ConfigurationError)?; - if decoded.len() < 32 || decoded.len() > 256 { - return Err(ConfigurationError); - } - let canonical_padded = STANDARD.encode(&decoded); - let canonical_unpadded = STANDARD_NO_PAD.encode(&decoded); - if value != canonical_padded && value != canonical_unpadded { - return Err(ConfigurationError); - } - Ok(decoded) -} - -fn bounded_env(name: &str, max_bytes: usize) -> Result { - let value = env::var(name).map_err(|_| ConfigurationError)?; - if value.is_empty() || value.len() > max_bytes { - return Err(ConfigurationError); - } - Ok(value) -} - -fn optional_bounded_env( - name: &str, - max_bytes: usize, -) -> Result, ConfigurationError> { - match env::var(name) { - Ok(value) if !value.is_empty() && value.len() <= max_bytes => Ok(Some(value)), - Ok(_) => Err(ConfigurationError), - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => Err(ConfigurationError), - } -} - -fn unix_seconds(time: SystemTime) -> Option { - time.duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_secs()) -} - -fn json_response(status: StatusCode, value: &impl Serialize) -> Response { - let body = serde_json::to_string(value) - .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); - if body.len() > MAX_RESPONSE_BYTES { - return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal_error"); - } - response(status, body) -} - -fn error_response(status: StatusCode, code: &'static str) -> Response { - let body = serde_json::to_string(&ErrorResponse { error: code }) - .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); - let mut response = response(status, body); - if status == StatusCode::METHOD_NOT_ALLOWED { - response - .headers_mut() - .insert(ALLOW, "POST".parse().expect("static header value")); - } - response -} - -fn response(status: StatusCode, body: String) -> Response { - Response::builder() - .status(status) - .header(CONTENT_TYPE, "application/json") - .header(CACHE_CONTROL, "no-store") - .header("x-content-type-options", "nosniff") - .body(ResponseBody::from(body)) - .expect("static response metadata is valid") -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct MintRequest { - endpoint_id: String, - lifetime_seconds: u64, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct MintResponse { - token: String, - expires_at: String, -} - -#[derive(Serialize)] -struct ErrorResponse { - error: &'static str, -} - -#[derive(Clone, Copy, Debug)] -enum RequestFailure { - Method, - Path, - ContentType, - Authentication, - BodyTooLarge, - InvalidBody, - InvalidEndpoint, - InvalidLifetime, - Internal, -} - -impl RequestFailure { - const fn status(self) -> StatusCode { - match self { - Self::Method => StatusCode::METHOD_NOT_ALLOWED, - Self::Path => StatusCode::NOT_FOUND, - Self::ContentType => StatusCode::UNSUPPORTED_MEDIA_TYPE, - Self::Authentication => StatusCode::UNAUTHORIZED, - Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - Self::InvalidBody | Self::InvalidEndpoint | Self::InvalidLifetime => { - StatusCode::BAD_REQUEST - } - Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - const fn code(self) -> &'static str { - match self { - Self::Method => "method_not_allowed", - Self::Path => "not_found", - Self::ContentType => "unsupported_media_type", - Self::Authentication => "unauthorized", - Self::BodyTooLarge => "body_too_large", - Self::InvalidBody => "invalid_body", - Self::InvalidEndpoint => "invalid_endpoint_id", - Self::InvalidLifetime => "invalid_lifetime", - Self::Internal => "internal_error", - } - } -} - -#[cfg(test)] -mod tests { - use std::{convert::Infallible, time::SystemTime}; - - use futures_util::stream; - use http_body::Frame; - use http_body_util::{BodyExt as _, Full, StreamBody}; - use hyper::{ - Method, Request, StatusCode, - body::Bytes, - header::{ALLOW, HeaderValue}, - }; - use iroh::SecretKey; - use rcan::{CapabilityOrigin, Expires, Rcan}; - use time::OffsetDateTime; - - use super::*; - - const TEST_HMAC_SECRET: [u8; 32] = [0x42; 32]; - const PREVIOUS_HMAC_SECRET: [u8; 32] = [0x41; 32]; - - #[tokio::test] - async fn mints_lowercase_relay_only_rcan_for_the_exact_audience() { - let config = test_config(); - let endpoint = test_endpoint(); - let now = SystemTime::now(); - let now_seconds = unix_seconds(now).expect("test clock is after the Unix epoch"); - let body = valid_body(&endpoint.to_string()); - let request = signed_request(Method::POST, MINT_PATH, now_seconds, body); - - let response = handle_request(request, &config, now).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); - let response: MintResponse = response_json(response).await; - - assert!(!response.token.contains('=')); - assert!(response.token.len() <= MAX_TOKEN_BYTES); - assert!( - response - .token - .bytes() - .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) - ); - - let token_bytes = BASE32_NOPAD - .decode(response.token.to_ascii_uppercase().as_bytes()) - .expect("response is unpadded base32"); - let rcan = Rcan::::decode(&token_bytes).expect("response is a signed RCAN"); - assert_eq!(rcan.audience(), &endpoint.as_verifying_key()); - assert_eq!(rcan.issuer(), &config.issuer.public().as_verifying_key()); - assert_eq!(rcan.capability_origin(), &CapabilityOrigin::Issuer); - assert_eq!(rcan.capability().to_strings(), ["relay:use"]); - - let Expires::At(token_expiry) = rcan.expires() else { - panic!("relay token must expire"); - }; - assert!( - now_seconds - .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) - .expect("test expiry is representable") - .abs_diff(*token_expiry) - <= 2 - ); - let response_expiry = OffsetDateTime::parse(&response.expires_at, &Rfc3339) - .expect("response expiry is RFC 3339"); - assert_eq!(response_expiry.unix_timestamp(), *token_expiry as i64); - } - - #[tokio::test] - async fn rejects_every_method_and_path_except_the_single_post_route() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let response = handle_request( - signed_request(Method::GET, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(response.headers()[ALLOW], "POST"); - - for path in ["/", "/api/relay-token/", "/api/relay-token?debug=1"] { - let response = handle_request( - signed_request(Method::POST, path, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::NOT_FOUND, "path {path}"); - } - } - - #[tokio::test] - async fn rejects_missing_wrong_or_body_substituted_hmac() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let mut missing = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - missing.headers_mut().remove(&SIGNATURE_HEADER); - assert_eq!( - handle_request(missing, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - - let mut wrong = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - wrong.headers_mut().insert( - &SIGNATURE_HEADER, - URL_SAFE_NO_PAD - .encode([0_u8; 32]) - .parse() - .expect("test header is valid"), - ); - assert_eq!( - handle_request(wrong, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - - let mut substituted = signed_request(Method::POST, MINT_PATH, timestamp, body); - *substituted.body_mut() = Full::new(Bytes::from(valid_body( - &SecretKey::from_bytes(&[0x33; 32]).public().to_string(), - ))); - assert_eq!( - handle_request(substituted, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - } - - #[tokio::test] - async fn accepts_the_previous_hmac_secret_only_during_rotation_overlap() { - let mut config = test_config(); - config.hmac_previous_secret = Some(Zeroizing::new(PREVIOUS_HMAC_SECRET.to_vec())); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let request = signed_request_with_secret( - Method::POST, - MINT_PATH, - timestamp, - body.clone(), - &PREVIOUS_HMAC_SECRET, - ); - - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::OK - ); - let previous_after_overlap = signed_request_with_secret( - Method::POST, - MINT_PATH, - timestamp, - body, - &PREVIOUS_HMAC_SECRET, - ); - assert_eq!( - handle_request(previous_after_overlap, &test_config(), now) - .await - .status(), - StatusCode::UNAUTHORIZED - ); - } - - #[tokio::test] - async fn enforces_the_thirty_second_timestamp_window() { - let config = test_config(); - let now = SystemTime::now(); - let now_seconds = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - for timestamp in [now_seconds - 31, now_seconds + 31] { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - for timestamp in [now_seconds - 30, now_seconds + 30] { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::OK); - } - - let mut malformed = signed_request(Method::POST, MINT_PATH, now_seconds, body); - malformed.headers_mut().insert( - &TIMESTAMP_HEADER, - "+123".parse().expect("test header is valid"), - ); - assert_eq!( - handle_request(malformed, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - assert_eq!(parse_timestamp("01"), None); - } - - #[tokio::test] - async fn bounds_declared_and_streamed_request_bodies() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let mut declared = signed_request(Method::POST, MINT_PATH, timestamp, body); - declared.headers_mut().insert( - CONTENT_LENGTH, - (MAX_REQUEST_BYTES + 1) - .to_string() - .parse() - .expect("test header is valid"), - ); - assert_eq!( - handle_request(declared, &config, now).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - - let chunk = Bytes::from(vec![b'x'; MAX_REQUEST_BYTES / 2 + 1]); - let streamed_body = [chunk.clone(), chunk]; - let joined = streamed_body.concat(); - let signature = sign_request(timestamp, &joined); - let body_stream = StreamBody::new(stream::iter( - streamed_body - .into_iter() - .map(|chunk| Ok::, Infallible>(Frame::data(chunk))), - )); - let streamed = Request::builder() - .method(Method::POST) - .uri(MINT_PATH) - .header(CONTENT_TYPE, "application/json") - .header(&TIMESTAMP_HEADER, timestamp.to_string()) - .header(&SIGNATURE_HEADER, signature) - .body(body_stream) - .expect("test request is valid"); - assert_eq!( - handle_request(streamed, &config, now).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - } - - #[tokio::test] - async fn rejects_invalid_endpoint_lifetime_and_json_shape() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let endpoint = test_endpoint().to_string(); - let invalid_bodies = [ - format!( - "{{\"endpointId\":\"{}\",\"lifetimeSeconds\":86400}}", - endpoint.to_ascii_uppercase() - ), - format!("{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86401}}"), - format!( - "{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86400,\"capability\":\"all\"}}" - ), - "{}".to_owned(), - ]; - - for body in invalid_bodies { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - } - - #[tokio::test] - async fn accepts_json_content_type_parameters() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let mut request = signed_request(Method::POST, MINT_PATH, timestamp, body); - request.headers_mut().insert( - CONTENT_TYPE, - "Application/JSON; charset=utf-8" - .parse() - .expect("valid test header"), - ); - - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::OK - ); - } - - #[tokio::test] - async fn rejects_non_json_duplicate_and_malformed_content_type_headers() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let mut non_json = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - non_json.headers_mut().insert( - CONTENT_TYPE, - "text/plain".parse().expect("valid test header"), - ); - let mut duplicate = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - duplicate.headers_mut().append( - CONTENT_TYPE, - "application/json; charset=utf-8" - .parse() - .expect("valid test header"), - ); - let mut malformed = signed_request(Method::POST, MINT_PATH, timestamp, body); - malformed.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_bytes(&[0xff]).expect("opaque header bytes are representable"), - ); - - for request in [non_json, duplicate, malformed] { - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::UNSUPPORTED_MEDIA_TYPE - ); - } - } - - #[test] - fn matches_the_typescript_hmac_wire_fixture() { - #[derive(Deserialize)] - struct Fixture { - path: String, - timestamp: String, - body: String, - signature: String, - } - - let fixture: Fixture = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../tests/fixtures/iroh/relay-minter-request-v1.json" - ))) - .expect("shared relay-minter fixture is valid"); - assert_eq!(fixture.path, MINT_PATH); - let timestamp = parse_timestamp(&fixture.timestamp).expect("fixture timestamp is valid"); - assert_eq!( - sign_request(timestamp, fixture.body.as_bytes()), - fixture.signature - ); - let signature = decode_signature(&fixture.signature).expect("fixture signature is valid"); - verify_hmac( - &TEST_HMAC_SECRET, - &fixture.timestamp, - fixture.body.as_bytes(), - &signature, - ) - .expect("fixture authenticates"); - let request: MintRequest = - serde_json::from_str(&fixture.body).expect("fixture body matches the contract"); - assert_eq!(request.lifetime_seconds, RELAY_TOKEN_LIFETIME_SECONDS); - parse_endpoint_id(&request.endpoint_id).expect("fixture endpoint is valid"); - } - - #[test] - fn accepts_only_canonical_hmac_secret_encodings_of_at_least_32_bytes() { - let padded = STANDARD.encode(TEST_HMAC_SECRET); - let unpadded = STANDARD_NO_PAD.encode(TEST_HMAC_SECRET); - assert_eq!( - decode_hmac_secret(&padded).expect("padded secret"), - TEST_HMAC_SECRET - ); - assert_eq!( - decode_hmac_secret(&unpadded).expect("unpadded secret"), - TEST_HMAC_SECRET - ); - assert!(decode_hmac_secret(&STANDARD.encode([1_u8; 31])).is_err()); - assert!(decode_hmac_secret(&format!(" {padded}")).is_err()); - assert!(decode_hmac_secret(&URL_SAFE_NO_PAD.encode([0xff_u8; 32])).is_err()); - } - - fn test_config() -> MinterConfig { - MinterConfig { - issuer: SecretKey::from_bytes(&[0x11; 32]), - hmac_secret: Zeroizing::new(TEST_HMAC_SECRET.to_vec()), - hmac_previous_secret: None, - } - } - - fn test_endpoint() -> EndpointId { - SecretKey::from_bytes(&[0x22; 32]).public() - } - - fn valid_body(endpoint_id: &str) -> String { - format!( - "{{\"endpointId\":\"{endpoint_id}\",\"lifetimeSeconds\":{RELAY_TOKEN_LIFETIME_SECONDS}}}" - ) - } - - fn signed_request( - method: Method, - path: &str, - timestamp: u64, - body: String, - ) -> Request> { - signed_request_with_secret(method, path, timestamp, body, &TEST_HMAC_SECRET) - } - - fn signed_request_with_secret( - method: Method, - path: &str, - timestamp: u64, - body: String, - secret: &[u8], - ) -> Request> { - Request::builder() - .method(method) - .uri(path) - .header(CONTENT_TYPE, "application/json") - .header(&TIMESTAMP_HEADER, timestamp.to_string()) - .header( - &SIGNATURE_HEADER, - sign_request_with_secret(secret, timestamp, body.as_bytes()), - ) - .body(Full::new(Bytes::from(body))) - .expect("test request is valid") - } - - fn sign_request(timestamp: u64, body: &[u8]) -> String { - sign_request_with_secret(&TEST_HMAC_SECRET, timestamp, body) - } - - fn sign_request_with_secret(secret: &[u8], timestamp: u64, body: &[u8]) -> String { - let body_hash = hex::encode(Sha256::digest(body)); - let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); - let mut mac = HmacSha256::new_from_slice(secret).expect("test HMAC key length is valid"); - mac.update(transcript.as_bytes()); - URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) - } - - async fn response_json(response: Response) -> T - where - T: for<'de> Deserialize<'de>, - { - let bytes = response - .into_body() - .collect() - .await - .expect("test response body is readable") - .to_bytes(); - serde_json::from_slice(&bytes).expect("test response is JSON") - } -} diff --git a/services/iroh-relay-minter/vercel.json b/services/iroh-relay-minter/vercel.json deleted file mode 100644 index 20fc77e7e7cf..000000000000 --- a/services/iroh-relay-minter/vercel.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json" -} diff --git a/tests/fixtures/iroh/relay-minter-request-v1.json b/tests/fixtures/iroh/relay-minter-request-v1.json deleted file mode 100644 index 978434ba6659..000000000000 --- a/tests/fixtures/iroh/relay-minter-request-v1.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "path": "/api/relay-token", - "timestamp": "1783641600", - "body": "{\"endpointId\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"lifetimeSeconds\":86400}", - "signature": "U7P9cSbpQMrtmshYTTuBALTsDhGwxWqTG4mIdlqgX4c" -} diff --git a/web/.env.example b/web/.env.example index 7a73d45a897d..db5e0790c242 100644 --- a/web/.env.example +++ b/web/.env.example @@ -93,10 +93,6 @@ CMUX_IROH_GRANT_SIGNING_KID= # Versioned public Ed25519 key set. Each key is canonical SPKI DER base64. # {"version":1,"current_kid":"current","keys":[{"kid":"current","alg":"EdDSA","spki_der_base64":"..."}]} CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON= -CMUX_IROH_MINT_URL= -# Current request-authentication key. The optional previous overlap key belongs -# only in the isolated minter project. -CMUX_IROH_MINT_HMAC_SECRET_B64= CMUX_IROH_RATE_LIMIT_ID= # Must exactly match the presence Worker's CONNECTIVITY_INVALIDATION_SECRET. # Generate an independent value with `openssl rand -hex 32`. diff --git a/web/app/env.ts b/web/app/env.ts index 91e6231eefbb..43841fddc35d 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -1,10 +1,5 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; -import { - insecureLoopbackMinterAllowed, - parseIrohMinterUrl, - type IrohMinterUrlPolicy, -} from "../services/iroh/minterUrlPolicy"; // Trim at the runtimeEnv source so every consumer — including paths that // run when validation is skipped (VERCEL_ENV === "preview") — sees clean @@ -31,13 +26,6 @@ const isVercelProductionDeployment = process.env.VERCEL === "1" && process.env.VERCEL_ENV === "production" && !isDocsZone; -const irohMinterUrlPolicy: IrohMinterUrlPolicy = { - allowInsecureLoopback: - trimEnv(process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER) === "1", - deploymentEnvironment: - process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", - isVercelDeployment: process.env.VERCEL === "1", -}; const requireVercelNonPreviewValue = ( name: string, schema: z.ZodType = z.string().min(1), @@ -116,32 +104,6 @@ const publicEnvValidationIssues = (issues: readonly unknown[]): readonly unknown } return publicIssues; }; -const localDevelopmentOptIn = (name: string) => - z.enum(["0", "1"]).optional().superRefine((value, context) => { - if ( - value === "1" && - !insecureLoopbackMinterAllowed({ - ...irohMinterUrlPolicy, - allowInsecureLoopback: true, - }) - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: `${name} is only allowed in local development`, - }); - } - }); -const irohMinterUrl = z.string().url().superRefine((value, context) => { - try { - parseIrohMinterUrl(value, irohMinterUrlPolicy); - } catch { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: - "CMUX_IROH_MINT_URL must use HTTPS, except for an opted-in local loopback development minter", - }); - } -}); const irohBindingLimit = z.string().regex(/^[1-9][0-9]{0,3}$/).superRefine((value, context) => { if (Number(value) > 4_096) { context.addIssue({ @@ -280,11 +242,6 @@ export const env = createEnv({ "CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON", z.string().min(2).max(32_768), ), - // Optional compatibility path for n0-hosted relay credentials. The - // self-hosted fleet mints endpoint-bound JWTs through /api/relay/token. - CMUX_IROH_MINT_URL: irohMinterUrl.optional(), - CMUX_IROH_MINT_HMAC_SECRET_B64: - z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), // Optional: leave unset to disable iroh rate limiting entirely. When unset, // the firewall gate in routeHandler.ts is skipped. Matches the other // optional rate-limit IDs (for example @@ -297,9 +254,6 @@ export const env = createEnv({ // Server-to-worker authentication for revision publication. Native clients // hold only their Stack access token and can never mint invalidations. CMUX_CONNECTIVITY_INVALIDATION_SECRET: z.string().min(32).max(512).optional(), - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( - "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", - ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), @@ -399,16 +353,11 @@ export const env = createEnv({ CMUX_IROH_GRANT_SIGNING_KEY_P8: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KEY_P8), CMUX_IROH_GRANT_SIGNING_KID: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KID), CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON), - CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), - CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), CMUX_PRESENCE_BASE_URL: trimEnv(process.env.CMUX_PRESENCE_BASE_URL), CMUX_CONNECTIVITY_INVALIDATION_SECRET: trimEnv( process.env.CMUX_CONNECTIVITY_INVALIDATION_SECRET, ), - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: trimEnv( - process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER, - ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), diff --git a/web/services/connectivity/routeHandler.ts b/web/services/connectivity/routeHandler.ts index ea472e1164de..d657c15743dc 100644 --- a/web/services/connectivity/routeHandler.ts +++ b/web/services/connectivity/routeHandler.ts @@ -162,7 +162,6 @@ function connectivityExpectedErrorResponse( }); case "IrohConfigurationError": case "IrohDatabaseError": - case "IrohRelayMintError": return connectivityJsonResponse({ error: "connectivity_service_unavailable" }, 503); } } diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index a34b729a1c77..e99968c44fc1 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -45,34 +45,24 @@ the Stack credential. There is no total active-binding limit per account or device. Postgres advisory locks keep request-rate limits concurrency-safe: six challenges per device per -ten minutes, 32 outstanding challenges per account, 60 pair grants per account -per hour, three relay mints per endpoint per ten minutes, 12 relay mints per -endpoint per day, and 100 relay mints per account per day. A relay reservation -remains active for 60 seconds, then the next account-scoped reservation marks it -expired before applying those quotas. The optional Vercel Firewall rule is -defense in depth. A tagged-build override widens challenge issuance only after -an exact authenticated user-id and deployment-environment allowlist match. +ten minutes, 32 outstanding challenges per account, and 60 pair grants per +account per hour. The optional Vercel Firewall rule is defense in depth. A +tagged-build override widens challenge issuance only after an exact +authenticated user-id and deployment-environment allowlist match. -Registration bootstraps a relay credential only when it creates a binding. -Signed refreshes of the same binding return `relay.status = "not_requested"`; -clients retain their existing credential or refresh it through -`/api/relay/token` when its refresh window arrives. Platform is part of the -immutable binding identity and requires explicit revocation before it can -change. - -The n0-hosted relay minter is an optional compatibility path. When -`CMUX_IROH_MINT_URL` and `CMUX_IROH_MINT_HMAC_SECRET_B64` are absent, initial -registration returns `relay.status = "unavailable"` without rolling back the -binding. Current clients obtain endpoint-bound credentials for the self-hosted -fleet from `/api/relay/token`. +Registration never mints a relay credential. A newly created binding receives +`relay.status = "unavailable"` and signed refreshes of the same binding return +`relay.status = "not_requested"`; clients obtain endpoint-bound credentials for +the self-hosted fleet from `/api/relay/token`, which admits callers by their +active binding. Platform is part of the immutable binding identity and requires +explicit revocation before it can change. Every user-scoped mutation acquires the account-deletion advisory fence before any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or relay audit state can be created. If an Iroh mutation wins, account deletion waits for that transaction and then removes its rows. Pair grants re-read and lock both exact signed peers at audit insertion, requiring an iOS initiator and -a pairable Mac acceptor. Relay credentials are returned only after a second -locked active-binding check following the external mint. +a pairable Mac acceptor. Registration stores the earliest managed-relay expiry in `path_hints_next_expiry`. The hourly cleanup uses that indexed scalar and diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts index d015a12897d4..9b0bd91a1609 100644 --- a/web/services/iroh/config.ts +++ b/web/services/iroh/config.ts @@ -8,11 +8,6 @@ export type IrohTrustBrokerConfigShape = { readonly grantSigningPrivateKeyPem?: string; readonly grantSigningKid?: string; readonly grantVerificationKeysJson?: string; - readonly relayMinterUrl?: string; - readonly relayMinterHmacSecretBase64?: string; - readonly relayMinterInsecureLoopbackOptIn: boolean; - readonly deploymentEnvironment: string; - readonly isVercelDeployment: boolean; }; export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConfig")< @@ -27,12 +22,6 @@ export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { grantSigningPrivateKeyPem: env.CMUX_IROH_GRANT_SIGNING_KEY_P8, grantSigningKid: env.CMUX_IROH_GRANT_SIGNING_KID, grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, - relayMinterUrl: env.CMUX_IROH_MINT_URL, - relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, - relayMinterInsecureLoopbackOptIn: - env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", - deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", - isVercelDeployment: process.env.VERCEL === "1", }; } diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts index 67d5214f3490..9dec0efd98be 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -27,8 +27,7 @@ export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationE | "grant_signing" | "grant_verification" | "account_subject" - | "lan_discovery" - | "relay_minter"; + | "lan_discovery"; }> {} export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ @@ -36,11 +35,6 @@ export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ readonly cause: unknown; }> {} -export class IrohRelayMintError extends Data.TaggedError("IrohRelayMintError")<{ - readonly code: string; - readonly cause?: unknown; -}> {} - export type IrohExpectedError = | IrohInvalidInputError | IrohNotFoundError @@ -48,8 +42,7 @@ export type IrohExpectedError = | IrohConflictError | IrohQuotaExceededError | IrohConfigurationError - | IrohDatabaseError - | IrohRelayMintError; + | IrohDatabaseError; export function irohExpectedError(error: unknown): IrohExpectedError | null { if (!error || typeof error !== "object") return null; @@ -88,5 +81,4 @@ const IROH_ERROR_TAGS = new Set([ "IrohQuotaExceededError", "IrohConfigurationError", "IrohDatabaseError", - "IrohRelayMintError", ]); diff --git a/web/services/iroh/minterUrlPolicy.ts b/web/services/iroh/minterUrlPolicy.ts deleted file mode 100644 index 7aac5faac229..000000000000 --- a/web/services/iroh/minterUrlPolicy.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; - -export type IrohMinterUrlPolicy = { - readonly allowInsecureLoopback: boolean; - readonly deploymentEnvironment: string; - readonly isVercelDeployment: boolean; -}; - -export function insecureLoopbackMinterAllowed(policy: IrohMinterUrlPolicy): boolean { - return policy.allowInsecureLoopback && - !policy.isVercelDeployment && - policy.deploymentEnvironment === "development"; -} - -export function parseIrohMinterUrl(value: string, policy: IrohMinterUrlPolicy): URL { - const url = new URL(value); - const secureTransport = url.protocol === "https:"; - const allowedDevelopmentTransport = - url.protocol === "http:" && - insecureLoopbackMinterAllowed(policy) && - isCanonicalLoopbackHost(url.hostname); - - if ( - (!secureTransport && !allowedDevelopmentTransport) || - url.username || - url.password || - url.pathname !== IROH_RELAY_MINTER_PATH || - url.search || - url.hash - ) { - throw new Error("invalid Iroh relay minter URL"); - } - return url; -} - -function isCanonicalLoopbackHost(hostname: string): boolean { - return hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "[::1]"; -} diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts deleted file mode 100644 index 8bffeed91bf5..000000000000 --- a/web/services/iroh/relayMinter.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { createHash, createHmac } from "node:crypto"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { - IrohConfigurationError, - type IrohInvalidInputError, - IrohRelayMintError, -} from "./errors"; -import { IrohTrustBrokerConfig } from "./config"; -import { - IROH_RELAY_MINTER_PATH, - parseIrohMinterUrl, - type IrohMinterUrlPolicy, -} from "./minterUrlPolicy"; -import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; - -const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; -export { IROH_RELAY_MINTER_PATH }; - -export type IrohRelayMintResult = { - readonly token: string; - readonly expiresAt: Date; -}; - -export type IrohRelayMinterShape = { - readonly mint: (input: { - readonly endpointId: string; - readonly lifetimeSeconds: typeof IROH_RELAY_TOKEN_LIFETIME_SECONDS; - readonly now: Date; - }) => Effect.Effect< - IrohRelayMintResult, - IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError - >; -}; - -export class IrohRelayMinter extends Context.Tag("cmux/IrohRelayMinter")< - IrohRelayMinter, - IrohRelayMinterShape ->() {} - -export const IrohRelayMinterLive = Layer.effect( - IrohRelayMinter, - Effect.gen(function* () { - const config = yield* IrohTrustBrokerConfig; - return { - mint: (input) => mintWithIsolatedService(config, input), - } satisfies IrohRelayMinterShape; - }), -); - -function mintWithIsolatedService( - config: typeof IrohTrustBrokerConfig.Service, - input: Parameters[0], -): Effect.Effect< - IrohRelayMintResult, - IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError -> { - return Effect.tryPromise({ - try: async () => { - endpointId(input.endpointId); - const url = parseMinterUrl(config.relayMinterUrl, { - allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, - deploymentEnvironment: config.deploymentEnvironment, - isVercelDeployment: config.isVercelDeployment, - }); - const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); - const body = JSON.stringify({ - endpointId: input.endpointId, - lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, - }); - const timestamp = String(Math.floor(input.now.getTime() / 1_000)); - const bodyHash = createHash("sha256").update(body).digest("hex"); - const signature = createHmac("sha256", secret) - .update(`POST\n${url.pathname}\n${timestamp}\n${bodyHash}`, "utf8") - .digest("base64url"); - const response = await fetch(url, { - method: "POST", - redirect: "error", - signal: AbortSignal.timeout(10_000), - headers: { - "content-type": "application/json", - "x-cmux-iroh-timestamp": timestamp, - "x-cmux-iroh-signature": signature, - }, - body, - }); - if (!response.ok) throw new IrohRelayMintError({ code: "minter_rejected" }); - const raw = await readBoundedMinterJson(response); - if ( - typeof raw.token !== "string" || - raw.token.length < 16 || - raw.token.length > 16_384 || - !/^[a-z2-7]+$/.test(raw.token) - ) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - if (typeof raw.expiresAt !== "string") throw new IrohRelayMintError({ code: "invalid_minter_response" }); - const expiresAt = new Date(raw.expiresAt); - const contractExpiry = input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000; - if ( - !Number.isFinite(expiresAt.getTime()) || - expiresAt <= input.now || - expiresAt.getTime() > contractExpiry + 60_000 || - expiresAt.getTime() < contractExpiry - 5 * 60_000 - ) { - throw new IrohRelayMintError({ code: "invalid_minter_expiry" }); - } - return { token: raw.token, expiresAt }; - }, - catch: (cause) => { - if ((cause as { _tag?: unknown } | null)?._tag === "IrohConfigurationError") { - return cause as IrohConfigurationError; - } - if ((cause as { _tag?: unknown } | null)?._tag === "IrohInvalidInputError") { - return cause as IrohInvalidInputError; - } - if ((cause as { _tag?: unknown } | null)?._tag === "IrohRelayMintError") { - return cause as IrohRelayMintError; - } - return new IrohRelayMintError({ code: "minter_unavailable", cause: safeCause(cause) }); - }, - }); -} - -export async function readBoundedMinterJson( - response: Response, -): Promise<{ token?: unknown; expiresAt?: unknown }> { - if ( - response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== - "application/json" - ) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const parsed = Number(contentLength); - if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_MINTER_RESPONSE_BYTES) { - throw new IrohRelayMintError({ code: "minter_response_too_large" }); - } - } - const reader = response.body?.getReader(); - if (!reader) throw new IrohRelayMintError({ code: "invalid_minter_response" }); - const chunks: Uint8Array[] = []; - let total = 0; - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > MAX_MINTER_RESPONSE_BYTES) { - await reader.cancel(); - throw new IrohRelayMintError({ code: "minter_response_too_large" }); - } - chunks.push(next.value); - } - const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); - let parsed: unknown; - try { - parsed = JSON.parse(bytes.toString("utf8")); - } catch { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - const object = parsed as Record; - const keys = Object.keys(object); - if (keys.length !== 2 || !keys.includes("token") || !keys.includes("expiresAt")) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - return object; -} - -export function parseMinterUrl( - value: string | undefined, - policy: IrohMinterUrlPolicy = { - allowInsecureLoopback: false, - deploymentEnvironment: "production", - isVercelDeployment: true, - }, -): URL { - if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); - try { - return parseIrohMinterUrl(value, policy); - } catch { - throw new IrohConfigurationError({ component: "relay_minter" }); - } -} - -export function parseMinterHmacSecret(value: string | undefined): Buffer { - if (!value || value.length > 512) throw new IrohConfigurationError({ component: "relay_minter" }); - const decoded = Buffer.from(value, "base64"); - const canonicalPadded = decoded.toString("base64"); - const canonicalUnpadded = canonicalPadded.replace(/=+$/, ""); - if ( - decoded.byteLength < 32 || - decoded.byteLength > 256 || - (value !== canonicalPadded && value !== canonicalUnpadded) - ) { - throw new IrohConfigurationError({ component: "relay_minter" }); - } - return decoded; -} - -function safeCause(cause: unknown): unknown { - return cause instanceof Error ? { name: cause.name } : { type: typeof cause }; -} diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index 70d768d8738c..a5cd50f4f569 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -358,9 +358,6 @@ function expectedErrorResponse(error: ReturnType & obj { "retry-after": String(quota.retryAfterSeconds) }, ); } - if (tag === "IrohConfigurationError" || tag === "IrohRelayMintError") { - return jsonResponse({ error: "iroh_service_unavailable" }, 503); - } return jsonResponse({ error: "iroh_service_unavailable" }, 503); } diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index aba6e19359f1..6ed15d6868c3 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -42,8 +42,6 @@ import { IROH_ENDPOINT_ATTESTATION_VERSION, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_SCOPE, - IROH_RELAY_TOKEN_LIFETIME_SECONDS, - IROH_RELAY_TOKEN_REFRESH_SECONDS, assertChallengeMatchesPayload, decodeRegistrationPayload, parseBindingIdBody, @@ -52,7 +50,6 @@ import { parseIrohPathHint, parsePairGrantRequest, parseRegisterRequest, - sha256, type IrohPathHint, } from "./model"; import { canIOSBindingUseMac } from "./buildCompatibility"; @@ -67,11 +64,6 @@ import { legacyIrohDiscoveryRequest, parseIrohDiscoveryRequest, } from "./discoveryPagination"; -import { - IrohRelayMinter, - IrohRelayMinterLive, - type IrohRelayMinterShape, -} from "./relayMinter"; import { defaultRelayPreference, type RelayPreference, @@ -154,7 +146,6 @@ export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< export function makeIrohTrustBroker( repository: IrohRepositoryShape, - relayMinter: IrohRelayMinterShape, config: IrohTrustBrokerConfigShape, relayPreferences: Pick = { getPreference: () => Effect.succeed({ @@ -203,54 +194,6 @@ export function makeIrohTrustBroker( return binding; }); - const issueRelayTokenForBinding = ( - userId: string, - binding: IrohBindingRecord, - now: Date, - ): Effect.Effect => Effect.gen(function* () { - const reservation = yield* repository.reserveRelayIssuance({ - userId, - bindingId: binding.id, - clientNamespace: binding.clientNamespace, - now, - }); - const minted = yield* relayMinter.mint({ - endpointId: reservation.binding.endpointId, - lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, - now, - }).pipe( - Effect.matchEffect({ - onFailure: (error) => repository.failRelayIssuance({ - userId, - issuanceId: reservation.issuanceId, - completedAt: new Date(), - failureCode: error._tag === "IrohRelayMintError" ? error.code : "not_configured", - }).pipe( - Effect.catchAll(() => Effect.void), - Effect.flatMap(() => Effect.fail(error)), - ), - onSuccess: Effect.succeed, - }), - ); - const completedAt = new Date(); - const completed = yield* repository.completeRelayIssuance({ - userId, - issuanceId: reservation.issuanceId, - bindingId: reservation.binding.id, - endpointId: reservation.binding.endpointId, - tokenHash: sha256(minted.token), - completedAt, - expiresAt: minted.expiresAt, - }); - if (!completed) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - return { - token: minted.token, - expires_at: minted.expiresAt.toISOString(), - refresh_after: new Date(now.getTime() + IROH_RELAY_TOKEN_REFRESH_SECONDS * 1_000).toISOString(), - relay_fleet: MANAGED_RELAY_URLS, - }; - }); - const discover = ( userId: string, now = new Date(), @@ -470,18 +413,13 @@ export function makeIrohTrustBroker( now, }); - // New registration is already committed before relay minting starts. - // Refreshes keep their existing credential and use the dedicated relay - // route when it expires, so path-hint churn cannot consume mint quotas. + // Registration never mints a relay credential: clients obtain + // endpoint-bound credentials for the self-hosted fleet from + // /api/relay/token after registering. "unavailable" preserves the + // response shape the pre-registry bootstrap produced when the removed + // n0-hosted minter was unconfigured, which production always was. const relay = registration.created - ? yield* issueRelayTokenForBinding( - userId, - registration.binding, - now, - ).pipe( - Effect.map((value) => ({ status: "issued" as const, ...value as object })), - Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), - ) + ? { status: "unavailable" as const } : { status: "not_requested" as const }; const discovery = request.discoveryScope ? (yield* discoverScoped( @@ -704,23 +642,17 @@ export const IrohTrustBrokerLive = Layer.effect( Effect.gen(function* () { return makeIrohTrustBroker( yield* IrohRepository, - yield* IrohRelayMinter, yield* IrohTrustBrokerConfig, yield* RelayRepository, ); }), ); -const IrohRelayMinterWithConfig = IrohRelayMinterLive.pipe( - Layer.provide(IrohTrustBrokerConfigLive), -); - export const IrohTrustBrokerRuntime = IrohTrustBrokerLive.pipe( Layer.provide(Layer.mergeAll( IrohRepositoryLive, RelayRepositoryLive, IrohTrustBrokerConfigLive, - IrohRelayMinterWithConfig, )), ); diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 12e243610d42..dac37eb7fb50 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -21,8 +21,6 @@ const requiredIrohProductionEnv = { CMUX_IROH_GRANT_SIGNING_KEY_P8: `-----BEGIN PRIVATE KEY-----\n${"A".repeat(64)}\n-----END PRIVATE KEY-----`, CMUX_IROH_GRANT_SIGNING_KID: "current", CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: "{}", - CMUX_IROH_MINT_URL: "https://iroh-minter.example.com/api/relay-token", - CMUX_IROH_MINT_HMAC_SECRET_B64: Buffer.alloc(32, 0x33).toString("base64"), }; const requiredRelayProductionEnv = { @@ -174,25 +172,6 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); - test("accepts the self-hosted relay path without the legacy hosted minter", () => { - const result = importEnv({ - ...requiredEnv, - VERCEL: "1", - VERCEL_ENV: "production", - CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", - CMUX_ANALYTICS_RATE_LIMIT_ID: "analytics-rule", - CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, - CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, - CMUX_IROH_GRANT_SIGNING_KEY_P8: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KEY_P8, - CMUX_IROH_GRANT_SIGNING_KID: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KID, - CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: - requiredIrohProductionEnv.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, - ...requiredSubrouterDeploymentEnv, - ...requiredRelayProductionEnv, - }); - - expect(result.exitCode).toBe(0); - }); test("allows explicit Vercel production without the optional Iroh limiter id", () => { const result = importEnv({ @@ -223,7 +202,6 @@ describe("client config env validation", () => { expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); - expect(result.stderr).not.toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); }); test("requires the self-hosted relay signing and rate-limit configuration in production", () => { @@ -254,59 +232,6 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); - - test("allows an explicitly opted-in loopback HTTP relay minter only in local development", () => { - const result = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "development", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("http://localhost:49152/api/relay-token"); - }); - - test("rejects a plaintext non-loopback relay minter in local development", () => { - const result = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "development", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://192.168.1.10:49152/api/relay-token", - }); - - expect(result.exitCode).not.toBe(0); - }); - - test("rejects the insecure loopback opt-in in Vercel preview and production", () => { - const preview = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "production", - VERCEL: "1", - VERCEL_ENV: "preview", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - expect(preview.exitCode).not.toBe(0); - - const production = inspectIrohMinterUrl({ - ...requiredEnv, - ...requiredIrohProductionEnv, - NODE_ENV: "production", - VERCEL: "1", - VERCEL_ENV: "production", - CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", - CMUX_ANALYTICS_RATE_LIMIT_ID: "analytics-rule", - ...requiredSubrouterDeploymentEnv, - ...requiredRelayProductionEnv, - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - expect(production.exitCode).not.toBe(0); - expect(production.stderr).toContain( - "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER is only allowed in local development", - ); - }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { @@ -323,35 +248,3 @@ function importEnv(env: Record): { exitCode: number; stderr: str stderr: result.stderr, }; } - -function inspectIrohMinterUrl( - env: Record, -): { exitCode: number; stdout: string; stderr: string } { - const result = spawnSync( - process.execPath, - [ - "--no-env-file", - "-e", - ` - const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config'); - const { parseMinterUrl } = await import('./services/iroh/relayMinter'); - const config = irohTrustBrokerConfigFromEnv(); - const url = parseMinterUrl(config.relayMinterUrl, { - allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, - deploymentEnvironment: config.deploymentEnvironment, - isVercelDeployment: config.isVercelDeployment, - }); - console.log(url.href); - `, - ], - { - env: env as NodeJS.ProcessEnv, - encoding: "utf8", - }, - ); - return { - exitCode: result.status ?? 1, - stdout: result.stdout.trim(), - stderr: result.stderr, - }; -} diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index fc1b70d9114b..351a5266f710 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -35,13 +35,6 @@ import { MANAGED_RELAY_URLS, parseRegistrationPayload, } from "../services/iroh/model"; -import { - parseMinterHmacSecret, - parseMinterUrl, - readBoundedMinterJson, - IrohRelayMinter, - IrohRelayMinterLive, -} from "../services/iroh/relayMinter"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -637,188 +630,6 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => }); }); -describe("Iroh relay minter response bounds", () => { - test("the production Live layer sends the canonical fetch body and HMAC", async () => { - const secret = Buffer.alloc(32, 0x63); - const originalFetch = globalThis.fetch; - let captured: { url: string; init: RequestInit } | undefined; - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - captured = { url: String(input), init: init ?? {} }; - return new Response(JSON.stringify({ - token: "a".repeat(64), - expiresAt: "2026-07-10T20:00:00.000Z", - }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; - try { - const config: IrohTrustBrokerConfigShape = { - relayMinterUrl: "https://minter.cmux.test/api/relay-token", - relayMinterHmacSecretBase64: secret.toString("base64"), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, - }; - const layer = IrohRelayMinterLive.pipe( - Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), - ); - const result = await Effect.runPromise( - Effect.gen(function* () { - const minter = yield* IrohRelayMinter; - return yield* minter.mint({ - endpointId: "ab".repeat(32), - lifetimeSeconds: 86_400, - now: NOW, - }); - }).pipe(Effect.provide(layer)), - ); - expect(result.token).toBe("a".repeat(64)); - } finally { - globalThis.fetch = originalFetch; - } - - const request = captured; - expect(request?.url).toBe("https://minter.cmux.test/api/relay-token"); - expect(request?.init.method).toBe("POST"); - expect(request?.init.redirect).toBe("error"); - const body = JSON.stringify({ endpointId: "ab".repeat(32), lifetimeSeconds: 86_400 }); - expect(request?.init.body).toBe(body); - const timestamp = String(Math.floor(NOW.getTime() / 1_000)); - const expectedSignature = createHmac("sha256", secret) - .update(`POST\n/api/relay-token\n${timestamp}\n${createHash("sha256").update(body).digest("hex")}`) - .digest("base64url"); - expect(new Headers(request?.init.headers).get("x-cmux-iroh-timestamp")).toBe(timestamp); - expect(new Headers(request?.init.headers).get("x-cmux-iroh-signature")).toBe(expectedSignature); - expect(new Headers(request?.init.headers).get("content-type")).toBe("application/json"); - }); - - test("preserves an invalid EndpointID as an input error", async () => { - const config: IrohTrustBrokerConfigShape = { - relayMinterUrl: "https://minter.cmux.test/api/relay-token", - relayMinterHmacSecretBase64: Buffer.alloc(32, 0x63).toString("base64"), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, - }; - const layer = IrohRelayMinterLive.pipe( - Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), - ); - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const minter = yield* IrohRelayMinter; - return yield* minter.mint({ - endpointId: "not-an-endpoint-id", - lifetimeSeconds: 86_400, - now: NOW, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(exit._tag).toBe("Failure"); - const failure = exit._tag === "Failure" - ? Option.getOrUndefined(Cause.failureOption(exit.cause)) - : undefined; - expect((failure as { _tag?: string } | undefined)?._tag).toBe("IrohInvalidInputError"); - }); - - test("matches the Rust minter HMAC wire fixture", () => { - const fixture = JSON.parse(readFileSync( - new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), - "utf8", - )) as { path: string; timestamp: string; body: string; signature: string }; - const bodyHash = createHash("sha256").update(fixture.body).digest("hex"); - const signature = createHmac("sha256", Buffer.alloc(32, 0x42)) - .update(`POST\n${fixture.path}\n${fixture.timestamp}\n${bodyHash}`, "utf8") - .digest("base64url"); - expect(fixture.path).toBe("/api/relay-token"); - expect(signature).toBe(fixture.signature); - }); - - test("requires a canonical 32-byte-or-longer HMAC secret", () => { - const valid = Buffer.alloc(32, 9).toString("base64"); - expect(parseMinterHmacSecret(valid)).toEqual(Buffer.alloc(32, 9)); - expect(() => parseMinterHmacSecret("%%%%" + valid)).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(16, 9).toString("base64"))).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(257, 9).toString("base64"))).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(32, 0xff).toString("base64url"))).toThrow(); - }); - - test("allows plaintext only for opted-in local loopback minters", () => { - const localDevelopment = { - allowInsecureLoopback: true, - deploymentEnvironment: "development", - isVercelDeployment: false, - }; - expect(parseMinterUrl("https://minter.cmux.test/api/relay-token").pathname).toBe("/api/relay-token"); - for (const value of [ - "http://localhost:49152/api/relay-token", - "http://127.0.0.1:49152/api/relay-token", - "http://[::1]:49152/api/relay-token", - ]) { - expect(parseMinterUrl(value, localDevelopment).protocol).toBe("http:"); - } - for (const value of [ - "http://minter.cmux.test/api/relay-token", - "http://192.168.1.10:49152/api/relay-token", - "https://minter.cmux.test/api/relay-token/", - "https://minter.cmux.test/other", - "https://minter.cmux.test/api/relay-token?debug=1", - ]) { - expect(() => parseMinterUrl(value, localDevelopment)).toThrow(); - } - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - allowInsecureLoopback: false, - })).toThrow(); - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - deploymentEnvironment: "production", - })).toThrow(); - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - deploymentEnvironment: "preview", - isVercelDeployment: true, - })).toThrow(); - }); - - test("parses a bounded response", async () => { - const body = { token: "a".repeat(32), expiresAt: "2026-07-10T20:00:00.000Z" }; - expect(await readBoundedMinterJson(new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - }))).toEqual(body); - }); - - test("rejects a non-JSON or expanded minter response contract", async () => { - await expect(readBoundedMinterJson(new Response("{}"))).rejects.toThrow(); - await expect(readBoundedMinterJson(new Response(JSON.stringify({ - token: "a".repeat(32), - expiresAt: "2026-07-10T20:00:00.000Z", - servicesSecret: "must-not-appear", - }), { - headers: { "content-type": "application/json" }, - }))).rejects.toThrow(); - }); - - test("rejects oversized fixed-length and chunked responses", async () => { - await expect(readBoundedMinterJson(new Response("{}", { - headers: { "content-length": "999999", "content-type": "application/json" }, - }))).rejects.toThrow(); - - const chunk = new Uint8Array(20_000); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(chunk); - controller.enqueue(chunk); - controller.close(); - }, - }); - await expect(readBoundedMinterJson(new Response(stream, { - headers: { "content-type": "application/json" }, - }))).rejects.toThrow(); - }); -}); - function manuallySignedJws(header: unknown, claims: unknown, privateKey: CryptoKey | import("node:crypto").KeyObject): string { const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url"); diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 34f31f791a37..daa8b59897e5 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -15,10 +15,8 @@ import { IrohConflictError, IrohForbiddenError, IrohNotFoundError, - IrohRelayMintError, } from "../services/iroh/errors"; import { - IROH_RELAY_TOKEN_LIFETIME_SECONDS, MANAGED_RELAY_URLS, sha256, type IrohRegistrationPayload, @@ -33,7 +31,6 @@ import { type IrohChallengeRecord, type IrohRepositoryShape, } from "../services/iroh/repository"; -import type { IrohRelayMinterShape } from "../services/iroh/relayMinter"; import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; import { bindingMatchesDiscoveryScope } from "../services/iroh/discoveryScope"; import type { RelayPreference } from "../services/relay/model"; @@ -206,13 +203,13 @@ describe("Iroh build compatibility", () => { }); describe("Iroh trust broker registration", () => { - test("registers a valid endpoint proof and mints relay credentials after commit", async () => { + test("registers a valid endpoint proof and reports the relay credential unavailable", async () => { const fixture = makeFixture(); const request = await fixture.signedRegistration(); const result = await Effect.runPromise(fixture.broker.register(USER_A, request, NOW)) as { revision: number; binding: { endpoint_id: string }; - relay: { status: string; token: string }; + relay: { status: string }; discovery_complete: boolean; discovery: { revision: number; @@ -220,7 +217,7 @@ describe("Iroh trust broker registration", () => { }; }; expect(result.binding.endpoint_id).toBe(fixture.endpointId); - expect(result.relay.status).toBe("issued"); + expect(result.relay.status).toBe("unavailable"); expect(result.discovery.revision).toBe(result.revision); expect(result.discovery_complete).toBe(true); expect(result.discovery.bindings.map((binding) => binding.binding_id)) @@ -234,7 +231,6 @@ describe("Iroh trust broker registration", () => { observed_at: "2026-07-09T19:55:00.000Z", expires_at: "2026-07-09T20:45:00.000Z", }]); - expect(fixture.minter.calls).toBe(1); }); test("persists and publishes signed family-specific direct ports to the same account", async () => { @@ -345,16 +341,7 @@ describe("Iroh trust broker registration", () => { ]); }); - test("relay failure cannot roll back an authenticated registration", async () => { - const fixture = makeFixture({ minterFailure: true }); - const result = await Effect.runPromise( - fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW), - ) as { relay: { status: string } }; - expect(result.relay.status).toBe("unavailable"); - expect(fixture.repository.bindings).toHaveLength(1); - }); - - test("does not mint another relay token when refreshing the same binding", async () => { + test("reports not_requested when refreshing the same binding", async () => { const fixture = makeFixture(); await Effect.runPromise(fixture.broker.register( USER_A, @@ -369,7 +356,6 @@ describe("Iroh trust broker registration", () => { )) as { relay: { status: string } }; expect(refreshed.relay.status).toBe("not_requested"); - expect(fixture.minter.calls).toBe(1); }); test("marks a truncated registration discovery page incomplete", async () => { @@ -1722,7 +1708,6 @@ describe("Iroh discovery and grants", () => { ), "IrohNotFoundError"); expect(internal.revokedAt).toBeNull(); - expect(fixture.minter.calls).toBe(0); expect(fixture.repository.pairGrantAudits).toHaveLength(0); }); @@ -1823,13 +1808,13 @@ describe("Iroh discovery and grants", () => { const fixture = makeFixture(); const active = binding({ userId: USER_A, platform: "ios" }); fixture.repository.bindings.push(active); - const noVerificationKeys = makeIrohTrustBroker(fixture.repository, fixture.minter, { + const noVerificationKeys = makeIrohTrustBroker(fixture.repository, { ...fixture.config, grantVerificationKeysJson: undefined, }); await expectEffectFailure(noVerificationKeys.discover(USER_A, NOW), "IrohConfigurationError"); - const noAccountSubject = makeIrohTrustBroker(fixture.repository, fixture.minter, { + const noAccountSubject = makeIrohTrustBroker(fixture.repository, { ...fixture.config, accountSubjectSecretBase64: undefined, }); @@ -2298,26 +2283,8 @@ class MemoryRepository implements IrohRepositoryShape { } } -class FakeMinter implements IrohRelayMinterShape { - calls = 0; - afterMint: (() => void) | undefined; - constructor(private readonly fail: boolean) {} - - mint(input: Parameters[0]) { - this.calls += 1; - if (this.fail) return Effect.fail(new IrohRelayMintError({ code: "test_failure" })); - const result = { - token: `relay-token-${this.calls}-with-safe-length`, - expiresAt: new Date(input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000), - }; - this.afterMint?.(); - return Effect.succeed(result); - } -} - function makeFixture(options: { repository?: MemoryRepository; - minterFailure?: boolean; appInstanceId?: string; deviceId?: string; identityGeneration?: number; @@ -2336,7 +2303,6 @@ function makeFixture(options: { const endpointPublicDer = endpointKeys.publicKey.export({ format: "der", type: "spki" }); const endpointId = Buffer.from(endpointPublicDer).subarray(-32).toString("hex"); const repository = options.repository ?? new MemoryRepository(); - const minter = new FakeMinter(options.minterFailure ?? false); const appInstanceId = options.appInstanceId ?? randomUUID(); const deviceId = options.deviceId ?? randomUUID(); const identityGeneration = options.identityGeneration ?? 1; @@ -2361,22 +2327,18 @@ function makeFixture(options: { }, ], }), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, }; let relayPreference = options.relayPreference ?? { mode: "automatic" as const, selectedManagedRelayIds: [], customRelays: [], }; - const broker = makeIrohTrustBroker(repository, minter, config, { + const broker = makeIrohTrustBroker(repository, config, { getPreference: () => Effect.succeed({ preference: relayPreference, revision: 0 }), }); return { repository, - minter, broker, config, endpointId, From 226af47e95056e61c3e12ca9559c9d117e3c3c7d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:44:31 -0700 Subject: [PATCH 07/71] Delete orphaned relay-issuance plumbing and the producer-less quota error With the legacy relay-token route and the n0 minter gone, nothing calls IrohRepository.reserveRelayIssuance/completeRelayIssuance/failRelayIssuance (grep: definitions and their direct tests only), and the model constants IROH_RELAY_TOKEN_LIFETIME_SECONDS/IROH_RELAY_TOKEN_REFRESH_SECONDS have zero remaining users. IrohQuotaExceededError has had no producer since #9269 removed the broker quotas (grep for 'new IrohQuotaExceededError' hits nothing); its 429 mappings in the iroh and connectivity route handlers were unreachable. The iroh_relay_token_issuances table, its migrations, and the retention cleanup that drains historical rows all stay: production still holds rows. --- web/services/connectivity/routeHandler.ts | 12 -- web/services/iroh/errors.ts | 7 - web/services/iroh/model.ts | 2 - web/services/iroh/repository.ts | 163 +--------------------- web/services/iroh/routeHandler.ts | 8 -- web/tests/iroh-db-behavior.test.ts | 132 ------------------ web/tests/iroh-trust-broker.test.ts | 42 ------ 7 files changed, 3 insertions(+), 363 deletions(-) diff --git a/web/services/connectivity/routeHandler.ts b/web/services/connectivity/routeHandler.ts index d657c15743dc..a62bd68680e7 100644 --- a/web/services/connectivity/routeHandler.ts +++ b/web/services/connectivity/routeHandler.ts @@ -148,18 +148,6 @@ function connectivityExpectedErrorResponse( return connectivityJsonResponse({ error: `${error.resource}_not_found` }, 404); case "IrohConflictError": return connectivityJsonResponse({ error: error.code }, 409); - case "IrohQuotaExceededError": - return new Response(JSON.stringify({ - error: error.code, - retry_after_seconds: error.retryAfterSeconds, - }), { - status: 429, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - "retry-after": String(error.retryAfterSeconds), - }, - }); case "IrohConfigurationError": case "IrohDatabaseError": return connectivityJsonResponse({ error: "connectivity_service_unavailable" }, 503); diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts index 9dec0efd98be..16c727160460 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -17,11 +17,6 @@ export class IrohConflictError extends Data.TaggedError("IrohConflictError")<{ readonly code: string; }> {} -export class IrohQuotaExceededError extends Data.TaggedError("IrohQuotaExceededError")<{ - readonly code: string; - readonly retryAfterSeconds: number; -}> {} - export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationError")<{ readonly component: | "grant_signing" @@ -40,7 +35,6 @@ export type IrohExpectedError = | IrohNotFoundError | IrohForbiddenError | IrohConflictError - | IrohQuotaExceededError | IrohConfigurationError | IrohDatabaseError; @@ -78,7 +72,6 @@ const IROH_ERROR_TAGS = new Set([ "IrohNotFoundError", "IrohForbiddenError", "IrohConflictError", - "IrohQuotaExceededError", "IrohConfigurationError", "IrohDatabaseError", ]); diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 7e474031775e..146edb8caf4e 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -18,8 +18,6 @@ export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; export const IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS = 5 * 60; export const IROH_OFFLINE_PAIR_SESSION_VERSION = 1; -export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; -export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; export const IROH_ROUTE_CONTRACT_VERSION = 1; export const POSTGRES_INT32_MAX = 2_147_483_647; diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 4ad49a8ba9c6..873af7845a76 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -19,14 +19,12 @@ import { IrohDatabaseError, IrohForbiddenError, IrohNotFoundError, - IrohQuotaExceededError, } from "./errors"; import type { PairGrantPeer } from "./crypto"; import type { IrohDiscoveryCursor } from "./discoveryPagination"; import { nextPathHintExpiry, parseIrohPathHint, - sha256, type IrohPathHint, type IrohRegistrationPayload, } from "./model"; @@ -40,7 +38,6 @@ import type { IrohDiscoveryScope } from "./discoveryScope"; export const IROH_RETENTION_BATCH_SIZE = 500; export const IROH_RETENTION_MAX_ROWS = 10_000; export const IROH_RETENTION_MAX_DURATION_MS = 8_000; -export const IROH_RELAY_RESERVATION_LEASE_MS = 60 * 1_000; export type IrohRetentionCategory = | "revokedHints" @@ -76,8 +73,7 @@ type RepositoryError = | IrohDatabaseError | IrohForbiddenError | IrohNotFoundError - | IrohConflictError - | IrohQuotaExceededError; + | IrohConflictError; export type IrohRepositoryShape = { readonly issueChallenge: (input: { @@ -181,30 +177,6 @@ export type IrohRepositoryShape = { readonly notBefore: Date; readonly expiresAt: Date; }) => Effect.Effect; - readonly reserveRelayIssuance: (input: { - readonly userId: string; - readonly bindingId: string; - readonly clientNamespace?: string; - readonly now: Date; - }) => Effect.Effect<{ - readonly issuanceId: string; - readonly binding: IrohBindingRecord; - }, RepositoryError>; - readonly completeRelayIssuance: (input: { - readonly userId: string; - readonly issuanceId: string; - readonly bindingId: string; - readonly endpointId: string; - readonly tokenHash: string; - readonly completedAt: Date; - readonly expiresAt: Date; - }) => Effect.Effect; - readonly failRelayIssuance: (input: { - readonly userId: string; - readonly issuanceId: string; - readonly completedAt: Date; - readonly failureCode: string; - }) => Effect.Effect; }; export class IrohRepository extends Context.Tag("cmux/IrohRepository")< @@ -1145,134 +1117,6 @@ function makeLiveRepository(): IrohRepositoryShape { }); }), - reserveRelayIssuance: (input) => repositoryEffect("reserve_relay_issuance", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:relay:${input.userId}`}, 0))`); - const [binding] = await tx - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.id, input.bindingId), - eq(irohEndpointBindings.userId, input.userId), - isNull(irohEndpointBindings.revokedAt), - )) - .for("update") - .limit(1); - if (!binding) throw new IrohNotFoundError({ resource: "binding" }); - if (binding.clientNamespace !== (input.clientNamespace ?? "legacy")) { - throw new IrohNotFoundError({ resource: "binding" }); - } - - await tx - .update(irohEndpointBindings) - .set({ lastSeenAt: input.now, updatedAt: input.now }) - .where(eq(irohEndpointBindings.id, binding.id)); - - const reservationCutoff = new Date( - input.now.getTime() - IROH_RELAY_RESERVATION_LEASE_MS, - ); - await tx - .update(irohRelayTokenIssuances) - .set({ - status: "expired", - completedAt: input.now, - failureCode: "reservation_expired", - }) - .where(and( - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.status, "pending"), - lte(irohRelayTokenIssuances.requestedAt, reservationCutoff), - )); - - const [issuance] = await tx - .insert(irohRelayTokenIssuances) - .values({ - userId: input.userId, - bindingId: binding.id, - endpointIdHash: sha256(binding.endpointId), - status: "pending", - requestedAt: input.now, - }) - .returning({ id: irohRelayTokenIssuances.id }); - if (!issuance) throw new Error("relay issuance insert returned no row"); - return { issuanceId: issuance.id, binding }; - }); - }), - - completeRelayIssuance: (input) => repositoryEffect("complete_relay_issuance", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - const [issuance] = await tx - .select() - .from(irohRelayTokenIssuances) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.bindingId, input.bindingId), - eq(irohRelayTokenIssuances.status, "pending"), - )) - .for("update") - .limit(1); - if (!issuance) return false; - const [binding] = await tx - .select({ endpointId: irohEndpointBindings.endpointId }) - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.id, input.bindingId), - eq(irohEndpointBindings.userId, input.userId), - isNull(irohEndpointBindings.revokedAt), - )) - .for("update") - .limit(1); - if ( - !binding || - binding.endpointId !== input.endpointId || - issuance.endpointIdHash !== sha256(input.endpointId) - ) { - await tx - .update(irohRelayTokenIssuances) - .set({ - status: "failed", - completedAt: input.completedAt, - failureCode: "binding_inactive_after_mint", - }) - .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); - return false; - } - const completed = await tx - .update(irohRelayTokenIssuances) - .set({ - status: "succeeded", - tokenHash: input.tokenHash, - completedAt: input.completedAt, - expiresAt: input.expiresAt, - failureCode: null, - }) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.status, "pending"), - )) - .returning({ id: irohRelayTokenIssuances.id }); - return completed.length === 1; - }); - }), - - failRelayIssuance: (input) => repositoryEffect("fail_relay_issuance", async () => { - await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx - .update(irohRelayTokenIssuances) - .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.status, "pending"), - )); - }); - }), }; } @@ -1735,11 +1579,10 @@ function repositoryEffect( function isDomainError(error: unknown): error is | IrohForbiddenError | IrohNotFoundError - | IrohConflictError - | IrohQuotaExceededError { + | IrohConflictError { const tag = (error as { _tag?: unknown } | null)?._tag; return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || - tag === "IrohConflictError" || tag === "IrohQuotaExceededError"; + tag === "IrohConflictError"; } function sanitizedDatabaseCause(cause: unknown): unknown { diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index a5cd50f4f569..d540a090c8dc 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -350,14 +350,6 @@ function expectedErrorResponse(error: ReturnType & obj if (tag === "IrohConflictError") { return jsonResponse({ error: (error as { code: string }).code }, 409); } - if (tag === "IrohQuotaExceededError") { - const quota = error as { code: string; retryAfterSeconds: number }; - return irohJsonResponse( - { error: quota.code, retry_after_seconds: quota.retryAfterSeconds }, - 429, - { "retry-after": String(quota.retryAfterSeconds) }, - ); - } return jsonResponse({ error: "iroh_service_unavailable" }, 503); } diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 3dc55195279b..0d7d721d58e8 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -284,13 +284,6 @@ describe("Iroh trust broker database behavior", () => { }); const ios = await pairPeer(iosId); const mac = await pairPeer(macId); - const [issuance] = await requiredSql()>` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) values (${userId}, ${macId}, ${"0f".repeat(32)}, 'pending', ${NOW}) - returning id::text - `; - if (!issuance) throw new Error("issuance insert failed"); await requiredSql()` insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) @@ -322,22 +315,6 @@ describe("Iroh trust broker database behavior", () => { notBefore: NOW, expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), }), - repository.reserveRelayIssuance({ userId, bindingId: macId, now: NOW }), - repository.completeRelayIssuance({ - userId, - issuanceId: issuance.id, - bindingId: macId, - endpointId: mac.endpointId, - tokenHash: "10".repeat(32), - completedAt: NOW, - expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), - }), - repository.failRelayIssuance({ - userId, - issuanceId: issuance.id, - completedAt: NOW, - failureCode: "test_failure", - }), ]; for (const operation of operations) { const exit = await Effect.runPromiseExit(operation); @@ -347,19 +324,16 @@ describe("Iroh trust broker database behavior", () => { const [state] = await requiredSql()>` select exists(select 1 from iroh_endpoint_bindings where id = ${macId} and revoked_at is not null) as revoked, (select count(*)::text from iroh_pair_grant_issuances where user_id = ${userId}) as grants, - (select status from iroh_relay_token_issuances where id = ${issuance.id}) as "issuanceStatus", (select count(*)::text from iroh_account_security_states where user_id = ${userId}) as "securityStates" `; expect(state).toEqual({ revoked: false, grants: "0", - issuanceStatus: "pending", securityStates: "0", }); }); @@ -1878,112 +1852,6 @@ describe("Iroh trust broker database behavior", () => { expect(String(exit)).toContain("IrohNotFoundError"); }); - dbTest("expires abandoned relay reservations before enforcing endpoint and account quotas", async () => { - const repo = requiredRepository(); - const endpointUserId = "user-relay-abandoned-endpoint"; - const endpointBindingId = await insertBinding({ - userId: endpointUserId, - endpointId: "63".repeat(32), - }); - for (let index = 0; index < 3; index += 1) { - await requiredSql()` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) values ( - ${endpointUserId}, ${endpointBindingId}, ${"64".repeat(32)}, 'pending', - ${new Date(NOW.getTime() - 5 * 60 * 1_000 - index * 1_000)} - ) - `; - } - - await Effect.runPromise(repo.reserveRelayIssuance({ - userId: endpointUserId, - bindingId: endpointBindingId, - now: NOW, - })); - const endpointStatuses = await requiredSql()>` - select status, count(*)::text as total - from iroh_relay_token_issuances - where user_id = ${endpointUserId} - group by status - order by status - `; - expect(endpointStatuses).toEqual([ - { status: "expired", total: "3" }, - { status: "pending", total: "1" }, - ]); - - const accountUserId = "user-relay-abandoned-account"; - const accountBindingIds: string[] = []; - for (let index = 0; index < 10; index += 1) { - const bindingId = await insertBinding({ - userId: accountUserId, - endpointId: (0xa0 + index).toString(16).repeat(32), - }); - accountBindingIds.push(bindingId); - await requiredSql()` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) - select - ${accountUserId}, ${bindingId}, ${"65".repeat(32)}, 'pending', - ${new Date(NOW.getTime() - 15 * 60 * 1_000)} - make_interval(secs => value) - from generate_series(1, 10) as values(value) - `; - } - - await Effect.runPromise(repo.reserveRelayIssuance({ - userId: accountUserId, - bindingId: accountBindingIds[0]!, - now: NOW, - })); - const accountStatuses = await requiredSql()>` - select status, count(*)::text as total - from iroh_relay_token_issuances - where user_id = ${accountUserId} - group by status - order by status - `; - expect(accountStatuses).toEqual([ - { status: "expired", total: "100" }, - { status: "pending", total: "1" }, - ]); - }); - - dbTest("fails relay finalization when revocation commits during provider mint", async () => { - const repo = requiredRepository(); - const endpointId = "61".repeat(32); - const bindingId = await insertBinding({ userId: "user-relay-race", endpointId }); - const reservation = await Effect.runPromise(repo.reserveRelayIssuance({ - userId: "user-relay-race", - bindingId, - now: NOW, - })); - expect(await Effect.runPromise(repo.revokeBinding({ - userId: "user-relay-race", - bindingId, - now: new Date(NOW.getTime() + 1_000), - }))).toEqual({ revoked: true, accountRevision: 1 }); - expect(await Effect.runPromise(repo.completeRelayIssuance({ - userId: "user-relay-race", - issuanceId: reservation.issuanceId, - bindingId, - endpointId, - tokenHash: "62".repeat(32), - completedAt: new Date(NOW.getTime() + 2_000), - expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), - }))).toBe(false); - const [issuance] = await requiredSql()>` - select status, failure_code as "failureCode" - from iroh_relay_token_issuances - where id = ${reservation.issuanceId} - `; - expect(issuance).toEqual({ - status: "failed", - failureCode: "binding_inactive_after_mint", - }); - }); - dbTest("global retention clears revoked hints and expired private data from Aurora", async () => { const repo = requiredRepository(); const activeId = await insertBinding({ diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index daa8b59897e5..d2975282b051 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -1834,13 +1834,6 @@ class MemoryRepository implements IrohRepositoryShape { readonly challenges: IrohChallengeRecord[] = []; readonly bindings: MutableBinding[] = []; readonly pairGrantAudits: unknown[] = []; - readonly relayIssuances: Array<{ - id: string; - userId: string; - bindingId: string; - requestedAt: Date; - status: string; - }> = []; private lanGenerations = new Map(); private routeRevisions = new Map(); beforeDiscoverySnapshot: (() => Promise) | undefined; @@ -2246,41 +2239,6 @@ class MemoryRepository implements IrohRepositoryShape { } return Effect.void; } - - reserveRelayIssuance(input: Parameters[0]) { - const active = this.bindings.find((row) => - row.id === input.bindingId && row.userId === input.userId && !row.revokedAt); - if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); - if (active.clientNamespace !== (input.clientNamespace ?? "legacy")) { - return Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - active.lastSeenAt = input.now; - active.updatedAt = input.now; - const issuanceId = randomUUID(); - this.relayIssuances.push({ id: issuanceId, userId: input.userId, bindingId: active.id, requestedAt: input.now, status: "pending" }); - return Effect.succeed({ issuanceId, binding: active }); - } - - completeRelayIssuance(input: Parameters[0]) { - const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); - const active = this.bindings.find((candidate) => - candidate.id === input.bindingId && - candidate.userId === input.userId && - candidate.endpointId === input.endpointId && - !candidate.revokedAt); - if (!row || !active) { - if (row) row.status = "failed"; - return Effect.succeed(false); - } - row.status = "succeeded"; - return Effect.succeed(true); - } - - failRelayIssuance(input: Parameters[0]) { - const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); - if (row) row.status = "failed"; - return Effect.void; - } } function makeFixture(options: { From 2b5b6c46dbd2094339426adf08222672a65c5b35 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:48:21 -0700 Subject: [PATCH 08/71] Delete never-wired offline-pair server subgraph and other unreferenced iroh exports createOfflinePairSessionRecord / verifyAndConsumeOfflineSameAccountPair and their private helpers and types were added in #7908 but no route, broker method, or repository call ever reached them; repo-wide grep hits only crypto.ts and their unit test. The Swift offline-pairing feature verifies attestations peer-to-peer and never calls a server session endpoint. Also removes the constants that existed only for that subgraph (IROH_OFFLINE_PAIR_SESSION_*), serverPublishedIrohPathHints (zero references, not even tests), IROH_SIGNED_PATH_HINT_UPDATE_FOLLOWUP (its only occurrence is its definition; the literal string appears nowhere else, including Swift), and bindingMatchesDiscoveryScope from production (used only by the trust-broker test's in-memory repository, where it now lives as a local fixture helper). --- web/services/iroh/crypto.ts | 190 ------------------------- web/services/iroh/discoveryScope.ts | 31 ---- web/services/iroh/model.ts | 2 - web/services/iroh/publicationPolicy.ts | 8 -- web/services/iroh/trustBroker.ts | 1 - web/tests/iroh-model-crypto.test.ts | 107 -------------- web/tests/iroh-trust-broker.test.ts | 35 ++++- 7 files changed, 34 insertions(+), 340 deletions(-) diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index 3b818ce76592..7ebbd427ad8c 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -12,8 +12,6 @@ import { IROH_ENDPOINT_ATTESTATION_SCOPE, IROH_ENDPOINT_ATTESTATION_TYP, IROH_ENDPOINT_ATTESTATION_VERSION, - IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS, - IROH_OFFLINE_PAIR_SESSION_VERSION, IROH_ALPN, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_GRANT_TYP, @@ -94,32 +92,6 @@ export type EndpointAttestationExpectation = { readonly nowSeconds: number; }; -export type OfflinePairVerificationExpectation = { - readonly initiator: Omit & { - readonly platform: "ios"; - }; - readonly acceptor: Omit & { - readonly platform: "mac"; - }; - readonly nowSeconds: number; -}; - -export type OfflinePairSessionRecord = { - readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; - readonly sessionId: string; - readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; - readonly proofHash: string; - readonly createdAtSeconds: number; - readonly expiresAtSeconds: number; - consumedAtSeconds: number | null; -}; - -export type OfflinePairInvitationProof = { - readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; - readonly sessionId: string; - readonly proof: string; -}; - export function registrationTranscript(input: { readonly challengeId: string; readonly nonce: string; @@ -350,109 +322,6 @@ export function verifyEndpointAttestation( return claims; } -function verifyOfflineSameAccountPair(input: { - readonly initiatorAttestation: string; - readonly acceptorAttestation: string; - readonly publicKeys: ReadonlyMap; - readonly expected: OfflinePairVerificationExpectation; -}): { - readonly initiator: EndpointAttestationClaims; - readonly acceptor: EndpointAttestationClaims; -} { - if ( - input.expected.initiator.platform !== "ios" || - input.expected.acceptor.platform !== "mac" - ) { - throw new IrohForbiddenError({ code: "invalid_offline_pair_platforms" }); - } - const initiator = verifyEndpointAttestation(input.initiatorAttestation, input.publicKeys, { - ...input.expected.initiator, - nowSeconds: input.expected.nowSeconds, - }); - const acceptor = verifyEndpointAttestation(input.acceptorAttestation, input.publicKeys, { - ...input.expected.acceptor, - nowSeconds: input.expected.nowSeconds, - }); - if ( - initiator.bindingId === acceptor.bindingId || - initiator.deviceId === acceptor.deviceId || - initiator.endpointId === acceptor.endpointId || - !canonicalSubjectsEqual(initiator.sub, acceptor.sub) - ) { - throw new IrohForbiddenError({ code: "offline_pair_same_account_proof_required" }); - } - return { initiator, acceptor }; -} - -export function createOfflinePairSessionRecord(input: { - readonly sessionId: string; - readonly proof: string; - readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; - readonly nowSeconds: number; - readonly expiresAtSeconds: number; -}): OfflinePairSessionRecord { - validateOfflinePairSessionWindow(input.nowSeconds, input.expiresAtSeconds); - validateEndpointExpectation(input.acceptor, "mac"); - if (!UUID_PATTERN.test(input.sessionId) || input.sessionId !== input.sessionId.toLowerCase()) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - const proof = decodeCanonicalBase64url(input.proof, 32, "invalid_offline_pair_proof"); - return { - version: IROH_OFFLINE_PAIR_SESSION_VERSION, - sessionId: input.sessionId.toLowerCase(), - acceptor: { ...input.acceptor }, - proofHash: offlinePairProofHash(input.sessionId.toLowerCase(), input.acceptor, proof), - createdAtSeconds: input.nowSeconds, - expiresAtSeconds: input.expiresAtSeconds, - consumedAtSeconds: null, - }; -} - -export function verifyAndConsumeOfflineSameAccountPair(input: { - readonly initiatorAttestation: string; - readonly acceptorAttestation: string; - readonly publicKeys: ReadonlyMap; - readonly expected: OfflinePairVerificationExpectation; - readonly session: OfflinePairSessionRecord; - readonly invitation: OfflinePairInvitationProof; -}): { - readonly initiator: EndpointAttestationClaims; - readonly acceptor: EndpointAttestationClaims; - readonly sessionId: string; -} { - const { session, invitation } = input; - if ( - typeof invitation.sessionId !== "string" || - !UUID_PATTERN.test(invitation.sessionId) || - invitation.sessionId !== invitation.sessionId.toLowerCase() - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - if ( - session.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || - invitation.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || - session.consumedAtSeconds !== null || - session.sessionId !== invitation.sessionId || - !sameEndpointExpectation(session.acceptor, input.expected.acceptor) || - session.createdAtSeconds > input.expected.nowSeconds + 30 || - session.expiresAtSeconds <= input.expected.nowSeconds - ) { - throw new IrohForbiddenError({ code: "offline_pair_session_unavailable" }); - } - validateOfflinePairSessionWindow( - session.createdAtSeconds, - session.expiresAtSeconds, - ); - const proof = decodeCanonicalBase64url(invitation.proof, 32, "invalid_offline_pair_proof"); - const actualHash = offlinePairProofHash(session.sessionId, session.acceptor, proof); - if (!hashesEqual(session.proofHash, actualHash)) { - throw new IrohForbiddenError({ code: "invalid_offline_pair_proof" }); - } - const verified = verifyOfflineSameAccountPair(input); - session.consumedAtSeconds = input.expected.nowSeconds; - return { ...verified, sessionId: session.sessionId }; -} - export function parseVerificationKeys( value: string | undefined, ): ParsedPairGrantVerificationKeys { @@ -827,65 +696,6 @@ function hasExactKeys(value: Record, allowed: readonly string[] return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); } -function canonicalSubjectsEqual(left: string, right: string): boolean { - const leftBytes = decodeCanonicalBase64url(left, 32, "invalid_endpoint_attestation"); - const rightBytes = decodeCanonicalBase64url(right, 32, "invalid_endpoint_attestation"); - return timingSafeEqual(leftBytes, rightBytes); -} - -function validateOfflinePairSessionWindow(nowSeconds: number, expiresAtSeconds: number): void { - if ( - !Number.isSafeInteger(nowSeconds) || - !Number.isSafeInteger(expiresAtSeconds) || - expiresAtSeconds <= nowSeconds || - expiresAtSeconds - nowSeconds > IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } -} - -function validateEndpointExpectation( - value: OfflinePairVerificationExpectation["acceptor"], - platform: "mac" | "ios", -): void { - if ( - !UUID_PATTERN.test(value.bindingId) || - !UUID_PATTERN.test(value.deviceId) || - value.platform !== platform || - !Number.isSafeInteger(value.identityGeneration) || - value.identityGeneration < 1 || - value.identityGeneration > POSTGRES_INT32_MAX - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - endpointId(value.endpointId); -} - -function sameEndpointExpectation( - left: OfflinePairVerificationExpectation["acceptor"], - right: OfflinePairVerificationExpectation["acceptor"], -): boolean { - return left.bindingId === right.bindingId && - left.deviceId === right.deviceId && - left.endpointId === right.endpointId && - left.identityGeneration === right.identityGeneration && - left.platform === right.platform; -} - -function offlinePairProofHash( - sessionId: string, - acceptor: OfflinePairVerificationExpectation["acceptor"], - proof: Uint8Array, -): string { - return sha256(Buffer.concat([ - Buffer.from( - `cmux/iroh/offline-pair-session/v1\n${sessionId}\n${acceptor.bindingId}\n${acceptor.deviceId}\n${acceptor.endpointId}\n${acceptor.identityGeneration}\n${acceptor.platform}\n`, - "utf8", - ), - Buffer.from(proof), - ])); -} - const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function assertExactKeys( diff --git a/web/services/iroh/discoveryScope.ts b/web/services/iroh/discoveryScope.ts index b5e4603b9dd0..59c2340c4091 100644 --- a/web/services/iroh/discoveryScope.ts +++ b/web/services/iroh/discoveryScope.ts @@ -86,37 +86,6 @@ export function discoveryScopeMatchesRegistration( && scope.localBinding.platform === registration.platform; } -export function bindingMatchesDiscoveryScope( - binding: { - readonly deviceUuid: string; - readonly appInstanceId: string; - readonly tag: string; - readonly platform: string; - readonly pairingEnabled: boolean; - }, - scope: IrohDiscoveryScope, -): boolean { - const local = scope.localBinding; - if ( - binding.deviceUuid === local.deviceId - && binding.appInstanceId === local.appInstanceId - && binding.tag === local.tag - && binding.platform === local.platform - ) { - return true; - } - const peers = scope.peerBindings; - return binding.platform === peers.platform - && ( - peers.tags === undefined - || peers.tags.includes(binding.tag.toLowerCase()) - ) - && ( - peers.pairingEnabled === undefined - || binding.pairingEnabled === peers.pairingEnabled - ); -} - function peerTags(value: unknown): readonly string[] { if ( !Array.isArray(value) diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 146edb8caf4e..1e054add274a 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -16,8 +16,6 @@ export const IROH_ENDPOINT_ATTESTATION_SCOPE = "cmux.offline-pair.same-account"; export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; -export const IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS = 5 * 60; -export const IROH_OFFLINE_PAIR_SESSION_VERSION = 1; export const IROH_ROUTE_CONTRACT_VERSION = 1; export const POSTGRES_INT32_MAX = 2_147_483_647; diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts index e43abc27f3e4..f34d6b67407a 100644 --- a/web/services/iroh/publicationPolicy.ts +++ b/web/services/iroh/publicationPolicy.ts @@ -26,14 +26,6 @@ type PathHintLike = { readonly privacy_scope?: string; }; -/** Keep only endpoint-reported managed relay URLs for server persistence. */ -export function serverPublishedIrohPathHints( - hints: readonly T[], -): T[] { - return hints.filter((hint) => - hint.kind === "relay_url" && MANAGED_RELAY_URL_SET.has(hint.value)); -} - /** * Keep only routes safe for the authenticated same-account broker. * diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 6ed15d6868c3..5d1f038be7d6 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -738,4 +738,3 @@ function bindingPlatform(binding: IrohBindingRecord): "mac" | "ios" { // Stack bearer authentication alone is never sufficient to mutate path hints. // Until the dedicated endpoint-signed monotonic update route lands, clients // refresh watch_addr output only through a new signed registration challenge. -export const IROH_SIGNED_PATH_HINT_UPDATE_FOLLOWUP = "endpoint-signed-monotonic-watch-addr-update-v1"; diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index 351a5266f710..e5ebae42628d 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -7,7 +7,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { assertCurrentSigningKey, - createOfflinePairSessionRecord, deriveAccountSubject, deriveLanRendezvousKey, parseVerificationKeys, @@ -16,7 +15,6 @@ import { signPairGrant, verifyEndpointAttestation, verifyEndpointRegistrationSignature, - verifyAndConsumeOfflineSameAccountPair, verifyPairGrant, type EndpointAttestationClaims, type PairGrantClaims, @@ -475,111 +473,6 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => )).toThrow(); }); - test("requires two fresh endpoint-bound attestations with the same opaque account subject", () => { - const initiatorToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: initiator, - }); - const acceptorToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: acceptor, - }); - const expected = { - initiator: { ...endpointExpectation(initiator), platform: "ios" as const }, - acceptor: { ...endpointExpectation(acceptor), platform: "mac" as const }, - nowSeconds, - } as const; - const proof = Buffer.alloc(32, 0x61).toString("base64url"); - const session = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000001", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - const invitation = { version: 1 as const, sessionId: session.sessionId, proof }; - - expect(verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session, - invitation, - }).acceptor.endpointId).toBe(acceptor.endpointId); - expect(session.consumedAtSeconds).toBe(nowSeconds); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session, - invitation, - })).toThrow(); - - const missingSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000002", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: "", - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: missingSession, - invitation: { ...invitation, sessionId: missingSession.sessionId }, - })).toThrow(); - expect(missingSession.consumedAtSeconds).toBeNull(); - - const wrongProofSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000004", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: wrongProofSession, - invitation: { - version: 1, - sessionId: wrongProofSession.sessionId, - proof: Buffer.alloc(32, 0x62).toString("base64url"), - }, - })).toThrow(); - expect(wrongProofSession.consumedAtSeconds).toBeNull(); - - const otherAccountToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: { ...acceptor, sub: Buffer.alloc(32, 0x52).toString("base64url") }, - }); - const mismatchSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000003", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: otherAccountToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: mismatchSession, - invitation: { ...invitation, sessionId: mismatchSession.sessionId }, - })).toThrow(); - expect(mismatchSession.consumedAtSeconds).toBeNull(); - }); - test("rejects endpoint substitution, expiry, extra identity claims, and noncanonical signatures", () => { const token = signEndpointAttestation({ privateKeyPem: currentPrivate, diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index d2975282b051..a4fe8dba85fb 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -32,7 +32,7 @@ import { type IrohRepositoryShape, } from "../services/iroh/repository"; import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; -import { bindingMatchesDiscoveryScope } from "../services/iroh/discoveryScope"; +import type { IrohDiscoveryScope } from "../services/iroh/discoveryScope"; import type { RelayPreference } from "../services/relay/model"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -1830,6 +1830,39 @@ type MutableBinding = IrohBindingRecord & { directPortV6: number | null; }; +// Mirrors the live repository's SQL discovery-scope filter for the in-memory +// fixture (the production filter lives in repository.ts SQL, not TypeScript). +function bindingMatchesDiscoveryScope( + binding: { + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly platform: string; + readonly pairingEnabled: boolean; + }, + scope: IrohDiscoveryScope, +): boolean { + const local = scope.localBinding; + if ( + binding.deviceUuid === local.deviceId + && binding.appInstanceId === local.appInstanceId + && binding.tag === local.tag + && binding.platform === local.platform + ) { + return true; + } + const peers = scope.peerBindings; + return binding.platform === peers.platform + && ( + peers.tags === undefined + || peers.tags.includes(binding.tag.toLowerCase()) + ) + && ( + peers.pairingEnabled === undefined + || binding.pairingEnabled === peers.pairingEnabled + ); +} + class MemoryRepository implements IrohRepositoryShape { readonly challenges: IrohChallengeRecord[] = []; readonly bindings: MutableBinding[] = []; From e49eecbceb499233088481a1cc348152be4b768d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:58:42 -0700 Subject: [PATCH 09/71] iroh host: a relay-readiness timeout never publishes the binding Review P1: the ready gate caught the bounded readiness timeout together with every other error and then ran the publication refresh, recreating the discoverable-but-undialable race for any relay outage or warm-up slower than the readiness window. A timeout now keeps the endpoint unpublished and retries the readiness wait with bounded backoff on the injected registration clock, still under the lifecycle revision guards. Publication happens only after waitForUsableHomeRelay() verifies a usable relay path. Endpoint replacement or deactivation ends the gate unpublished and leaves state surfacing to the existing failure handling. The readiness window is now injectable (relayReadinessTimeout, default 15 s) so behavior tests can drive repeated timeouts without wall-clock waits. --- .../CmxIrohHostRuntime.swift | 62 ++++++++++-- ...ohHostRuntimeStartupPublicationTests.swift | 99 +++++++++++++++++++ 2 files changed, 152 insertions(+), 9 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 28807d5eb25c..6f627e8faf00 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -74,6 +74,9 @@ public actor CmxIrohHostRuntime { let registrationClock: any CmxIrohRelayClock let registrationRetrySchedule: CmxIrohRetrySchedule let registrationRetryJitter: @Sendable () -> Double + /// One bounded signal-or-deadline window for the startup relay-readiness + /// wait. A timeout keeps the endpoint unpublished and retries the wait. + let relayReadinessTimeout: Duration let handleTransport: TransportHandler let handleBinding: BindingHandler let handleRoute: RouteHandler @@ -139,6 +142,7 @@ public actor CmxIrohHostRuntime { registrationRetryJitter: @escaping @Sendable () -> Double = { Double.random(in: 0 ... 1) }, + relayReadinessTimeout: Duration = .seconds(15), handleTransport: @escaping TransportHandler, handleBinding: @escaping BindingHandler = { _, _, _ in }, handleRoute: @escaping RouteHandler = { _, _ in }, @@ -157,6 +161,7 @@ public actor CmxIrohHostRuntime { self.registrationClock = registrationClock self.registrationRetrySchedule = registrationRetrySchedule self.registrationRetryJitter = registrationRetryJitter + self.relayReadinessTimeout = relayReadinessTimeout self.handleTransport = handleTransport self.handleBinding = handleBinding self.handleRoute = handleRoute @@ -666,15 +671,54 @@ public actor CmxIrohHostRuntime { guard lifecyclePhase == .active, lifecycleRevision == revision, !Task.isCancelled else { return } - if let connectivityEngine, await connectivityEngine.hasConfiguredRelay() { - do { - try await connectivityEngine.waitForUsableHomeRelay() - } catch is CancellationError { - return - } catch { - // The bounded readiness window elapsed or the generation moved - // on. Publication proceeds so a relay outage cannot leave this - // Mac permanently unpublished on its LAN and direct paths. + guard let connectivityEngine else { return } + let relayExpected: Bool + if relayCoordinator != nil { + relayExpected = true + } else { + relayExpected = await connectivityEngine.hasConfiguredRelay() + } + if relayExpected { + // The Mac must never be discoverable-but-undialable: a readiness + // timeout keeps the endpoint unpublished and retries the wait with + // bounded backoff on the injected clock until a verified usable + // relay path exists or this lifecycle revision is superseded. + var readinessFailureCount = 0 + while true { + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + do { + try await connectivityEngine.waitForUsableHomeRelay( + timeout: relayReadinessTimeout + ) + break + } catch is CancellationError { + return + } catch CmxIrohEndpointSupervisorError.relayReadinessTimedOut { + // Retry below after bounded backoff. + } catch { + // The endpoint generation was replaced or deactivated. The + // successor lifecycle owns publication; this one stays + // unpublished and existing failure handling surfaces state. + return + } + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + let delay = registrationRetrySchedule.delay( + failureCount: readinessFailureCount, + retryAfterSeconds: nil, + jitterUnitInterval: registrationRetryJitter() + ) + readinessFailureCount = min(readinessFailureCount + 1, 20) + do { + try await registrationClock.sleep( + until: registrationClock.now().addingTimeInterval(delay) + ) + } catch { + return + } } } guard lifecyclePhase == .active, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 69abedaac41f..0544b8778f5a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -189,4 +189,103 @@ extension CmxIrohHostRuntimeTests { #expect(await runtime.snapshot().state == .active) await runtime.stop() } + + /// A relay-readiness timeout must never publish. The gate keeps retrying + /// the readiness wait on the injected clock; once the relay becomes + /// usable, exactly one publication follows. + @Test("readiness timeouts keep the binding unpublished until the relay succeeds") + func readinessTimeoutsKeepBindingUnpublishedUntilRelaySucceeds() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + #expect(await bindings.count() == 0) + + // First readiness timeout: still unpublished, backoff armed. + await clock.waitUntilSleepCount(1) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + + // Second readiness timeout: still unpublished. + await clock.waitUntilSleepCount(2) + #expect(await bindings.count() == 0) + + // The home relay comes up. Publication must follow, exactly once. + await endpoint.emit(.online) + + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A relay that never becomes usable must leave the endpoint permanently + /// unpublished: no handleBinding, no handleRoute, no extra broker rounds. + @Test("readiness that never succeeds never publishes") + func readinessThatNeverSucceedsNeverPublishes() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + for cycle in 1 ... 3 { + await clock.waitUntilSleepCount(cycle) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + } + + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await broker.observedRegistrationCount() == 1) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } } From ca8eb7315968cf64d87bf82b21f02ac52f1e564c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:03:52 -0700 Subject: [PATCH 10/71] iroh host: apply the readiness check on every publication path The relay-required activation branch set publishInline directly. The readiness barrier had already completed there, so behavior was correct, but the special case made the guarantee non-obvious to review. Every path now re-checks verified readiness through initialPublicationReady() immediately before publication. --- .../CmuxIrohTransport/CmxIrohHostRuntime.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 6f627e8faf00..0ea073b08236 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -350,15 +350,14 @@ public actor CmxIrohHostRuntime { // into `readyPolicy`; do not immediately publish a third copy. registrationRefreshPending = false } - let publishInline: Bool - if requiresRelayReadiness { - publishInline = true - } else { - publishInline = await initialPublicationReady( - engine: connectivityEngine - ) - try requireCurrent(revision) - } + // Every path re-checks verified readiness immediately before + // publication. Relay-required activations arrive here only after + // the blocking waitForUsableHomeRelay() above succeeded, so the + // check returns true for them without a second wait. + let publishInline = await initialPublicationReady( + engine: connectivityEngine + ) + try requireCurrent(revision) let publishedFreshBinding: Bool if let registration = publishedPolicy.registration, let discovery = publishedPolicy.discovery, From 4f2daabd33b358362566ce87cede9461172e1559 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:07:54 -0700 Subject: [PATCH 11/71] test(iroh): bounded close wait for terminal reconcile teardown A network-change refresh can own the terminal round; its teardown can still be closing the endpoint when the publication pipeline await returns. The fail-closed assertions now wait bounded for the close. --- .../CmxIrohHostRuntimePolicyTests.swift | 6 +++--- .../CmxIrohHostRuntimeStartupPublicationTests.swift | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift index 61dabc88c3e5..facd2322681d 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift @@ -351,7 +351,7 @@ extension CmxIrohHostRuntimeTests { await runtime.waitForInitialPublicationForTesting() #expect(await runtime.snapshot().state == .failed) - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await endpoint.waitForCloseCallCount(1)) #expect(await bindings.count() == 0) } @@ -537,7 +537,7 @@ extension CmxIrohHostRuntimeTests { await runtime.waitForInitialPublicationForTesting() #expect(await runtime.snapshot().state == .failed) - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await endpoint.waitForCloseCallCount(1)) } @Test @@ -571,7 +571,7 @@ extension CmxIrohHostRuntimeTests { await runtime.waitForInitialPublicationForTesting() #expect(await runtime.snapshot().state == .failed) - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await endpoint.waitForCloseCallCount(1)) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 0544b8778f5a..8de1859b2f90 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -15,6 +15,19 @@ extension CmxIrohHostRuntime { } } +extension TestIrohEndpoint { + /// A network-change refresh can own the terminal round and still be + /// finishing its teardown when the publication pipeline await returns. + /// Bounded wait for the endpoint close that teardown must perform. + func waitForCloseCallCount(_ minimum: Int) async -> Bool { + for _ in 0 ..< 50_000 { + if observedCloseCallCount() >= minimum { return true } + await Task.yield() + } + return observedCloseCallCount() >= minimum + } +} + extension CmxIrohHostRuntimeTests { /// Regression for the advertise-before-ready warm-up race /// (https://github.com/manaflow-ai/cmux/issues/9724): a Mac must not From 51198c12153adc508277957ef6aae3c466d9b977 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:16:49 -0700 Subject: [PATCH 12/71] Delete unreferenced CmxIrohInboundStream from CmuxIrohTransport The struct's only occurrence in the entire repo (all Swift under Packages/, Sources/, ios/, CLI/, daemon/, Native/, tests) is its own definition; no code constructs, returns, or names it, including the package's tests. swift build and swift test on the package pass after removal (615 tests in 66 suites; CmxConnectivityPeerSessionTests skipped because it deadlocks on current main independent of this change - the fix is in flight on origin/fix-peer-session-test-deadlock). --- .../CmxIrohInboundStream.swift | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift deleted file mode 100644 index 350f413dd7e5..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// A peer-created unidirectional stream after its lane header is removed. -public struct CmxIrohInboundStream: Sendable { - /// The declared server-event or artifact lane. - public let lane: CmxIrohLane - - /// The readable application payload after the consumed header. - public let receiveStream: any CmxIrohReceiveStream - - /// Creates a decoded inbound stream. - /// - /// - Parameters: - /// - lane: The peer-declared application lane. - /// - receiveStream: The stream with any over-read bytes preserved. - public init(lane: CmxIrohLane, receiveStream: any CmxIrohReceiveStream) { - self.lane = lane - self.receiveStream = receiveStream - } -} From 0bd0ddb0bdeff2e31bb123b06527b35faea3cbdc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:48:37 -0700 Subject: [PATCH 13/71] iroh host: decouple the live reconcile from relay readiness Review P1 pair: the deferred first publication could still ride any forced refresh (direct-port change, requested refresh) while the home relay was unusable, and a cache-first host whose relay never came up never verified its cached authority against the broker, hiding a server-side revocation or replacement behind a relay outage. Every refresh round now re-checks verified relay readiness immediately before performing the lifecycle's first publication; an unready round still applies admission policy, binding adoption, and renewal scheduling, and leaves the publication owed. A cache-first activation schedules its authenticated reconcile immediately, independent of relay readiness; a broker cooldown observed during activation keeps its validated retry floor, and the ready gate defers to an armed failure retry instead of preempting it. --- .../CmxIrohHostRuntime+PolicyRefresh.swift | 20 +++++ .../CmxIrohHostRuntime.swift | 41 +++++---- .../CmxIrohHostRuntimeLifecycleTests.swift | 9 +- .../CmxIrohHostRuntimePolicyTests.swift | 7 +- ...ohHostRuntimeStartupPublicationTests.swift | 84 ++++++++++++++++++- 5 files changed, 136 insertions(+), 25 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 392ac3c1e44f..18338672fa03 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -617,6 +617,26 @@ extension CmxIrohHostRuntime { let discovery = policy.discovery else { throw CmxIrohHostRuntimeError.invalidLocalBinding } + if initialPublicationPending { + let ready = await initialPublicationReady( + engine: connectivityEngine + ) + try requireCurrent(revision) + guard ready else { + // The first publication of this lifecycle stays gated on + // a verified usable relay path; the authenticated + // reconcile above already applied admission policy, + // binding adoption, and renewal scheduling. The ready + // gate runs the publishing round once the relay works. + registrationRefreshFailureCount = 0 + completedSuccessfully = true + scheduleRegistrationRenewal( + binding: registration.binding, + revision: revision + ) + return + } + } await handleBinding(registration, discovery, policy.attestation) try requireCurrent(revision) await handleRoute(policy.binding, policy.routePathHints) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 0ea073b08236..f2bde0ad3234 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -402,16 +402,31 @@ public actor CmxIrohHostRuntime { retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds ) } else { - // The ready gate activates the relay, waits for a usable - // home relay, and then runs the one live reconcile that - // publishes fresh path hints. initialPublicationPending = true allowsReplacedBindingAdoption = cachedStartPolicy != nil + if let retryAfterSeconds = publishedPolicy + .registrationRetryAfterSeconds { + // A broker cooldown observed during activation keeps + // its validated floor for the next live round. + scheduleRegistrationRetry( + revision: revision, + retryAfterSeconds: retryAfterSeconds + ) + } else if cachedStartPolicy != nil { + // Cached authority is verified against the live broker + // immediately, independent of relay readiness, so a + // server-side revocation or replacement cannot hide + // behind a relay outage. Readiness gates only the + // publication inside the refresh round. + scheduleRegistrationRefresh(revision: revision) + } + // The ready gate activates the relay, waits for a usable + // home relay, and then runs the round that performs the + // deferred first publication with fresh path hints. scheduleInitialPublication( binding: publishedPolicy.binding, endpointID: endpointID, bootstrap: publishedPolicy.relayBootstrap, - retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds, revision: revision ) } @@ -620,7 +635,7 @@ public actor CmxIrohHostRuntime { /// Returns whether the binding may be published immediately: the home /// relay is already usable, or this endpoint will never own a relay. - private func initialPublicationReady( + func initialPublicationReady( engine: CmxConnectivityEngine ) async -> Bool { if await engine.hasUsableHomeRelay() { return true } @@ -632,7 +647,6 @@ public actor CmxIrohHostRuntime { binding: CmxIrohBrokerBindingMetadata, endpointID: CmxIrohPeerIdentity, bootstrap: CmxIrohRelayTokenResponse?, - retryAfterSeconds: Int?, revision: UInt64 ) { initialPublicationTask?.cancel() @@ -641,7 +655,6 @@ public actor CmxIrohHostRuntime { binding: binding, endpointID: endpointID, bootstrap: bootstrap, - retryAfterSeconds: retryAfterSeconds, revision: revision ) } @@ -651,7 +664,6 @@ public actor CmxIrohHostRuntime { binding: CmxIrohBrokerBindingMetadata, endpointID: CmxIrohPeerIdentity, bootstrap: CmxIrohRelayTokenResponse?, - retryAfterSeconds: Int?, revision: UInt64 ) async { guard lifecyclePhase == .active, @@ -723,16 +735,13 @@ public actor CmxIrohHostRuntime { guard lifecyclePhase == .active, lifecycleRevision == revision, !Task.isCancelled else { return } - if let retryAfterSeconds { - // A broker cooldown observed during activation keeps its validated - // floor; the lifecycle retry loop owns the next live round. - scheduleRegistrationRetry( - revision: revision, - retryAfterSeconds: retryAfterSeconds - ) + guard initialPublicationPending else { return } + guard registrationRefreshFailureCount == 0 else { + // A broker cooldown or failure retry is already armed. That + // lifecycle-owned round performs the deferred publication once it + // succeeds, and it honors the broker's validated retry floor. return } - guard initialPublicationPending else { return } scheduleRegistrationRefresh(revision: revision) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift index 51b099e77182..641be7418c18 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift @@ -514,7 +514,10 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity, + subsequentRegistrationErrors: [ + .connectivity, .connectivity, .connectivity, + ] ) let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( @@ -536,7 +539,7 @@ extension CmxIrohHostRuntimeTests { #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) await runtime.waitForInitialPublicationForTesting() - #expect(await broker.observedRegistrationCount() == 1) + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) #expect(await runtime.snapshot().state == .active) #expect(await bindings.count() == 0) await runtime.stop() @@ -570,7 +573,7 @@ extension CmxIrohHostRuntimeTests { configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), pendingRevocations: fixture.pendingRevocations(), now: { now }, - registrationClock: ImmediateHostActivationClock(), + registrationClock: HostRegistrationRenewalClock(now: now), handleTransport: { session, _ in await session.close() }, handleRoute: { binding, pathHints in await routes.record(binding: binding, pathHints: pathHints) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift index facd2322681d..0b48a7c6302c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift @@ -237,7 +237,7 @@ extension CmxIrohHostRuntimeTests { @Test func networkChangeDuringActiveRefreshDoesNotRequestAnotherRegistration() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let gate = HostRuntimeRegistrationGate() let refreshes = HostRuntimeLANRefreshRecorder() let broker = TestIrohHostBroker( @@ -278,8 +278,7 @@ extension CmxIrohHostRuntimeTests { relays: Array(fixture.managedRelays), lanGeneration: 2 ) - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, + let endpoint = try fixture.relayReadyEndpoint( directAddresses: ["192.168.1.10:50906"] ) let policies = HostRuntimeLANPolicyRecorder() @@ -503,7 +502,7 @@ extension CmxIrohHostRuntimeTests { #expect(await runtime.snapshot().state == .failed) #expect(await bindings.count() == 0) - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await endpoint.waitForCloseCallCount(1)) } @Test diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 8de1859b2f90..0fa44256a36a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -87,8 +87,14 @@ extension CmxIrohHostRuntimeTests { #expect(await routes.values().isEmpty) #expect(await runtime.snapshot().state == .active) - // The relay comes up through the normal credential installation path. - // Publication must follow, exactly once, with the post-relay hints. + // The relay credential installs, then native iroh reports the home + // relay online. Publication must follow, exactly once, with the + // post-relay hints. + for _ in 0 ..< 20_000 { + if await !endpoint.observedRelayUpdates().isEmpty { break } + await Task.yield() + } + await endpoint.emit(.online) #expect(await bindings.waitForCount(1, timeout: .seconds(5))) let republished = await routes.values() #expect(republished.map(\.binding.bindingID) == [fixture.binding.bindingID]) @@ -301,4 +307,78 @@ extension CmxIrohHostRuntimeTests { #expect(await runtime.snapshot().state == .active) await runtime.stop() } + + /// A requested refresh must not perform the deferred first publication + /// while the home relay is still unusable, even though its authenticated + /// broker round runs and applies admission policy. + @Test("a requested refresh while the relay is unready does not publish") + func requestedRefreshWhileRelayUnreadyDoesNotPublish() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + await runtime.requestRegistrationRefresh() + + #expect(await broker.observedRegistrationCount() == 2) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// Cached authority must be verified against the live broker even when + /// the home relay never becomes usable: a server-side rejection fails + /// the runtime closed instead of hiding behind the relay outage. + @Test("stale cached authority fails closed without relay readiness") + func staleCachedAuthorityFailsClosedWithoutRelayReadiness() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationError: .missingAuthentication + ) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + cachedHostPolicy: try cachedFixture.policy() + ), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + relayReadinessTimeout: .milliseconds(50), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await endpoint.waitForCloseCallCount(1)) + #expect(await runtime.snapshot().state == .failed) + #expect(await bindings.count() == 0) + } } From 71df5089c315907822449c71d60e1b967bb06b0a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:53:38 -0700 Subject: [PATCH 14/71] iroh host: relay-required deferred retries carry the publication gate Review P1: the relay-required deferred branch armed its registration retry without initialPublicationPending, so a retry round could perform the lifecycle's first publication without re-checking relay readiness. Every deferred first publication now carries the pending flag. --- .../Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index f2bde0ad3234..92b1cb2863d4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -392,6 +392,10 @@ public actor CmxIrohHostRuntime { registrationRefreshEnabled = true if !publishedFreshBinding { registrationRefreshPending = false + // Every deferred first publication carries the pending flag so + // any refresh round that ends up performing it re-checks + // verified relay readiness first. + initialPublicationPending = true if requiresRelayReadiness { // Cached authority keeps offline admission and LAN // discovery available, but it cannot describe this endpoint @@ -402,7 +406,6 @@ public actor CmxIrohHostRuntime { retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds ) } else { - initialPublicationPending = true allowsReplacedBindingAdoption = cachedStartPolicy != nil if let retryAfterSeconds = publishedPolicy .registrationRetryAfterSeconds { From 1ae07e47e26a37d1977b110421a3e1712c1bf678 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:50:27 -0700 Subject: [PATCH 15/71] fix: restore app target compilation broken by worktree identity fields Commit 6cf5630c14 (#8567) declared worktreeDeviceID/worktreeFileID as 'let ... = nil', which removes them from the synthesized memberwise initializer, so the createWorktree call passing both labels failed to compile. Drop the defaults so the fields enter the memberwise init and the captured identity keeps flowing to rollback. Also unwrap the optional identity tuple in bestEffortCleanupFailedWorktree before comparing, since == is not lifted over optional tuples. --- Sources/ExtensionWorktreePrototype.swift | 7 ++++--- cmuxTests/ExtensionWorktreeSpawnArgsTests.swift | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/ExtensionWorktreePrototype.swift b/Sources/ExtensionWorktreePrototype.swift index de69fbf6a246..69061740fd1b 100644 --- a/Sources/ExtensionWorktreePrototype.swift +++ b/Sources/ExtensionWorktreePrototype.swift @@ -12,8 +12,8 @@ struct CmuxExtensionWorktreeCreationResult: Sendable { let generatedArtifactContents: Data /// Filesystem identity captured immediately after `git worktree add`. /// Rollback refuses to touch a path whose checkout was replaced. - let worktreeDeviceID: UInt64? = nil - let worktreeFileID: UInt64? = nil + let worktreeDeviceID: UInt64? + let worktreeFileID: UInt64? /// A convenience command (e.g. a sample dev-server launcher) that should run /// inside the new workspace's interactive shell. This is *setup*, never the /// workspace's primary process. @@ -537,7 +537,8 @@ enum CmuxExtensionWorktreePrototype { expectedIdentity: (deviceID: UInt64, fileID: UInt64)? ) async { guard let expectedIdentity, - filesystemIdentity(at: worktree) == expectedIdentity else { + let currentIdentity = Self.filesystemIdentity(at: worktree), + currentIdentity == expectedIdentity else { logPrivateDiagnostic("Skipped failed worktree cleanup after identity changed.") return } diff --git a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift index d8fc565f0227..92dcfc22ecee 100644 --- a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift +++ b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift @@ -28,6 +28,8 @@ struct ExtensionWorktreeSpawnArgsTests { createdHead: "0000000000000000000000000000000000000000", generatedArtifactRelativePath: "cmux-sample-dev/index.html", generatedArtifactContents: Data(), + worktreeDeviceID: nil, + worktreeFileID: nil, setupCommand: setupCommand ) } From 9bbb0720a9c05f043163e272e15b1f30404b05a3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:27:34 -0700 Subject: [PATCH 16/71] fix: add explicit return in task-group closure in worktree rollback test A multi-statement closure gets no implicit return, so the group.addTask closure returning Int32? failed to compile. Found while running the touched test class on the remote builder. --- cmuxTests/ExtensionWorktreeSpawnArgsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift index 92dcfc22ecee..12cbf31a0fbe 100644 --- a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift +++ b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift @@ -361,7 +361,7 @@ struct ExtensionWorktreeSpawnArgsTests { let mutationStatus = await withTaskGroup(of: Int32?.self, returning: Int32?.self) { group in group.addTask { var mutationIterator = mutation.stream.makeAsyncIterator() - await mutationIterator.next() + return await mutationIterator.next() } group.addTask { try? await Task.sleep(for: .seconds(5)) From 7bd2a246348a8e7e2a1252a1c42df62a9381d2ad Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:05:36 -0700 Subject: [PATCH 17/71] Drop stale comments referencing the deleted relay minter --- web/.env.example | 2 +- web/app/env.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/.env.example b/web/.env.example index db5e0790c242..7c05e7f8ba4e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -78,7 +78,7 @@ NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= STACK_SECRET_SERVER_KEY= # Personal-account Iroh trust broker. The Iroh Services project API key is not -# accepted by this process; it belongs only in the isolated Rust relay minter. +# accepted by this process. # Grant verification JSON maps the current and previous KIDs to Ed25519 SPKI # PEM strings. The developer binding override remains off unless all three # override settings explicitly match the authenticated user and deployment. diff --git a/web/app/env.ts b/web/app/env.ts index fc6e5457f50a..501e897ab0cf 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -220,8 +220,8 @@ export const env = createEnv({ }) .optional(), // Iroh trust broker. The Services API key deliberately has no TypeScript - // env entry: only the isolated Rust relay minter may hold it. These values - // are server-only and routes fail closed when an operation's key is absent. + // env entry. These values are server-only and routes fail closed when an + // operation's key is absent. CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requireVercelNonPreviewValue( "CMUX_IROH_LAN_DISCOVERY_SECRET_B64", z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), @@ -280,8 +280,8 @@ export const env = createEnv({ CMUX_RELAY_PREFERENCES_RATE_LIMIT_ID: z.string().min(1).optional(), // Shared secret for the relay fleet's per-connection access-control hook // (POST /api/relay/allow). Optional: when unset the route answers 503 and - // the fleet fails closed for new endpoint admissions. Same base64 shape as - // CMUX_IROH_MINT_HMAC_SECRET_B64. + // the fleet fails closed for new endpoint admissions. 32-byte random + // secret in standard base64. CMUX_RELAY_ALLOW_HMAC_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), }, From 2db949df163bea56f21e114311cfa6cad1aa08f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:32:00 -0700 Subject: [PATCH 18/71] iroh host: binding adoption drains and re-arms the startup ready gate The startup ready gate task is armed with the cached binding and relay bootstrap it saw at activation. When the background reconcile adopts a server-side replacement binding, the stale gate could interleave with adoption and activate the replacement relay coordinator with the superseded binding ID and credential, breaking binding identity. adoptReplacedBinding now cancels and drains the stale gate before rebinding the relay coordinator, then re-arms the gate bound to the adopted binding and its bootstrap while the first publication is still owed, so the endpoint still publishes once the relay becomes usable. The new test covers adoption while the relay is unready end to end (cache-first start stays unpublished, adoption lands, exactly one publication of the adopted identity after readiness). The stale-activate interleaving itself is a scheduling window that a behavior-level test cannot pin deterministically, so this is hardening coverage, not a red-then-green regression pair. --- .../CmxIrohHostRuntime+PolicyRefresh.swift | 39 ++++++++- ...ohHostRuntimeStartupPublicationTests.swift | 83 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 18338672fa03..a4fa11687b4c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -708,6 +708,43 @@ extension CmxIrohHostRuntime { guard let connectivityEngine else { throw CmxIrohHostRuntimeError.inactive } + // The startup ready gate retains the superseded cached binding and + // relay bootstrap it was armed with. Cancel and drain it before + // rebinding so it can never activate the replacement coordinator with + // the stale identity or install a stale relay credential; the deferred + // first publication is re-armed onto the adopted binding below. + if let staleReadyGate = initialPublicationTask { + staleReadyGate.cancel() + initialPublicationTask = nil + await staleReadyGate.value + try requireCurrent(revision) + } + try await rebindRelayCoordinator( + policy: policy, + engine: connectivityEngine, + revision: revision + ) + if initialPublicationPending { + // The drained gate owned the relay-readiness wait for the deferred + // first publication. Re-arm it bound to the adopted identity so + // the endpoint still publishes once the relay becomes usable. + scheduleInitialPublication( + binding: policy.binding, + endpointID: policy.binding.endpointID, + bootstrap: policy.relayBootstrap, + revision: revision + ) + } + } + + /// Deactivates the coordinator pinned to the replaced binding and, for a + /// managed relay profile, activates a replacement pinned to the adopted + /// binding. + private func rebindRelayCoordinator( + policy: ResolvedPolicy, + engine: CmxConnectivityEngine, + revision: UInt64 + ) async throws { guard let coordinator = relayCoordinator else { return } relayActivationTask?.cancel() relayActivationTask = nil @@ -721,7 +758,7 @@ extension CmxIrohHostRuntime { profile.source == .managed, !profile.allowedRelayURLs.isEmpty else { return } let replacement = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, + supervisor: engine, broker: broker, managedRelayURLs: managedRelayURLs, selectedRelayURLs: profile.allowedRelayURLs, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 0fa44256a36a..2138b4593944 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -345,6 +345,89 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } + /// A cache-first activation whose live reconcile adopts a server-side + /// replacement binding while the home relay is still unusable must move + /// the ready gate onto the adopted identity: the stale gate armed with + /// the superseded cached binding is drained so it can never activate the + /// replacement relay coordinator with the old binding, nothing publishes + /// before the relay is usable, and afterwards exactly the adopted + /// binding publishes, once. + @Test("adoption while the relay is unready re-arms the gate on the adopted binding") + func adoptionWhileRelayUnreadyReArmsGateOnAdoptedBinding() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let cachedPolicy = try cachedFixture.policy() + let now = cachedFixture.now + let replacementBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + bindingID: "123e4567-e89b-42d3-a456-426614174099" + ) + let replacementDiscovery = try HostRuntimeFixture.discovery( + binding: replacementBinding, + relays: HostRuntimeFixture.relayURLs + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: replacementBinding, + discovery: replacementDiscovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // Cache-first start on the persisted binding: the cached route + // identity is refreshed, nothing publishes while the relay warms up. + #expect(await runtime.snapshot().state == .active) + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + ]) + #expect(await bindings.count() == 0) + + // The live reconcile adopts the server-side replacement binding. The + // relay is still unusable, so the adopted binding must stay + // unpublished. + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + var adopted = false + for _ in 0 ..< 50_000 { + if await runtime.snapshot().bindingID == replacementBinding.bindingID { + adopted = true + break + } + await Task.yield() + } + #expect(adopted) + #expect(await bindings.count() == 0) + + // The relay credential installs for the adopted binding, then the + // home relay comes online. Publication must follow, exactly once, + // with the adopted identity. + for _ in 0 ..< 20_000 { + if await !endpoint.observedRelayUpdates().isEmpty { break } + await Task.yield() + } + await endpoint.emit(.online) + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + let published = await routes.values() + #expect(published.first?.binding.bindingID == cachedPolicy.binding.bindingID) + #expect(published.last?.binding.bindingID == replacementBinding.bindingID) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + /// Cached authority must be verified against the live broker even when /// the home relay never becomes usable: a server-side rejection fails /// the runtime closed instead of hiding behind the relay outage. From a62e9069efbe3e95ceb148e56eb1daf8c3868664 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:29:25 -0700 Subject: [PATCH 19/71] debug: CMUX_IROH_RELAY_URL_OVERRIDE forces one test relay in debug builds Debug-build-only override read from the environment (or the same-named UserDefaults key for iOS launch arguments). Applied at endpoint-profile resolution and at both runtime replaceRelayProfile funnels so a broker policy refresh cannot displace the test relay. Release builds compile the override away. --- .../CmxIrohClientRuntime+RelayPolicy.swift | 6 ++ .../CmxIrohDebugRelayOverride.swift | 56 +++++++++++ .../CmxIrohHostRuntime+RelayPolicy.swift | 6 ++ .../CmxIrohRuntimeRelayProfile.swift | 16 +++- .../CmxIrohDebugRelayOverrideTests.swift | 95 +++++++++++++++++++ 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift index ef1382f57fd0..457f757f0753 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift @@ -29,6 +29,12 @@ extension CmxIrohClientRuntime { managedRelayURLs replacementManagedURLs: Set, relayBootstrap: CmxIrohRelayTokenResponse? ) async throws { + // A debug-only forced relay pins every profile installation, so a + // broker policy refresh cannot displace the test relay mid-run. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let binding = localBinding else { throw CmxIrohClientRuntimeError.inactive } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift new file mode 100644 index 000000000000..121ddbdd2879 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift @@ -0,0 +1,56 @@ +public import Foundation + +/// A debug-build-only forced relay for tagged test builds. +/// +/// When active, every host (Mac) and dial (iOS) endpoint generation uses +/// exactly one operator-supplied relay, replacing managed policy, cached +/// credentials, and account preference. The override is read at each +/// endpoint-profile installation, so a later broker policy refresh cannot +/// displace it. Release builds compile the override away entirely. +public enum CmxIrohDebugRelayOverride { + /// The environment variable consulted first, and the `UserDefaults` key + /// consulted second. A `-CMUX_IROH_RELAY_URL_OVERRIDE ` launch + /// argument populates the defaults key on iOS builds whose launch + /// environment cannot carry variables. + public static let key = "CMUX_IROH_RELAY_URL_OVERRIDE" + + /// The process-wide override profile, or nil when inactive. + static func activeProfile() -> CmxIrohEndpointRelayProfile? { + #if DEBUG + profile(rawValue: rawValue()) + #else + nil + #endif + } + + #if DEBUG + /// Reads the raw override value, preferring the process environment. + static func rawValue( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> String? { + if let fromEnvironment = environment[key], !fromEnvironment.isEmpty { + return fromEnvironment + } + return defaults.string(forKey: key) + } + + /// Builds a strict single-relay custom profile, or nil for unusable input. + /// + /// The value must be a canonical HTTPS origin; a missing trailing slash + /// is added. Any other malformed value deactivates the override instead + /// of failing endpoint activation. + static func profile(rawValue: String?) -> CmxIrohEndpointRelayProfile? { + guard var url = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty else { + return nil + } + if !url.hasSuffix("/") { url += "/" } + guard let relay = try? CmxIrohCustomRelay(url: url), + let custom = try? CmxIrohCustomRelayProfile(relays: [relay]) else { + return nil + } + return CmxIrohEndpointRelayProfile(customProfile: custom) + } + #endif +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift index f0e0e25254c7..f9144154b496 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift @@ -29,6 +29,12 @@ extension CmxIrohHostRuntime { managedRelayURLs replacementManagedURLs: Set, relayBootstrap: CmxIrohRelayTokenResponse? ) async throws { + // A debug-only forced relay pins every profile installation, so a + // broker policy refresh cannot displace the test relay mid-run. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let connectivityEngine, let binding = localBinding else { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift index 772165665a8c..2924156cdb56 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift @@ -1,8 +1,12 @@ import Foundation extension CmxIrohHostRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( + func resolvedEndpointRelayProfile( + now: Date, + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try resolveEndpointRelayProfile( configured: endpointRelayProfile, managedRelayURLs: managedRelayURLs, cachedRelayCredential: cachedRelayCredential, @@ -12,8 +16,12 @@ extension CmxIrohHostRuntimeConfiguration { } extension CmxIrohClientRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( + func resolvedEndpointRelayProfile( + now: Date, + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try resolveEndpointRelayProfile( configured: endpointRelayProfile, managedRelayURLs: managedRelayURLs, cachedRelayCredential: cachedRelayCredential, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift new file mode 100644 index 000000000000..66e64f2cc1f0 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import CmuxIrohTransport + +@Suite struct CmxIrohDebugRelayOverrideTests { + @Test func parsesCanonicalHTTPSOriginAndAddsTrailingSlash() throws { + let profile = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: " https://relay-test.example.com ") + ) + #expect(profile.allowedRelayURLs == ["https://relay-test.example.com/"]) + #expect(profile.source == .custom) + #expect(profile.activeRelays.map(\.url) == ["https://relay-test.example.com/"]) + } + + @Test func keepsExplicitPort() throws { + let profile = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com:8443/") + ) + #expect(profile.allowedRelayURLs == ["https://relay-test.example.com:8443/"]) + } + + @Test(arguments: [ + nil, + "", + " ", + "http://insecure.example.com/", + "https://relay.example.com/path", + "https://relay.example.com/?q=1", + "https://user:pw@relay.example.com/", + "not a url", + ] as [String?]) + func rejectsUnusableValues(_ raw: String?) { + #expect(CmxIrohDebugRelayOverride.profile(rawValue: raw) == nil) + } + + @Test func environmentWinsOverDefaults() throws { + let suiteName = "cmux-debug-relay-override-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set( + "https://from-defaults.example.com/", + forKey: CmxIrohDebugRelayOverride.key + ) + #expect( + CmxIrohDebugRelayOverride.rawValue( + environment: [CmxIrohDebugRelayOverride.key: "https://from-env.example.com/"], + defaults: defaults + ) == "https://from-env.example.com/" + ) + #expect( + CmxIrohDebugRelayOverride.rawValue( + environment: [:], + defaults: defaults + ) == "https://from-defaults.example.com/" + ) + } + + @Test func hostResolutionPrefersOverride() throws { + let fixture = try HostRuntimeFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile( + now: Date(), + debugOverride: override + ) + #expect(resolved == override) + + let managed = try fixture.configuration.resolvedEndpointRelayProfile( + now: Date(), + debugOverride: nil + ) + #expect(managed.source == .managed) + #expect(managed.allowedRelayURLs == fixture.managedRelays) + } + + @Test func clientResolutionPrefersOverride() throws { + let fixture = try ClientRuntimeTestFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile( + now: fixture.now, + debugOverride: override + ) + #expect(resolved == override) + + let managed = try fixture.configuration.resolvedEndpointRelayProfile( + now: fixture.now, + debugOverride: nil + ) + #expect(managed.source == .managed) + #expect(managed.allowedRelayURLs == Set(ClientRuntimeTestFixture.relayURLs)) + } +} From fb56f993e29b4653f02146ded69746eb4d946475 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:48:22 -0700 Subject: [PATCH 20/71] iroh-diag: report the active relay profile source and URLs The verb printed only the DiagnosticLog timeline; proving which relay the endpoint used required netstat. Append an active-relay section (managed catalog / custom / CMUX_IROH_RELAY_URL_OVERRIDE debug override, plus URLs) from a nonisolated mirror of the installed policy so the verb stays usable while the main thread is wedged. Relay URLs stay out of the privacy-safe DiagnosticLog report itself. --- .../CmxIrohDebugRelayOverride.swift | 8 ++ Sources/Mobile/MobileHostIrohRuntime.swift | 81 ++++++++++++++++++- Sources/TerminalController.swift | 8 +- .../MobileHostServiceSettingsTests.swift | 62 ++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift index 121ddbdd2879..8f2b24d5f272 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift @@ -23,6 +23,14 @@ public enum CmxIrohDebugRelayOverride { #endif } + /// The active override's single relay URL, exposed for local debug + /// diagnostics (the `iroh_diag` socket verb). Nil when the override is + /// inactive, and always nil in release builds, where the override + /// compiles away. + public static var diagnosticsActiveRelayURL: String? { + activeProfile()?.activeRelays.first?.url + } + #if DEBUG /// Reads the raw override value, preferring the process environment. static func rawValue( diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index 3b1b07ce5332..f9c2a9d415ca 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -4,6 +4,7 @@ import CmuxIrohTransport import CryptoKit import Foundation import Observation +import os import OSLog let mobileHostIrohLog = Logger( @@ -108,7 +109,19 @@ final class MobileHostIrohRuntime { var transitionTask: Task? var runtime: CmxIrohHostRuntime? var relayPolicyService: CmxIrohRelayPolicyService? - var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? + var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? { + didSet { + Self.relayDiagMirror.withLock { [relayPolicyEffective] state in + state = relayPolicyEffective.map { + RelayDiagState( + source: $0.source, + usedCachedPolicy: $0.usedCachedPolicy, + relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted() + ) + } + } + } + } var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? var relayPolicyEndpointID: CmxIrohPeerIdentity? var relayPolicyObservationTask: Task? @@ -234,6 +247,72 @@ final class MobileHostIrohRuntime { role: .macHost ) + /// The relay policy fields the `iroh_diag` socket verb reports. + /// + /// Relay URLs are deliberately kept out of ``DiagnosticLog`` and its + /// report, which stay privacy-safe for Settings exports; the local debug + /// socket appends these lines itself. + struct RelayDiagState: Equatable, Sendable { + let source: CmxIrohRelayPolicySource + let usedCachedPolicy: Bool + let relayURLs: [String] + } + + /// Nonisolated mirror of the relay policy most recently installed by the + /// account pipeline, written from the main-actor `relayPolicyEffective` + /// funnel and readable (like ``hostDiagnosticLog``) without a main-actor + /// hop so `iroh_diag` keeps working when the main thread is wedged. + nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( + initialState: nil + ) + + /// The relay section appended to `iroh_diag` output: the profile the + /// endpoint is actually using, and whether it came from the managed + /// catalog, a custom profile, or the debug override. The override is + /// consulted first because every profile installation funnel replaces + /// the installed profile with it while it is active. + nonisolated static func relayDiagReportText() -> String { + relayDiagReport( + policy: relayDiagMirror.withLock { $0 }, + debugOverrideRelayURL: CmxIrohDebugRelayOverride.diagnosticsActiveRelayURL + ) + } + + nonisolated static func relayDiagReport( + policy: RelayDiagState?, + debugOverrideRelayURL: String? + ) -> String { + var lines = ["Active relay profile"] + if let debugOverrideRelayURL { + lines.append("Source: debug override (\(CmxIrohDebugRelayOverride.key))") + lines.append("Relays: \(debugOverrideRelayURL)") + return lines.joined(separator: "\n") + } + guard let policy else { + lines.append("Source: none installed (no relay policy this launch)") + return lines.joined(separator: "\n") + } + let source = switch policy.source { + case .inactive: + "inactive (no account policy restored)" + case .managed: + policy.usedCachedPolicy ? "managed catalog (cached)" : "managed catalog" + case .custom: + "custom" + case .managedUnavailable: + "managed selection unavailable (relays disabled)" + case .customUnavailable: + "custom selection unavailable (relays disabled)" + } + lines.append("Source: \(source)") + if policy.relayURLs.isEmpty { + lines.append("Relays: (none)") + } else { + lines.append("Relays: \(policy.relayURLs.joined(separator: ", "))") + } + return lines.joined(separator: "\n") + } + private nonisolated static var diagnosticBuildStamp: String { DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 33f1549af7e6..9ce723ecb973 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11696,7 +11696,9 @@ class TerminalController { /// Serves the v1 `iroh_diag` socket command: the host's Iroh Connection /// Report in the same plain-language format the Settings pane exports, /// read from the same `DiagnosticLog` snapshot path so the two can never - /// disagree. + /// disagree, plus an active-relay section (profile source and URLs) that + /// exists only in the local debug socket output because relay URLs are + /// deliberately excluded from the privacy-safe exported report. private nonisolated func irohDiagText() -> String { let semaphore = DispatchSemaphore(value: 0) nonisolated(unsafe) var export = "" @@ -11711,7 +11713,9 @@ class TerminalController { semaphore.signal() } semaphore.wait() - return export + // Appended outside the report so URLs never enter the DiagnosticLog + // pipeline; the mirror read is lock-guarded and main-actor-free. + return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n" } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 366b3fca855c..0c5de77db892 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -447,3 +447,65 @@ struct MobileHostMacScopedMutationAuthorizationTests { } #endif + +@Suite +struct MobileHostIrohRelayDiagReportTests { + @Test func debugOverrideWinsOverInstalledPolicy() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managed, + usedCachedPolicy: false, + relayURLs: ["https://relay.cmux.io/"] + ), + debugOverrideRelayURL: "https://test-relay.example/" + ) + #expect(text == """ + Active relay profile + Source: debug override (CMUX_IROH_RELAY_URL_OVERRIDE) + Relays: https://test-relay.example/ + """) + } + + @Test func managedCatalogReportsCachednessAndURLs() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managed, + usedCachedPolicy: true, + relayURLs: ["https://a.example/", "https://b.example/"] + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: managed catalog (cached) + Relays: https://a.example/, https://b.example/ + """) + } + + @Test func customProfileReportsCustomSource() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .custom, + usedCachedPolicy: false, + relayURLs: ["https://my-relay.example/"] + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: custom + Relays: https://my-relay.example/ + """) + } + + @Test func missingPolicyReportsNoneInstalled() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: nil, + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: none installed (no relay policy this launch) + """) + } +} From bab67f9819e741debfaba8f89bdc01ffd537fa45 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:49:20 -0700 Subject: [PATCH 21/71] test: quit requests must fire from a run-loop callout, not the caller's block Regression test for #10788. Routes the quit shortcut path's NSApp.terminate through one shared AppTerminationRequest seam (still synchronous here, so this commit stays red) and asserts the scheduled terminate does not run inside the requesting main-queue block. --- Sources/AppDelegate.swift | 20 ++++++++++++++-- .../QuitConfirmationAlertPresenterTests.swift | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f78fadb67384..f19e80c047cc 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13754,7 +13754,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasDirtyWorkspaces: hasQuitConfirmationDirtyWorkspaces(), isDevBuild: BuildFlavor.current == .dev ) { - NSApp.terminate(nil) + AppTerminationRequest.schedule() return true } @@ -13767,7 +13767,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Mark as confirmed so applicationShouldTerminate does not show a // second alert when NSApp.terminate re-enters the delegate callback. self?.isQuitWarningConfirmed = true - NSApp.terminate(nil) + AppTerminationRequest.schedule() } else { onCancel?() } @@ -19561,3 +19561,19 @@ extension AppDelegate { // MARK: - CmuxAppKitSupportUI seam conformance extension AppDelegate: WindowDecorating {} + +// MARK: - App termination requests + +/// The shared terminate request used by the quit shortcut path (keyboard +/// Cmd+Q routing, socket-driven `simulate_shortcut`, and the quit +/// confirmation alert reply). +@MainActor +enum AppTerminationRequest { + /// Requests app termination. `terminate` is injectable for tests; + /// production callers use the default `NSApp.terminate`. + static func schedule( + _ terminate: @escaping @MainActor () -> Void = { NSApp.terminate(nil) } + ) { + terminate() + } +} diff --git a/cmuxTests/QuitConfirmationAlertPresenterTests.swift b/cmuxTests/QuitConfirmationAlertPresenterTests.swift index 0db12c56ee17..4897e27478ac 100644 --- a/cmuxTests/QuitConfirmationAlertPresenterTests.swift +++ b/cmuxTests/QuitConfirmationAlertPresenterTests.swift @@ -258,3 +258,27 @@ private final class QuitConfirmationAlertSpy: NSAlert { return .alertSecondButtonReturn } } + +@MainActor +@Suite +struct AppTerminationRequestDispatchTests { + /// Regression for https://github.com/manaflow-ai/cmux/issues/10788: a + /// debug-socket `simulate_shortcut cmd+q` runs the quit path inside a + /// `DispatchQueue.main.sync` block (`v2MainSync`). Terminating + /// synchronously from there deadlocks: `applicationShouldTerminate` + /// returns `.terminateLater` and its deferred `@MainActor` cleanup task + /// can never start while the main queue is still inside the socket + /// command block. The terminate request must therefore leave the + /// caller's turn and fire from a later main-run-loop callout. + @Test + func scheduledTerminateFiresFromALaterRunLoopCallout_notInsideTheRequestingBlock() { + var fired = false + AppTerminationRequest.schedule { fired = true } + #expect(!fired, "terminate ran synchronously inside the requesting block; this is the issue #10788 deadlock shape") + let deadline = Date().addingTimeInterval(5) + while !fired, Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + #expect(fired, "the scheduled terminate request never fired on a later run-loop turn") + } +} From 91799653f7a9d00e9a0512238b16defc175ca9de Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:49:35 -0700 Subject: [PATCH 22/71] fix: schedule socket-driven quit onto the run loop to avoid terminateLater deadlock AppTerminationRequest.schedule now defers NSApp.terminate to a main-run-loop callout (RunLoop.main.perform in common modes) instead of calling it inside the requesting block. A simulate_shortcut cmd+q handler runs inside v2MainSync's DispatchQueue.main.sync block; terminating there left the main queue occupied while applicationShouldTerminate's .terminateLater cleanup task waited for it, hanging the app forever. Fixes #10788. --- Sources/AppDelegate.swift | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f19e80c047cc..9a3accf9e0f5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -19567,13 +19567,30 @@ extension AppDelegate: WindowDecorating {} /// The shared terminate request used by the quit shortcut path (keyboard /// Cmd+Q routing, socket-driven `simulate_shortcut`, and the quit /// confirmation alert reply). +/// +/// `NSApp.terminate` must not run while the main dispatch queue is inside a +/// caller's block. When `applicationShouldTerminate` returns +/// `.terminateLater`, AppKit spins the run loop waiting for +/// `replyToApplicationShouldTerminate`, and the deferred `@MainActor` +/// cleanup task can only start once the main queue is free again. A debug +/// socket command executes inside `v2MainSync` (`DispatchQueue.main.sync`), +/// so terminating synchronously from it deadlocked the app +/// (https://github.com/manaflow-ai/cmux/issues/10788). Scheduling the +/// terminate as a main-run-loop callout lets the handler finish its reply +/// and release the main queue first, matching how a real keyboard Cmd+Q +/// arrives (a run-loop event callout with an idle main queue). @MainActor enum AppTerminationRequest { - /// Requests app termination. `terminate` is injectable for tests; - /// production callers use the default `NSApp.terminate`. + /// Requests app termination from a later main-run-loop callout. + /// `terminate` is injectable for tests; production callers use the + /// default `NSApp.terminate`. static func schedule( _ terminate: @escaping @MainActor () -> Void = { NSApp.terminate(nil) } ) { - terminate() + RunLoop.main.perform(inModes: [.common]) { + MainActor.assumeIsolated { + terminate() + } + } } } From e4c88870e5b38752f79dd7d4b4b89eefbc872899 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:59:53 -0700 Subject: [PATCH 23/71] web: publish relay routes from fleet attach/detach reports POST /api/relay/report receives the cmux-relay Reporter's fire-and-forget {endpointId, event, relayId, ts} events, HMAC-verified with the allow-hook secret and hardened like /api/relay/allow (no-store, bounded body read, apply deadline, dedicated deadline-bounded pool, concurrency cap). An applied attach publishes the exact catalog or account-saved custom relay URL onto the endpoint's binding; discovery then serves that server-observed route ahead of client-published hints, so phones learn 'Mac X reachable via relay Y' without the Mac's post-attach republish. Reports about relays outside the catalog and the account's saved set are refused; out-of-order events are dropped by relay-side timestamp with attach winning ties. The Mac's post-attach republish stays as a gated fallback (rollout note in CmxIrohHostRuntime.initialPublicationReady) until the reporting relay build is deployed fleet-wide. --- .../CmxIrohHostRuntime.swift | 11 + web/app/api/relay/allow/route.ts | 63 +-- web/app/api/relay/report/route.ts | 141 +++++++ .../migration.sql | 13 + web/db/schema.ts | 17 + web/services/iroh/publicationPolicy.ts | 13 + web/services/iroh/repository.ts | 5 + web/services/iroh/trustBroker.ts | 54 ++- web/services/relay/allow.ts | 114 +---- web/services/relay/hookDb.ts | 142 +++++++ web/services/relay/http.ts | 62 +++ web/services/relay/report.ts | 297 ++++++++++++++ web/tests/iroh-trust-broker.test.ts | 46 +++ web/tests/relay-report-db-behavior.test.ts | 325 +++++++++++++++ web/tests/relay-report-route.test.ts | 388 ++++++++++++++++++ 15 files changed, 1532 insertions(+), 159 deletions(-) create mode 100644 web/app/api/relay/report/route.ts create mode 100644 web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql create mode 100644 web/services/relay/hookDb.ts create mode 100644 web/services/relay/report.ts create mode 100644 web/tests/relay-report-db-behavior.test.ts create mode 100644 web/tests/relay-report-route.test.ts diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 92b1cb2863d4..42346e874540 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -638,6 +638,17 @@ public actor CmxIrohHostRuntime { /// Returns whether the binding may be published immediately: the home /// relay is already usable, or this endpoint will never own a relay. + /// + /// ROLLOUT NOTE (intended-shape attach reporting): this relay-readiness + /// gate and the post-attach republish it defers exist so the Mac's own + /// registration carries its relay route. The broker now also publishes + /// the route server-side from the relay fleet's attach/detach reports + /// (`POST /api/relay/report`, cmux-relay attach reporting), and + /// discovery serves that server-observed hint ahead of client-published + /// hints. The client republish stays as the fallback ONLY while fleet + /// relays that do not report attach remain deployed; once the reporting + /// relay build is rolled out fleet-wide, delete this gate and publish at + /// register time. func initialPublicationReady( engine: CmxConnectivityEngine ) async -> Bool { diff --git a/web/app/api/relay/allow/route.ts b/web/app/api/relay/allow/route.ts index f8e8e0df2638..b8a9595f18ce 100644 --- a/web/app/api/relay/allow/route.ts +++ b/web/app/api/relay/allow/route.ts @@ -13,7 +13,7 @@ // availability through its allow cache. import { env } from "../../../env"; -import { jsonResponse } from "../../../../services/relay/http"; +import { jsonResponse, readBoundedBody } from "../../../../services/relay/http"; import { RELAY_ALLOW_SIGNATURE_HEADER, parseRelayAllowSecret, @@ -61,10 +61,10 @@ export async function handleRelayAllowRequest( const secret = parseRelayAllowSecret(deps.secretBase64()); if (!secret) return jsonResponse({ error: "relay_allow_not_configured" }, 503); - const body = await readBoundedBody( - request, - deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, - ); + const body = await readBoundedBody(request, { + maxBytes: MAX_BODY_BYTES, + timeoutMs: deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, + }); if (!body.ok) return body.response; const provided = providedSignature(request); @@ -174,59 +174,6 @@ function admissionResponse(admission: RelayAllowAdmission): Response { }); } -async function readBoundedBody( - request: Request, - timeoutMs: number, -): Promise< - | { readonly ok: true; readonly bytes: Uint8Array } - | { readonly ok: false; readonly response: Response } -> { - const contentLength = request.headers.get("content-length"); - if (contentLength) { - const parsed = Number(contentLength); - if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_BODY_BYTES) { - return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; - } - } - const reader = request.body?.getReader(); - // iroh-relay's access-mode POST carries no body at all. - if (!reader) return { ok: true, bytes: new Uint8Array() }; - const chunks: Uint8Array[] = []; - let total = 0; - let timedOut = false; - // Cancelling the reader on expiry resolves the pending read() and releases - // the underlying stream: real cancellation, not an abandoned promise. - const timer = setTimeout(() => { - timedOut = true; - void reader.cancel().catch(() => undefined); - }, timeoutMs); - try { - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > MAX_BODY_BYTES) { - await reader.cancel(); - return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; - } - chunks.push(next.value); - } - } catch { - if (!timedOut) { - return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; - } - } finally { - clearTimeout(timer); - } - if (timedOut) { - return { ok: false, response: jsonResponse({ error: "request_read_timeout" }, 408) }; - } - return { - ok: true, - bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total), - }; -} - export function POST(request: Request): Promise { return handleRelayAllowRequest(request, productionDeps); } diff --git a/web/app/api/relay/report/route.ts b/web/app/api/relay/report/route.ts new file mode 100644 index 000000000000..52a7130b12e6 --- /dev/null +++ b/web/app/api/relay/report/route.ts @@ -0,0 +1,141 @@ +// Attach/detach report sink for the self-hosted relay fleet (cmux-relay +// `Reporter`): fire-and-forget HMAC-signed POSTs of +// `{endpointId, event, relayId, ts}` on every admitted connect and every +// disconnect. Applying one publishes "endpoint X reachable via relay Y" into +// the registry, which discovery serves to the account's other devices — the +// server-side replacement for the Mac's client-side post-attach republish. +// +// Hardening mirrors /api/relay/allow: shared-secret HMAC over the raw body +// (fail closed to 503 when the secret is unset), bounded body read with a +// hard deadline, a response-latency bound on the database application, a +// dedicated deadline-bounded connection pool, a hard concurrency cap, and +// `cache-control: no-store` on every response. The relay treats any non-2xx +// as a logged warning, never a retry loop, so degraded answers are safe. + +import { env } from "../../../env"; +import { jsonResponse, readBoundedBody } from "../../../../services/relay/http"; +import { + parseRelayAllowSecret, + verifyRelayAllowSignature, +} from "../../../../services/relay/allow"; +import { + RELAY_REPORT_SIGNATURE_HEADER, + RelayReportSaturatedError, + applyRelayAttachReport, + parseRelayAttachReport, + type RelayAttachReport, + type RelayReportOutcome, +} from "../../../../services/relay/report"; + +const MAX_BODY_BYTES = 4 * 1_024; +const BODY_READ_TIMEOUT_MS = 5_000; +// Response-latency bound for the registry update, so a stalled database +// cannot hold report handlers (and their concurrency slots) until some +// external timeout. Expiry fails to 503; the next attach/detach event +// self-heals the published state. The resource bounds live in +// services/relay/report.ts (statement timeout, settle bound, dedicated pool, +// concurrency cap). +const APPLY_TIMEOUT_MS = 3_000; + +export interface RelayReportDeps { + readonly secretBase64: () => string | undefined; + readonly apply: (report: RelayAttachReport) => Promise; + readonly applyTimeoutMs?: number; + readonly bodyReadTimeoutMs?: number; + readonly now?: () => Date; +} + +const productionDeps: RelayReportDeps = { + secretBase64: () => env.CMUX_RELAY_ALLOW_HMAC_SECRET_B64, + apply: applyRelayAttachReport, +}; + +export async function handleRelayReportRequest( + request: Request, + deps: RelayReportDeps, +): Promise { + const secret = parseRelayAllowSecret(deps.secretBase64()); + if (!secret) return jsonResponse({ error: "relay_report_not_configured" }, 503); + + const body = await readBoundedBody(request, { + maxBytes: MAX_BODY_BYTES, + timeoutMs: deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, + }); + if (!body.ok) return body.response; + + // Reports always carry the signature header (the relay signs the exact + // body bytes); no bearer fallback exists on this route. + const provided = request.headers.get(RELAY_REPORT_SIGNATURE_HEADER)?.trim(); + if (!provided || !verifyRelayAllowSignature(secret, body.bytes, provided)) { + return jsonResponse({ error: "invalid_relay_report_signature" }, 401); + } + + if (body.bytes.byteLength === 0) { + return jsonResponse({ error: "missing_report_body" }, 400); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(body.bytes).toString("utf8")); + } catch { + return jsonResponse({ error: "invalid_json" }, 400); + } + const parsed = parseRelayAttachReport(value, (deps.now ?? (() => new Date()))()); + if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400); + + try { + return outcomeResponse(await applyWithDeadline(deps, parsed.report)); + } catch (error) { + if (error instanceof RelayReportSaturatedError) { + return jsonResponse({ error: "relay_report_saturated" }, 503); + } + // Never log EndpointIDs; the route and failure class are enough. + console.error("relay report application failed", { failure: "unexpected" }); + return jsonResponse({ error: "relay_report_unavailable" }, 503); + } +} + +async function applyWithDeadline( + deps: RelayReportDeps, + report: RelayAttachReport, +): Promise { + let timer: ReturnType | undefined; + const application = deps.apply(report); + // An application that settles (typically rejecting on the database-side + // statement_timeout) after losing the race must not surface as an + // unhandled rejection. + application.catch(() => undefined); + try { + return await Promise.race([ + application, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("relay_report_apply_timeout")), + deps.applyTimeoutMs ?? APPLY_TIMEOUT_MS, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function outcomeResponse(outcome: RelayReportOutcome): Response { + switch (outcome) { + case "applied": + return jsonResponse({ applied: true }); + case "superseded": + case "unknown_endpoint": + // Stale orderings and reports about endpoints that no longer have an + // active binding are normal fleet operation, not caller errors. + return jsonResponse({ applied: false, reason: outcome }); + case "untrusted_relay": + // A syntactically valid, correctly signed report about a relay outside + // the account's dialable set: refuse loudly so the relay's warn log + // shows the misconfiguration (e.g. a dev relay pointed at production). + return jsonResponse({ error: "untrusted_relay" }, 403); + } +} + +export function POST(request: Request): Promise { + return handleRelayReportRequest(request, productionDeps); +} diff --git a/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql b/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql new file mode 100644 index 000000000000..81615b7fb8a3 --- /dev/null +++ b/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "relay_attached_url" text; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "relay_attach_reported_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_relay_attached_url_check" + CHECK ("relay_attached_url" IS NULL OR ("relay_attached_url" ~ '^https://' AND length("relay_attached_url") <= 2048)); +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_relay_attach_reported_check" + CHECK ("relay_attached_url" IS NULL OR "relay_attach_reported_at" IS NOT NULL); diff --git a/web/db/schema.ts b/web/db/schema.ts index 88d3197818a3..70c747d58e4c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -982,6 +982,14 @@ export const irohEndpointBindings = pgTable( directPortV6: integer("direct_port_v6"), pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }), + // Live relay attach state, written only by HMAC-verified fleet reports + // (POST /api/relay/report). `relayAttachedUrl` is the exact catalog or + // saved-custom relay URL the endpoint is currently attached through; + // `relayAttachReportedAt` is the relay-side event timestamp of the last + // APPLIED report and orders fire-and-forget reports that arrive out of + // order. Cleared by a matching detach report and by revocation. + relayAttachedUrl: text("relay_attached_url"), + relayAttachReportedAt: timestamp("relay_attach_reported_at", { withTimezone: true }), deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), @@ -1000,6 +1008,15 @@ export const irohEndpointBindings = pgTable( check("iroh_endpoint_bindings_direct_port_v4_check", sql`${table.directPortV4} is null or ${table.directPortV4} between 1 and 65535`), check("iroh_endpoint_bindings_direct_port_v6_check", sql`${table.directPortV6} is null or ${table.directPortV6} between 1 and 65535`), check("iroh_endpoint_bindings_path_hints_check", sql`jsonb_typeof(${table.pathHints}) = 'array' and jsonb_array_length(${table.pathHints}) <= 16`), + check( + "iroh_endpoint_bindings_relay_attached_url_check", + sql`${table.relayAttachedUrl} is null or (${table.relayAttachedUrl} ~ '^https://' and length(${table.relayAttachedUrl}) <= 2048)`, + ), + // A published attach URL always carries the event timestamp that set it. + check( + "iroh_endpoint_bindings_relay_attach_reported_check", + sql`${table.relayAttachedUrl} is null or ${table.relayAttachReportedAt} is not null`, + ), uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") .on(table.endpointId) .where(sql`${table.revokedAt} is null`), diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts index e43abc27f3e4..4861a20d529c 100644 --- a/web/services/iroh/publicationPolicy.ts +++ b/web/services/iroh/publicationPolicy.ts @@ -57,6 +57,19 @@ export function accountPrivateIrohPathHints( }); } +/** + * May a server-observed relay attachment be published to the account's other + * devices? Same trust rule as endpoint-reported relay hints: only an exact + * managed-catalog URL or a relay the account has saved. Re-checked at read + * time so deleting a saved custom relay also stops serving its attach route. + */ +export function isPublishableAttachedRelayURL( + url: string, + savedCustomRelayURLs: ReadonlySet, +): boolean { + return MANAGED_RELAY_URL_SET.has(url) || savedCustomRelayURLs.has(url); +} + function plainRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 4ad49a8ba9c6..1d617409eba5 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -1298,6 +1298,8 @@ async function revokeActiveBindings( directPortV6: null, pathHints: [], pathHintsNextExpiry: null, + relayAttachedUrl: null, + relayAttachReportedAt: null, updatedAt: input.now, }) .where(and( @@ -1424,6 +1426,7 @@ async function drainIrohRetention(input: { path_hints_next_expiry is not null or direct_port_v4 is not null or direct_port_v6 is not null + or relay_attached_url is not null ) order by revoked_at, id limit ${limit} @@ -1434,6 +1437,8 @@ async function drainIrohRetention(input: { path_hints_next_expiry = null, direct_port_v4 = null, direct_port_v6 = null, + relay_attached_url = null, + relay_attach_reported_at = null, updated_at = ${nowIso}::timestamptz from candidates where binding.id = candidates.id diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 13cdc74166fc..36816d68de51 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -84,6 +84,7 @@ import { import { MANAGED_RELAY_URLS, accountPrivateIrohPathHints, + isPublishableAttachedRelayURL, } from "./publicationPolicy"; import { discoveryScopeMatchesRegistration, @@ -788,17 +789,64 @@ function publicBinding( ...(binding.directPortV6 === null ? {} : { ipv6: binding.directPortV6 }), }, }), - path_hints: accountPrivateIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => { + path_hints: bindingPathHints(binding, now, savedCustomRelayURLs), + last_seen_at: binding.lastSeenAt.toISOString(), + }; +} + +/** + * Serves the relay-reported attach route ahead of endpoint-published hints. + * + * The fleet reports attach/detach per admitted connection (cmux-relay + * attach reporting), so `relayAttachedUrl` is live server-side truth and the + * phone learns "reachable via relay Y" without the Mac's client-side + * post-attach republish. The synthesized hint reuses the standard path-hint + * shape so existing clients dial it unchanged; the client-published hint for + * the same URL is dropped as redundant, and every other client hint stays a + * fallback while pre-attach-reporting fleet relays remain deployed. + */ +function bindingPathHints( + binding: IrohBindingRecord, + now: Date, + savedCustomRelayURLs: ReadonlySet, +): IrohPathHint[] { + const clientHints = accountPrivateIrohPathHints( + binding.pathHints.flatMap((hint): IrohPathHint[] => { try { return [parseIrohPathHint(hint, now)]; } catch { return []; } - }), savedCustomRelayURLs), - last_seen_at: binding.lastSeenAt.toISOString(), + }), + savedCustomRelayURLs, + ); + const attachedURL = binding.relayAttachedUrl; + if ( + attachedURL === null || + !isPublishableAttachedRelayURL(attachedURL, savedCustomRelayURLs) + ) { + return clientHints; + } + const serverHint: IrohPathHint = { + kind: "relay_url", + value: attachedURL, + source: "native", + privacy_scope: "public_internet", + // Attachment is current as of this read; clients bound hint freshness to + // observed_at + 1h, and every discovery read re-serves the live state. + observed_at: now.toISOString(), + expires_at: new Date(now.getTime() + SERVER_RELAY_HINT_TTL_MS).toISOString(), }; + return [ + serverHint, + ...clientHints.filter((hint) => + !(hint.kind === "relay_url" && hint.value === attachedURL)), + ]; } +/** Half the model's 1h hint-lifetime cap; refreshed by every discovery read. */ +const SERVER_RELAY_HINT_TTL_MS = 30 * 60 * 1_000; + function customRelayURLs(preference: RelayPreference): ReadonlySet { return new Set(preference.customRelays.map((relay) => relay.url)); } diff --git a/web/services/relay/allow.ts b/web/services/relay/allow.ts index a5248ceea3c1..e49fbcb3e2af 100644 --- a/web/services/relay/allow.ts +++ b/web/services/relay/allow.ts @@ -6,22 +6,15 @@ // trustworthy; this side only decides whether that endpoint is admitted. // // The admission lookup deliberately does NOT borrow the shared cloudDb -// client: its pool checkout and connection phases have no deadline there, so -// a stalled operation could neither be cancelled nor be counted on to settle. -// Instead the admission path owns a dedicated client sized to its concurrency -// cap with a hard bound on every phase (connect, checkout, execution, plus a -// client-side cancel), so every admission operation settles within a known -// bound and the concurrency slots — released strictly at settlement — bound -// retained work without ever staying saturated after an outage heals. +// client: it runs on the deadline-bounded per-hook client from ./hookDb, so +// every admission operation settles within a known bound and the concurrency +// slots — released strictly at settlement — bound retained work without ever +// staying saturated after an outage heals. import { createHmac, timingSafeEqual } from "node:crypto"; -import { attachDatabasePool } from "@vercel/functions"; -import type { Pool } from "pg"; -import postgres, { type Sql } from "postgres"; -import { createAwsRdsIamPool } from "../../db/client"; -import { cloudDbConfig, cloudDbConfigKey } from "../../db/config"; import { isBlockingAccountDeletionTombstone } from "../account/deletionLock"; +import { closeRelayHookDbClientForTests, relayHookDbClient } from "./hookDb"; export const RELAY_ALLOW_SIGNATURE_HEADER = "x-cmux-relay-allow-signature"; @@ -53,10 +46,6 @@ export const RELAY_ALLOW_STATEMENT_TIMEOUT_MS = 2_500; */ export const RELAY_ALLOW_LOOKUP_SETTLE_MS = 4_000; -/** Connection-establishment (and, for pg, checkout-wait) deadline. */ -const CONNECT_TIMEOUT_MS = 5_000; -const IDLE_TIMEOUT_SECONDS = 60; - export class RelayAllowAdmissionSaturatedError extends Error { constructor() { super("relay allow admission concurrency saturated"); @@ -150,91 +139,20 @@ const ADMISSION_SQL = ` limit 1 `; -type AdmissionClientState = { - readonly key: string; - readonly lookup: (endpointId: string) => Promise; - readonly close: () => Promise; -}; - -const globalForAdmission = globalThis as typeof globalThis & { - __cmuxRelayAllowAdmission?: AdmissionClientState; -}; - -function admissionClient(): AdmissionClientState { - const config = cloudDbConfig(); - const key = cloudDbConfigKey(config); - const cached = globalForAdmission.__cmuxRelayAllowAdmission; - if (cached?.key === key) return cached; - if (cached) { - // The database config rotated within this runtime: drop the stale client - // and close it so its pool is not retained alongside the replacement. - // In-flight lookups hold their own reference and settle under their phase - // deadlines; close() (pool.end / sql.end) waits for them, so this cannot - // interrupt an admission already running. - globalForAdmission.__cmuxRelayAllowAdmission = undefined; - void cached.close().catch(() => { - // Best-effort teardown; the replacement client is unaffected. - }); - } +const ADMISSION_HOOK = "relay-allow"; - let state: AdmissionClientState; - if (config.driver === "aws-rds-iam") { - const pool: Pool = createAwsRdsIamPool(config, { - max: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, - // Bounds checkout waits as well as connection establishment. - connectionTimeoutMillis: CONNECT_TIMEOUT_MS, - idleTimeoutMillis: IDLE_TIMEOUT_SECONDS * 1_000, - statement_timeout: RELAY_ALLOW_STATEMENT_TIMEOUT_MS, - query_timeout: RELAY_ALLOW_LOOKUP_SETTLE_MS, - }); - attachDatabasePool(pool); - state = { - key, - lookup: async (endpointId) => { - const result = await pool.query(ADMISSION_SQL, [endpointId]); - return result.rows[0] ?? null; - }, - close: () => pool.end(), - }; - } else { - const sql: Sql = postgres(config.url, { - max: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, - prepare: false, - connect_timeout: Math.ceil(CONNECT_TIMEOUT_MS / 1_000), - idle_timeout: IDLE_TIMEOUT_SECONDS, - connection: { statement_timeout: RELAY_ALLOW_STATEMENT_TIMEOUT_MS }, - }); - state = { - key, - lookup: async (endpointId) => { - const query = sql.unsafe(ADMISSION_SQL, [endpointId]); - // cancel() rejects the query whether still queued or executing, so - // the operation settles even through a pool or network stall. - const settleBound = setTimeout(() => { - try { - query.cancel(); - } catch { - // Cancellation is best-effort; the statement timeout remains. - } - }, RELAY_ALLOW_LOOKUP_SETTLE_MS); - try { - const rows = await query; - return rows[0] ?? null; - } finally { - clearTimeout(settleBound); - } - }, - close: () => sql.end(), - }; - } - globalForAdmission.__cmuxRelayAllowAdmission = state; - return state; +async function admissionLookup(endpointId: string): Promise { + const client = relayHookDbClient(ADMISSION_HOOK, { + maxConnections: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, + statementTimeoutMs: RELAY_ALLOW_STATEMENT_TIMEOUT_MS, + settleMs: RELAY_ALLOW_LOOKUP_SETTLE_MS, + }); + const rows = await client.query(ADMISSION_SQL, [endpointId]); + return (rows[0] as AdmissionRow | undefined) ?? null; } export async function closeRelayAllowAdmissionClientForTests(): Promise { - const state = globalForAdmission.__cmuxRelayAllowAdmission; - globalForAdmission.__cmuxRelayAllowAdmission = undefined; - await state?.close(); + await closeRelayHookDbClientForTests(ADMISSION_HOOK); } /** @@ -247,7 +165,7 @@ export async function relayAllowAdmission( endpointId: string, ): Promise { return await withRelayAllowAdmissionSlot(async () => { - const row = await admissionClient().lookup(endpointId); + const row = await admissionLookup(endpointId); if (!row) return "deny" as const; if (tombstoneBlocks(row)) return "deny" as const; return "allow" as const; diff --git a/web/services/relay/hookDb.ts b/web/services/relay/hookDb.ts new file mode 100644 index 000000000000..ea445f265282 --- /dev/null +++ b/web/services/relay/hookDb.ts @@ -0,0 +1,142 @@ +// Dedicated deadline-bounded Postgres access for relay fleet hooks +// (/api/relay/allow, /api/relay/report). +// +// These hooks deliberately do NOT borrow the shared cloudDb client: its pool +// checkout and connection phases have no deadline there, so a stalled +// operation could neither be cancelled nor be counted on to settle. Each hook +// instead owns a client sized to its concurrency cap with a hard bound on +// every phase (connect, checkout, execution, plus a client-side cancel), so +// every hook operation settles within a known bound and the hook's +// concurrency slots — released strictly at settlement — bound retained work +// without ever staying saturated after an outage heals. +// +// Clients are cached per hook name so each hook keeps its own pool: report +// ingestion load can never queue behind (or starve) connection admissions. + +import { attachDatabasePool } from "@vercel/functions"; +import type { Pool } from "pg"; +import postgres, { type Sql } from "postgres"; + +import { createAwsRdsIamPool } from "../../db/client"; +import { cloudDbConfig, cloudDbConfigKey } from "../../db/config"; + +/** Connection-establishment (and, for pg, checkout-wait) deadline. */ +const CONNECT_TIMEOUT_MS = 5_000; +const IDLE_TIMEOUT_SECONDS = 60; + +export type RelayHookDbBounds = { + /** + * Pool size; pair it with the hook's concurrency cap so no hook operation + * ever queues inside the driver. + */ + readonly maxConnections: number; + /** + * Server-side statement_timeout, set as a session parameter on every + * connection: Postgres cancels an executing statement and frees the + * connection. + */ + readonly statementTimeoutMs: number; + /** + * Client-side settle bound. postgres.js: a timer calls query.cancel(), + * which rejects the query whether it is still queued or already executing. + * pg: the pool's query_timeout enforces the same bound. Keep it above the + * statement timeout so the server usually cancels first. + */ + readonly settleMs: number; +}; + +export type RelayHookDbClient = { + readonly query: ( + text: string, + params: readonly unknown[], + ) => Promise[]>; + readonly close: () => Promise; +}; + +type CachedClient = { + readonly configKey: string; + readonly client: RelayHookDbClient; +}; + +const globalForHooks = globalThis as typeof globalThis & { + __cmuxRelayHookDbClients?: Map; +}; + +export function relayHookDbClient( + hook: string, + bounds: RelayHookDbBounds, +): RelayHookDbClient { + const config = cloudDbConfig(); + const configKey = cloudDbConfigKey(config); + const cache = (globalForHooks.__cmuxRelayHookDbClients ??= new Map()); + const cached = cache.get(hook); + if (cached?.configKey === configKey) return cached.client; + if (cached) { + // The database config rotated within this runtime: drop the stale client + // and close it so its pool is not retained alongside the replacement. + // In-flight operations hold their own reference and settle under their + // phase deadlines; close() (pool.end / sql.end) waits for them, so this + // cannot interrupt an operation already running. + cache.delete(hook); + void cached.client.close().catch(() => { + // Best-effort teardown; the replacement client is unaffected. + }); + } + + let client: RelayHookDbClient; + if (config.driver === "aws-rds-iam") { + const pool: Pool = createAwsRdsIamPool(config, { + max: bounds.maxConnections, + // Bounds checkout waits as well as connection establishment. + connectionTimeoutMillis: CONNECT_TIMEOUT_MS, + idleTimeoutMillis: IDLE_TIMEOUT_SECONDS * 1_000, + statement_timeout: bounds.statementTimeoutMs, + query_timeout: bounds.settleMs, + }); + attachDatabasePool(pool); + client = { + query: async (text, params) => { + const result = await pool.query(text, params as unknown[]); + return result.rows as readonly Record[]; + }, + close: () => pool.end(), + }; + } else { + const sql: Sql = postgres(config.url, { + max: bounds.maxConnections, + prepare: false, + connect_timeout: Math.ceil(CONNECT_TIMEOUT_MS / 1_000), + idle_timeout: IDLE_TIMEOUT_SECONDS, + connection: { statement_timeout: bounds.statementTimeoutMs }, + }); + client = { + query: async (text, params) => { + const query = sql.unsafe(text, params as never[]); + // cancel() rejects the query whether still queued or executing, so + // the operation settles even through a pool or network stall. + const settleBound = setTimeout(() => { + try { + query.cancel(); + } catch { + // Cancellation is best-effort; the statement timeout remains. + } + }, bounds.settleMs); + try { + return (await query) as unknown as readonly Record[]; + } finally { + clearTimeout(settleBound); + } + }, + close: () => sql.end(), + }; + } + cache.set(hook, { configKey, client }); + return client; +} + +export async function closeRelayHookDbClientForTests(hook: string): Promise { + const cache = globalForHooks.__cmuxRelayHookDbClients; + const cached = cache?.get(hook); + cache?.delete(hook); + await cached?.client.close(); +} diff --git a/web/services/relay/http.ts b/web/services/relay/http.ts index fe8121d62d1d..00284b551b6f 100644 --- a/web/services/relay/http.ts +++ b/web/services/relay/http.ts @@ -175,3 +175,65 @@ export function jsonResponse( }, }); } + +export type BoundedBodyResult = + | { readonly ok: true; readonly bytes: Uint8Array } + | { readonly ok: false; readonly response: Response }; + +/** + * Reads a request body under a hard byte cap and a hard read deadline, for + * unauthenticated fleet-hook routes (/api/relay/allow, /api/relay/report). + * The byte cap alone does not stop a slowloris client that trickles (or + * never finishes) a body to occupy the handler; the deadline cancels the + * request stream itself on expiry — real cancellation, not an abandoned + * promise. A missing body resolves to zero bytes, so callers that require a + * body decide their own failure mode. + */ +export async function readBoundedBody( + request: Request, + options: { readonly maxBytes: number; readonly timeoutMs: number }, +): Promise { + const contentLength = request.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > options.maxBytes) { + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + } + const reader = request.body?.getReader(); + if (!reader) return { ok: true, bytes: new Uint8Array() }; + const chunks: Uint8Array[] = []; + let total = 0; + let timedOut = false; + // Cancelling the reader on expiry resolves the pending read() and releases + // the underlying stream. + const timer = setTimeout(() => { + timedOut = true; + void reader.cancel().catch(() => undefined); + }, options.timeoutMs); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > options.maxBytes) { + await reader.cancel(); + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + chunks.push(next.value); + } + } catch { + if (!timedOut) { + return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; + } + } finally { + clearTimeout(timer); + } + if (timedOut) { + return { ok: false, response: jsonResponse({ error: "request_read_timeout" }, 408) }; + } + return { + ok: true, + bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total), + }; +} diff --git a/web/services/relay/report.ts b/web/services/relay/report.ts new file mode 100644 index 000000000000..4171e1bdff6c --- /dev/null +++ b/web/services/relay/report.ts @@ -0,0 +1,297 @@ +// Backend half of the relay fleet's attach/detach reporting (cmux-relay +// `Reporter`, manaflow-ai/cmux-relay PR #9). On every admitted connect the +// relay fire-and-forgets `POST CMUX_RELAY_REPORT_URL` with the JSON body +// `{endpointId, event: "attach"|"detach", relayId, ts}`, signed exactly like +// allow-hook calls: unpadded base64url HMAC-SHA256 over the raw body bytes in +// the same `x-cmux-relay-allow-signature` header, same shared secret +// (CMUX_RELAY_ALLOW_HMAC_SECRET_B64). `relayId` is the relay's public +// hostname (its `--hostname` flag, e.g. `usc1.relay.cmux.dev`); `ts` is the +// relay-side event time in unix milliseconds. +// +// Applying a report publishes "endpoint X is reachable via relay Y" into the +// registry (`iroh_endpoint_bindings.relay_attached_url`), which discovery +// serves to the account's other devices as a server-observed relay hint — +// replacing the Mac's client-side post-attach republish. +// +// Trust decision: the HMAC proves a fleet relay (holder of the shared +// secret) sent the report, but `relayId` inside the body is self-declared, +// so a single compromised relay — or a leaked secret — could otherwise +// publish an attacker-controlled relay URL to every phone on any account. +// A report is therefore only applied when its hostname resolves to a relay +// the account is already allowed to dial: an exact managed-catalog URL, or a +// custom relay the account has saved (matched by hostname, publishing the +// saved URL verbatim). Everything else is rejected (`untrusted_relay`), +// which also keeps dev relays (`cmux-relay-dev`) out of production state. +// Debug/test fleets get trusted the same way: their relay must be in the +// catalog the deployment was built with, or saved as that account's custom +// relay; the HMAC alone is deliberately NOT sufficient. +// +// Ordering: reports are fire-and-forget and can arrive out of order, so each +// applied report records its relay-side event timestamp and older events are +// dropped (`superseded`). Attach wins a timestamp tie (make-before-break +// reconnects admit the new connection before the old one closes). Known +// residual: the report body carries no connection id, so a same-relay +// reconnect whose old-connection detach lands after the new-connection +// attach (with a later relay-side ts) unpublishes the route until the next +// attach event; the client republish fallback covers that window, and fixing +// it properly needs a connection id in the relay's report body. +// +// Unlike /api/relay/allow this path does not consult account-deletion +// tombstones: deletion revokes bindings (which hides them from discovery and +// denies relay admission), so attach state on a to-be-deleted account is +// unreachable either way. + +import { RELAY_ALLOW_SIGNATURE_HEADER } from "./allow"; +import { MANAGED_IROH_RELAY_CATALOG } from "./generated/managedRelayCatalog"; +import { closeRelayHookDbClientForTests, relayHookDbClient } from "./hookDb"; + +/** Same header, same signing scheme, same secret as the allow hook. */ +export const RELAY_REPORT_SIGNATURE_HEADER = RELAY_ALLOW_SIGNATURE_HEADER; + +/** + * Hard cap on concurrently running report applications per runtime instance, + * and the size of the dedicated report pool (separate from the admission + * pool, so report bursts can never starve connection admissions). A + * saturated instance answers 503; the relay treats any failure as + * best-effort and the next attach/detach event self-heals the state. + */ +export const RELAY_REPORT_MAX_CONCURRENT = 16; + +export const RELAY_REPORT_STATEMENT_TIMEOUT_MS = 2_500; +export const RELAY_REPORT_SETTLE_MS = 4_000; + +/** + * Reject event timestamps this far ahead of server time. Without the bound a + * relay with a runaway clock would plant a far-future `relay_attach_reported_at` + * that blocks every later (correctly timed) report for the endpoint. + */ +export const RELAY_REPORT_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +// RFC 1123 hostname labels, lowercase; total length bounded below. +const RELAY_HOSTNAME_RE = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; +const MAX_RELAY_HOSTNAME_LENGTH = 253; + +export class RelayReportSaturatedError extends Error { + constructor() { + super("relay report concurrency saturated"); + this.name = "RelayReportSaturatedError"; + } +} + +let inFlightReports = 0; + +/** Runs one report application under the concurrency cap; rejects when saturated. */ +export async function withRelayReportSlot( + operation: () => Promise, +): Promise { + if (inFlightReports >= RELAY_REPORT_MAX_CONCURRENT) { + throw new RelayReportSaturatedError(); + } + inFlightReports += 1; + try { + return await operation(); + } finally { + inFlightReports -= 1; + } +} + +export type RelayAttachReport = { + readonly endpointId: string; + readonly event: "attach" | "detach"; + readonly relayId: string; + /** Relay-side event time (body `ts`, unix milliseconds). */ + readonly reportedAt: Date; +}; + +export type RelayReportParseError = + | "invalid_report_body" + | "invalid_endpoint_id" + | "invalid_report_event" + | "invalid_relay_id" + | "invalid_report_time"; + +export type RelayReportParseResult = + | { readonly ok: true; readonly report: RelayAttachReport } + | { readonly ok: false; readonly error: RelayReportParseError }; + +export function parseRelayAttachReport( + value: unknown, + now: Date, +): RelayReportParseResult { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: "invalid_report_body" }; + } + const record = value as Record; + const allowed = new Set(["endpointId", "event", "relayId", "ts"]); + if (Object.keys(record).some((key) => !allowed.has(key))) { + return { ok: false, error: "invalid_report_body" }; + } + + const endpointId = typeof record.endpointId === "string" + ? record.endpointId.trim().toLowerCase() + : ""; + if (!ENDPOINT_ID_RE.test(endpointId)) { + return { ok: false, error: "invalid_endpoint_id" }; + } + + const event = record.event; + if (event !== "attach" && event !== "detach") { + return { ok: false, error: "invalid_report_event" }; + } + + const relayId = typeof record.relayId === "string" + ? record.relayId.trim().toLowerCase() + : ""; + if ( + relayId.length === 0 || + relayId.length > MAX_RELAY_HOSTNAME_LENGTH || + !RELAY_HOSTNAME_RE.test(relayId) + ) { + return { ok: false, error: "invalid_relay_id" }; + } + + const ts = record.ts; + if ( + typeof ts !== "number" || + !Number.isSafeInteger(ts) || + ts <= 0 || + ts > now.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS + ) { + return { ok: false, error: "invalid_report_time" }; + } + + return { + ok: true, + report: { endpointId, event, relayId, reportedAt: new Date(ts) }, + }; +} + +/** + * Resolves a reported relay hostname to the exact URL the registry may + * publish: the managed catalog first, then the account's saved custom relays + * (their saved URL verbatim, so ports survive). Returns null for hostnames + * the account is not allowed to dial. + */ +export function publishableRelayURLForHostname( + relayId: string, + savedCustomRelayURLs: readonly string[], +): string | null { + for (const relay of MANAGED_IROH_RELAY_CATALOG.relays) { + if (urlHostname(relay.url) === relayId) return relay.url; + } + // Deterministic pick if several saved relays share one hostname. + for (const saved of [...savedCustomRelayURLs].sort()) { + if (urlHostname(saved) === relayId) return saved; + } + return null; +} + +function urlHostname(value: string): string | null { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +} + +export type RelayReportOutcome = + | "applied" + | "superseded" + | "unknown_endpoint" + | "untrusted_relay"; + +// The active binding for the endpoint plus that account's saved custom +// relays (preferences are keyed by the same Stack user id bindings carry). +// The partial unique index `iroh_endpoint_bindings_active_endpoint_unique` +// guarantees at most one row. +const REPORT_CONTEXT_SQL = ` + select + binding.user_id as "userId", + pref.custom_relays as "customRelays" + from iroh_endpoint_bindings binding + left join iroh_relay_preferences pref + on pref.account_id = binding.user_id + where binding.endpoint_id = $1 + and binding.revoked_at is null + limit 1 +`; + +// Ordering guards: an attach applies unless a strictly newer event was +// already applied (attach wins ties); a detach applies only against the +// exact URL it detached from and only when strictly newer, so a late detach +// from an old relay can never clear a newer attachment elsewhere. +const APPLY_ATTACH_SQL = ` + update iroh_endpoint_bindings + set relay_attached_url = $2, + relay_attach_reported_at = $3::timestamptz + where endpoint_id = $1 + and revoked_at is null + and (relay_attach_reported_at is null or relay_attach_reported_at <= $3::timestamptz) + returning id +`; + +const APPLY_DETACH_SQL = ` + update iroh_endpoint_bindings + set relay_attached_url = null, + relay_attach_reported_at = $3::timestamptz + where endpoint_id = $1 + and revoked_at is null + and relay_attached_url = $2 + and relay_attach_reported_at < $3::timestamptz + returning id +`; + +const REPORT_HOOK = "relay-report"; + +function reportClient() { + return relayHookDbClient(REPORT_HOOK, { + maxConnections: RELAY_REPORT_MAX_CONCURRENT, + statementTimeoutMs: RELAY_REPORT_STATEMENT_TIMEOUT_MS, + settleMs: RELAY_REPORT_SETTLE_MS, + }); +} + +function savedCustomRelayURLs(customRelays: unknown): string[] { + if (!Array.isArray(customRelays)) return []; + const urls: string[] = []; + for (const relay of customRelays) { + if (relay !== null && typeof relay === "object" && !Array.isArray(relay)) { + const url = (relay as Record).url; + if (typeof url === "string") urls.push(url); + } + } + return urls; +} + +/** + * Applies one verified report to the registry. Both statements run on the + * dedicated deadline-bounded report client under the concurrency cap. + */ +export async function applyRelayAttachReport( + report: RelayAttachReport, +): Promise { + return await withRelayReportSlot(async () => { + const client = reportClient(); + const context = (await client.query(REPORT_CONTEXT_SQL, [report.endpointId]))[0]; + if (!context) return "unknown_endpoint" as const; + const url = publishableRelayURLForHostname( + report.relayId, + savedCustomRelayURLs(context.customRelays), + ); + if (url === null) return "untrusted_relay" as const; + const applied = await client.query( + report.event === "attach" ? APPLY_ATTACH_SQL : APPLY_DETACH_SQL, + [report.endpointId, url, report.reportedAt.toISOString()], + ); + // Zero rows: an equal-or-newer event already holds the slot, the detach + // target URL no longer matches, or the binding was revoked between the + // two statements. All are stale-report shapes, not failures. + return applied.length > 0 ? ("applied" as const) : ("superseded" as const); + }); +} + +export async function closeRelayReportClientForTests(): Promise { + await closeRelayHookDbClientForTests(REPORT_HOOK); +} diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 8938329fbcb2..2530d916bae2 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -1337,6 +1337,50 @@ describe("Iroh discovery and grants", () => { expect(discovered.bindings[0]?.path_hints).toEqual([]); }); + test("serves the fleet-reported attach route ahead of client-published hints", async () => { + const attachedURL = MANAGED_RELAY_URLS[0]!; + const otherManagedURL = MANAGED_RELAY_URLS[1]!; + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + // The client republish also advertised the same relay plus another one. + pathHints: [relayHint(attachedURL), relayHint(otherManagedURL)], + relayAttachedUrl: attachedURL, + relayAttachReportedAt: new Date(NOW.getTime() - 60_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: Array> }>; + }; + const hints = discovered.bindings[0]!.path_hints; + // Server-observed hint first, the duplicate client hint dropped, the + // remaining client hint kept as fallback. + expect(hints.map((hint) => hint.value)).toEqual([attachedURL, otherManagedURL]); + expect(hints[0]).toMatchObject({ + kind: "relay_url", + source: "native", + privacy_scope: "public_internet", + observed_at: NOW.toISOString(), + }); + expect(new Date(String(hints[0]!.expires_at)).getTime()) + .toBeGreaterThan(NOW.getTime()); + }); + + test("withholds a fleet-reported custom relay the account no longer saves", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: "https://relay.example.net/", + relayAttachReportedAt: new Date(NOW.getTime() - 60_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + test("does not combine a pre-revocation binding with a post-revocation LAN generation", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A }); @@ -2556,6 +2600,8 @@ function binding(overrides: Partial = {}): MutableBinding { directPortV6: null, pathHints: [], pathHintsNextExpiry: null, + relayAttachedUrl: null, + relayAttachReportedAt: null, deviceLimitOverrideUsed: false, lastSeenAt: now, registeredAt: now, diff --git a/web/tests/relay-report-db-behavior.test.ts b/web/tests/relay-report-db-behavior.test.ts new file mode 100644 index 000000000000..04bab7e6bfbf --- /dev/null +++ b/web/tests/relay-report-db-behavior.test.ts @@ -0,0 +1,325 @@ +// Database-backed tests of the relay attach-report registry behavior behind +// POST /api/relay/report: the route tests fake the application, so the +// ordering guards, the custom-relay trust join, revocation, and the +// discovery read that serves the attach-derived hint to a phone are proven +// here against Postgres. Gated like tests/iroh-db-behavior.test.ts. + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import * as Effect from "effect/Effect"; +import postgres, { type Sql } from "postgres"; + +import { closeCloudDbForTests } from "../db/client"; +import type { IrohTrustBrokerConfigShape } from "../services/iroh/config"; +import { IrohRelayMintError } from "../services/iroh/errors"; +import type { IrohPathHint } from "../services/iroh/model"; +import { + IrohRepository, + IrohRepositoryLive, + type IrohRepositoryShape, +} from "../services/iroh/repository"; +import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; +import { + applyRelayAttachReport, + closeRelayReportClientForTests, + type RelayAttachReport, +} from "../services/relay/report"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; + +const USER_ID = "user-report"; +const ENDPOINT_ID = "ab".repeat(32); +const MANAGED_HOSTNAME = "usc1.relay.cmux.dev"; +const MANAGED_URL = "https://usc1.relay.cmux.dev/"; +const OTHER_MANAGED_HOSTNAME = "euw4.relay.cmux.dev"; +const CUSTOM_HOSTNAME = "relay.corp.example"; +const CUSTOM_URL = "https://relay.corp.example:8443/"; +const T0 = 1_756_100_000_000; + +let sql: Sql | null = null; +let repository: IrohRepositoryShape | null = null; + +function requiredSql(): Sql { + if (!sql) throw new Error("sql not initialized"); + return sql; +} + +beforeAll(async () => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + sql = postgres(databaseURL, { max: 4 }); + repository = await Effect.runPromise( + Effect.gen(function* () { return yield* IrohRepository; }).pipe( + Effect.provide(IrohRepositoryLive), + ), + ); +}); + +beforeEach(async () => { + if (!sql) return; + await sql` + truncate + iroh_relay_token_issuances, + iroh_pair_grant_issuances, + iroh_registration_challenges, + iroh_endpoint_bindings, + iroh_relay_preferences, + account_deletion_tombstones + restart identity cascade + `; +}); + +afterAll(async () => { + await closeRelayReportClientForTests(); + await closeCloudDbForTests(); + await sql?.end(); +}); + +async function insertBinding(input: { + readonly userId?: string; + readonly endpointId?: string; + readonly revokedAt?: Date; +} = {}): Promise { + await requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, + identity_generation, pairing_enabled, revoked_at, revoked_reason + ) values ( + ${input.userId ?? USER_ID}, ${randomUUID()}, ${randomUUID()}, 'stable', + 'mac', ${input.endpointId ?? ENDPOINT_ID}, 1, true, + ${input.revokedAt ?? null}, + ${input.revokedAt ? "user_requested" : null} + ) + `; +} + +async function saveCustomRelay(userId: string, url: string): Promise { + const sql = requiredSql(); + await sql` + insert into iroh_relay_preferences (account_id, mode, selected_managed_relay_ids, custom_relays) + values ( + ${userId}, 'custom', '[]'::jsonb, + ${sql.json([{ + id: "corp1", + provider: "corp", + region: "on-prem", + url, + authMode: "none", + }])} + ) + `; +} + +function report(overrides: Partial = {}): RelayAttachReport { + return { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: new Date(T0), + ...overrides, + }; +} + +async function attachState(): Promise<{ url: string | null; reportedAt: Date | null }> { + const [row] = await requiredSql()>` + select relay_attached_url as url, relay_attach_reported_at as "reportedAt" + from iroh_endpoint_bindings + where endpoint_id = ${ENDPOINT_ID} + `; + if (!row) throw new Error("binding row missing"); + return row; +} + +describe("relay attach report registry behavior", () => { + dbTest("publishes a managed relay attachment for an active binding", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report())).toBe("applied"); + expect(await attachState()).toEqual({ + url: MANAGED_URL, + reportedAt: new Date(T0), + }); + }); + + dbTest("a matching detach clears the published route", async () => { + await insertBinding(); + await applyRelayAttachReport(report()); + expect(await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 1_000), + }))).toBe("applied"); + expect(await attachState()).toEqual({ + url: null, + reportedAt: new Date(T0 + 1_000), + }); + }); + + dbTest("drops an out-of-order older attach after a newer detach", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ reportedAt: new Date(T0) })); + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 2_000), + })); + expect(await applyRelayAttachReport(report({ + reportedAt: new Date(T0 + 1_000), + }))).toBe("superseded"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("an attach that ties a detach timestamp wins (make-before-break)", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0), + })); + expect(await applyRelayAttachReport(report({ + reportedAt: new Date(T0), + }))).toBe("applied"); + expect((await attachState()).url).toBe(MANAGED_URL); + }); + + dbTest("a late detach from an old relay cannot clear a newer attachment", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ + relayId: OTHER_MANAGED_HOSTNAME, + reportedAt: new Date(T0), + })); + await applyRelayAttachReport(report({ reportedAt: new Date(T0 + 5_000) })); + expect(await applyRelayAttachReport(report({ + event: "detach", + relayId: OTHER_MANAGED_HOSTNAME, + reportedAt: new Date(T0 + 6_000), + }))).toBe("superseded"); + expect(await attachState()).toEqual({ + url: MANAGED_URL, + reportedAt: new Date(T0 + 5_000), + }); + }); + + dbTest("ignores reports about unknown endpoints", async () => { + expect(await applyRelayAttachReport(report())).toBe("unknown_endpoint"); + }); + + dbTest("ignores reports about revoked bindings", async () => { + await insertBinding({ revokedAt: new Date(T0) }); + expect(await applyRelayAttachReport(report())).toBe("unknown_endpoint"); + }); + + dbTest("refuses a relay outside the catalog and the account's saved set", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("untrusted_relay"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("publishes the saved custom relay URL verbatim for its hostname", async () => { + await insertBinding(); + await saveCustomRelay(USER_ID, CUSTOM_URL); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("applied"); + expect((await attachState()).url).toBe(CUSTOM_URL); + }); + + dbTest("another account's saved custom relay grants no trust", async () => { + await insertBinding(); + await saveCustomRelay("user-other", CUSTOM_URL); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("untrusted_relay"); + }); + + dbTest("revocation clears published attach state", async () => { + await insertBinding(); + await applyRelayAttachReport(report()); + const [binding] = await requiredSql()>` + select id from iroh_endpoint_bindings where endpoint_id = ${ENDPOINT_ID} + `; + if (!repository || !binding) throw new Error("repository not initialized"); + await Effect.runPromise(repository.revokeBinding({ + userId: USER_ID, + bindingId: binding.id, + now: new Date(T0 + 1_000), + })); + const [row] = await requiredSql()>` + select relay_attached_url as url from iroh_endpoint_bindings + where id = ${binding.id} + `; + expect(row?.url ?? null).toBeNull(); + }); +}); + +describe("phone discovery serves the attach-derived relay route", () => { + dbTest("a discover after a simulated attach report carries the relay hint", async () => { + if (!repository) throw new Error("repository not initialized"); + await insertBinding(); + await applyRelayAttachReport(report()); + + const broker = makeIrohTrustBroker(repository, failingMinter(), brokerConfig()); + const discovery = await Effect.runPromise( + broker.discover(USER_ID, new Date(T0 + 10_000)), + ) as { + bindings: ReadonlyArray<{ endpoint_id: string; path_hints: IrohPathHint[] }>; + }; + + const mac = discovery.bindings.find((entry) => entry.endpoint_id === ENDPOINT_ID); + expect(mac).toBeDefined(); + const relayHints = (mac?.path_hints ?? []).filter((hint) => hint.kind === "relay_url"); + expect(relayHints.map((hint) => hint.value)).toEqual([MANAGED_URL]); + // The synthesized hint is dialable under the standard client rules. + expect(relayHints[0]?.source).toBe("native"); + expect(relayHints[0]?.privacy_scope).toBe("public_internet"); + expect(new Date(relayHints[0]?.expires_at ?? 0).getTime()) + .toBeGreaterThan(T0 + 10_000); + + // After a detach report the same fetch no longer advertises the relay. + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 20_000), + })); + const after = await Effect.runPromise( + broker.discover(USER_ID, new Date(T0 + 30_000)), + ) as { + bindings: ReadonlyArray<{ endpoint_id: string; path_hints: IrohPathHint[] }>; + }; + const macAfter = after.bindings.find((entry) => entry.endpoint_id === ENDPOINT_ID); + expect((macAfter?.path_hints ?? []).filter((hint) => hint.kind === "relay_url")) + .toEqual([]); + }); +}); + +/** Discovery never mints; fail loudly if it tries. */ +function failingMinter() { + return { + mint: () => Effect.fail(new IrohRelayMintError({ code: "test_failure" })), + }; +} + +function brokerConfig(): IrohTrustBrokerConfigShape { + const grantKeys = generateKeyPairSync("ed25519"); + return { + lanDiscoverySecretBase64: Buffer.alloc(32, 7).toString("base64"), + accountSubjectSecretBase64: Buffer.alloc(32, 8).toString("base64"), + grantSigningPrivateKeyPem: grantKeys.privateKey + .export({ format: "pem", type: "pkcs8" }) + .toString(), + grantSigningKid: "current", + grantVerificationKeysJson: JSON.stringify({ + version: 1, + current_kid: "current", + keys: [{ + kid: "current", + alg: "EdDSA", + spki_der_base64: grantKeys.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64"), + }], + }), + relayMinterInsecureLoopbackOptIn: false, + deploymentEnvironment: "test", + isVercelDeployment: false, + }; +} diff --git a/web/tests/relay-report-route.test.ts b/web/tests/relay-report-route.test.ts new file mode 100644 index 000000000000..3effa5b49b33 --- /dev/null +++ b/web/tests/relay-report-route.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; + +import { + handleRelayReportRequest, + type RelayReportDeps, +} from "../app/api/relay/report/route"; +import { relayAllowSignature } from "../services/relay/allow"; +import { + RELAY_REPORT_MAX_CONCURRENT, + RELAY_REPORT_MAX_FUTURE_SKEW_MS, + RELAY_REPORT_SIGNATURE_HEADER, + RelayReportSaturatedError, + parseRelayAttachReport, + publishableRelayURLForHostname, + withRelayReportSlot, + type RelayAttachReport, +} from "../services/relay/report"; + +// Pure route tests: deps injection only, nothing leaks into the shared +// bun-test module registry, no database. +const SECRET = randomBytes(32); +const SECRET_B64 = SECRET.toString("base64"); +const ENDPOINT_ID = "0123456789abcdef".repeat(4); +const NOW = new Date("2026-08-25T12:00:00.000Z"); +const MANAGED_HOSTNAME = "usc1.relay.cmux.dev"; +const MANAGED_URL = "https://usc1.relay.cmux.dev/"; + +function deps(overrides: Partial = {}): RelayReportDeps { + return { + secretBase64: () => SECRET_B64, + apply: async () => "applied", + now: () => NOW, + ...overrides, + }; +} + +function reportBody(overrides: Record = {}): Record { + return { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + ts: NOW.getTime(), + ...overrides, + }; +} + +/** The exact shape the cmux-relay Reporter sends: JSON body, signature header. */ +function signedRequest(body: unknown, signature?: string): Request { + const text = JSON.stringify(body); + return new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + "content-type": "application/json", + [RELAY_REPORT_SIGNATURE_HEADER]: + signature ?? relayAllowSignature(SECRET, Buffer.from(text, "utf8")), + }, + body: text, + }); +} + +describe("POST /api/relay/report", () => { + test("applies a signed attach report, uncacheable", async () => { + const observed: RelayAttachReport[] = []; + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: async (report) => { + observed.push(report); + return "applied"; + }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ applied: true }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(observed).toEqual([{ + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: NOW, + }]); + }); + + test("applies a signed detach report", async () => { + const observed: RelayAttachReport[] = []; + const response = await handleRelayReportRequest( + signedRequest(reportBody({ event: "detach" })), + deps({ + apply: async (report) => { + observed.push(report); + return "applied"; + }, + }), + ); + expect(response.status).toBe(200); + expect(observed[0]?.event).toBe("detach"); + }); + + test("rejects a missing signature without touching the registry", async () => { + let applications = 0; + const text = JSON.stringify(reportBody()); + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { "content-type": "application/json" }, + body: text, + }), + deps({ + apply: async () => { + applications += 1; + return "applied"; + }, + }), + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "invalid_relay_report_signature" }); + expect(applications).toBe(0); + }); + + test("rejects a signature over different body bytes", async () => { + const response = await handleRelayReportRequest( + signedRequest( + reportBody(), + relayAllowSignature(SECRET, Buffer.from("{}", "utf8")), + ), + deps(), + ); + expect(response.status).toBe(401); + }); + + test("rejects a signature minted with the wrong secret", async () => { + const text = JSON.stringify(reportBody()); + const response = await handleRelayReportRequest( + signedRequest( + reportBody(), + relayAllowSignature(randomBytes(32), Buffer.from(text, "utf8")), + ), + deps(), + ); + expect(response.status).toBe(401); + }); + + test("answers 503 when the shared secret is not configured", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ secretBase64: () => undefined }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_not_configured" }); + }); + + test("rejects a signed empty body", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + }), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "missing_report_body" }); + }); + + test("rejects signed malformed JSON", async () => { + const text = "{not json"; + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, Buffer.from(text, "utf8")), + }, + body: text, + }), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_json" }); + }); + + test("rejects malformed reports with the specific failure code", async () => { + const cases: ReadonlyArray = [ + [reportBody({ endpointId: "not-hex" }), "invalid_endpoint_id"], + [reportBody({ endpointId: ENDPOINT_ID.slice(1) }), "invalid_endpoint_id"], + [reportBody({ event: "connected" }), "invalid_report_event"], + [reportBody({ relayId: "" }), "invalid_relay_id"], + [reportBody({ relayId: "bad_host!" }), "invalid_relay_id"], + [reportBody({ relayId: `${"a".repeat(64)}.example` }), "invalid_relay_id"], + [reportBody({ ts: "123" }), "invalid_report_time"], + [reportBody({ ts: 0 }), "invalid_report_time"], + [reportBody({ ts: 1.5 }), "invalid_report_time"], + [reportBody({ extra: true }), "invalid_report_body"], + [[reportBody()], "invalid_report_body"], + ]; + for (const [body, error] of cases) { + const response = await handleRelayReportRequest(signedRequest(body), deps()); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error }); + } + }); + + test("rejects an event timestamp too far in the future", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody({ + ts: NOW.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS + 1, + })), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_report_time" }); + }); + + test("rejects an oversized declared body without reading it", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + "content-length": String(1024 * 1024), + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + body: new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024)); + }, + }), + }), + deps(), + ); + expect(response.status).toBe(413); + }); + + test("rejects an oversized streamed body at the byte cap", async () => { + const chunk = new Uint8Array(1024); + let sent = 0; + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + body: new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 32) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }), + }), + deps(), + ); + expect(response.status).toBe(413); + }); + + test("times out a trickled body instead of waiting forever", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + // A stream that never produces data and never closes. + body: new ReadableStream({ pull: () => new Promise(() => {}) }), + }), + deps({ bodyReadTimeoutMs: 25 }), + ); + expect(response.status).toBe(408); + }); + + test("answers 403 for a trusted-signature report about an untrusted relay", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ apply: async () => "untrusted_relay" }), + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "untrusted_relay" }); + }); + + test("answers applied:false for stale or unknown reports without failing the relay", async () => { + for (const outcome of ["superseded", "unknown_endpoint"] as const) { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ apply: async () => outcome }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ applied: false, reason: outcome }); + } + }); + + test("bounds application latency and fails to 503 on expiry", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: () => new Promise(() => {}), + applyTimeoutMs: 25, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_unavailable" }); + }); + + test("answers 503 when report concurrency is saturated", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: async () => { + throw new RelayReportSaturatedError(); + }, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_saturated" }); + }); +}); + +describe("relay report concurrency slots", () => { + test("rejects work past the cap and recovers as slots settle", async () => { + const releases: Array<() => void> = []; + const held = Array.from( + { length: RELAY_REPORT_MAX_CONCURRENT }, + () => withRelayReportSlot( + () => new Promise((resolve) => releases.push(resolve)), + ), + ); + await Promise.resolve(); + await expect(withRelayReportSlot(async () => "over")).rejects.toThrow( + RelayReportSaturatedError, + ); + for (const release of releases) release(); + await Promise.all(held); + expect(await withRelayReportSlot(async () => "recovered")).toBe("recovered"); + }); +}); + +describe("relay report parsing and trust mapping", () => { + test("normalizes case and preserves millisecond timestamps", () => { + const parsed = parseRelayAttachReport({ + endpointId: ENDPOINT_ID.toUpperCase(), + event: "attach", + relayId: MANAGED_HOSTNAME.toUpperCase(), + ts: 1_756_100_000_123, + }, new Date(1_756_100_000_500)); + expect(parsed).toEqual({ + ok: true, + report: { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: new Date(1_756_100_000_123), + }, + }); + }); + + test("maps a managed hostname to its exact catalog URL", () => { + expect(publishableRelayURLForHostname(MANAGED_HOSTNAME, [])).toBe(MANAGED_URL); + }); + + test("maps a saved custom hostname to the saved URL verbatim", () => { + expect(publishableRelayURLForHostname( + "relay.corp.example", + ["https://relay.corp.example:8443/"], + )).toBe("https://relay.corp.example:8443/"); + }); + + test("refuses hostnames outside the catalog and the saved set", () => { + expect(publishableRelayURLForHostname("cmux-relay-dev", [])).toBeNull(); + expect(publishableRelayURLForHostname( + "evil.example", + ["https://relay.corp.example:8443/"], + )).toBeNull(); + }); + + test("the catalog wins over a saved custom relay with the same hostname", () => { + expect(publishableRelayURLForHostname( + MANAGED_HOSTNAME, + [`https://${MANAGED_HOSTNAME}:8443/`], + )).toBe(MANAGED_URL); + }); +}); From e37b144e829644bd62c3b32c65d4fe20e350c38a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:45 -0700 Subject: [PATCH 24/71] review: age out uncorroborated attach routes; let deleted custom relays detach P1: a relay that dies with its fire-and-forget detach report used to leave relay_attached_url served as fresh forever. Discovery now serves the attach route only while some live evidence is under an hour old: the attach report itself or the binding's lastSeenAt (a live Mac re-registers at least hourly; a Mac that outlives its relay reattaches elsewhere). P2: the trust lookup now gates only attach. A detach clears the stored attachment matched by hostname, so a custom relay deleted from preferences still detaches cleanly instead of leaving a stale route that would resurface if the relay were saved again. --- web/services/iroh/trustBroker.ts | 24 +++++++++- web/services/relay/report.ts | 52 +++++++++++++++++----- web/tests/iroh-trust-broker.test.ts | 37 +++++++++++++++ web/tests/relay-report-db-behavior.test.ts | 21 +++++++++ 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 36816d68de51..3dabd2614788 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -823,7 +823,8 @@ function bindingPathHints( const attachedURL = binding.relayAttachedUrl; if ( attachedURL === null || - !isPublishableAttachedRelayURL(attachedURL, savedCustomRelayURLs) + !isPublishableAttachedRelayURL(attachedURL, savedCustomRelayURLs) || + !attachmentCorroborated(binding, now) ) { return clientHints; } @@ -847,6 +848,27 @@ function bindingPathHints( /** Half the model's 1h hint-lifetime cap; refreshed by every discovery read. */ const SERVER_RELAY_HINT_TTL_MS = 30 * 60 * 1_000; +/** + * Detach reports are fire-and-forget, so a relay that dies together with its + * report leaves `relayAttachedUrl` behind; without a liveness bound that dead + * route would be re-served as fresh forever. An attachment is served only + * while some live evidence is younger than this window: the attach report + * itself, or the binding's `lastSeenAt` (a live Mac re-registers at least + * hourly to keep its ≤1h path hints and binding freshness lease current, + * and a Mac that outlives its relay reattaches elsewhere, which overwrites + * the URL). A Mac that goes dark with its relay stops refreshing both, so + * the stale route ages out within this window. + */ +const SERVER_RELAY_ATTACH_LIVENESS_MS = 60 * 60 * 1_000; + +function attachmentCorroborated(binding: IrohBindingRecord, now: Date): boolean { + const freshestEvidence = Math.max( + binding.relayAttachReportedAt?.getTime() ?? 0, + binding.lastSeenAt.getTime(), + ); + return now.getTime() - freshestEvidence <= SERVER_RELAY_ATTACH_LIVENESS_MS; +} + function customRelayURLs(preference: RelayPreference): ReadonlySet { return new Set(preference.customRelays.map((relay) => relay.url)); } diff --git a/web/services/relay/report.ts b/web/services/relay/report.ts index 4171e1bdff6c..a1feb7215b80 100644 --- a/web/services/relay/report.ts +++ b/web/services/relay/report.ts @@ -17,11 +17,14 @@ // secret) sent the report, but `relayId` inside the body is self-declared, // so a single compromised relay — or a leaked secret — could otherwise // publish an attacker-controlled relay URL to every phone on any account. -// A report is therefore only applied when its hostname resolves to a relay +// An ATTACH is therefore only applied when its hostname resolves to a relay // the account is already allowed to dial: an exact managed-catalog URL, or a // custom relay the account has saved (matched by hostname, publishing the -// saved URL verbatim). Everything else is rejected (`untrusted_relay`), +// saved URL verbatim). Every other attach is rejected (`untrusted_relay`), // which also keeps dev relays (`cmux-relay-dev`) out of production state. +// A DETACH clears state instead of publishing it, so it is matched against +// the stored URL by hostname without the trust lookup — see +// `applyRelayAttachReport`. // Debug/test fleets get trusted the same way: their relay must be in the // catalog the deployment was built with, or saved as that account's custom // relay; the HMAC alone is deliberately NOT sufficient. @@ -202,13 +205,14 @@ export type RelayReportOutcome = | "unknown_endpoint" | "untrusted_relay"; -// The active binding for the endpoint plus that account's saved custom -// relays (preferences are keyed by the same Stack user id bindings carry). -// The partial unique index `iroh_endpoint_bindings_active_endpoint_unique` -// guarantees at most one row. +// The active binding for the endpoint (with its current attachment) plus +// that account's saved custom relays (preferences are keyed by the same +// Stack user id bindings carry). The partial unique index +// `iroh_endpoint_bindings_active_endpoint_unique` guarantees at most one row. const REPORT_CONTEXT_SQL = ` select binding.user_id as "userId", + binding.relay_attached_url as "attachedUrl", pref.custom_relays as "customRelays" from iroh_endpoint_bindings binding left join iroh_relay_preferences pref @@ -268,6 +272,14 @@ function savedCustomRelayURLs(customRelays: unknown): string[] { /** * Applies one verified report to the registry. Both statements run on the * dedicated deadline-bounded report client under the concurrency cap. + * + * The catalog/saved-set trust rule gates only ATTACH, because only an attach + * publishes a URL. A detach merely clears the stored attachment, so it is + * matched against the STORED URL by hostname and needs no trust resolution: + * a custom relay deleted from the account's preferences must still be able + * to detach cleanly, or its stale attachment would resurface if the same + * relay were ever saved again (a forged clear already requires the shared + * secret and only degrades discovery to the client-published fallback). */ export async function applyRelayAttachReport( report: RelayAttachReport, @@ -276,11 +288,29 @@ export async function applyRelayAttachReport( const client = reportClient(); const context = (await client.query(REPORT_CONTEXT_SQL, [report.endpointId]))[0]; if (!context) return "unknown_endpoint" as const; - const url = publishableRelayURLForHostname( - report.relayId, - savedCustomRelayURLs(context.customRelays), - ); - if (url === null) return "untrusted_relay" as const; + + let url: string; + if (report.event === "attach") { + const publishable = publishableRelayURLForHostname( + report.relayId, + savedCustomRelayURLs(context.customRelays), + ); + if (publishable === null) return "untrusted_relay" as const; + url = publishable; + } else { + const attachedUrl = typeof context.attachedUrl === "string" + ? context.attachedUrl + : null; + if (attachedUrl === null || urlHostname(attachedUrl) !== report.relayId) { + // Nothing (or a different relay's route) is published; the detach is + // stale relative to the applied event stream. + return "superseded" as const; + } + // Clear exactly what was read; the WHERE below re-checks it so a + // concurrent attach between the two statements survives. + url = attachedUrl; + } + const applied = await client.query( report.event === "attach" ? APPLY_ATTACH_SQL : APPLY_DETACH_SQL, [report.endpointId, url, report.reportedAt.toISOString()], diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 2530d916bae2..d4615ea62cbe 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -1366,6 +1366,43 @@ describe("Iroh discovery and grants", () => { .toBeGreaterThan(NOW.getTime()); }); + test("ages out an attach route with no fresh evidence (lost detach report)", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: MANAGED_RELAY_URLS[0]!, + // Both evidence channels are stale: the relay died without a detach + // report and the Mac stopped renewing its registration. + relayAttachReportedAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + lastSeenAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + + test("a fresh attach report keeps the route while the binding lease is stale", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: MANAGED_RELAY_URLS[0]!, + relayAttachReportedAt: new Date(NOW.getTime() - 5 * 60 * 1_000), + // Broker unreachable from the Mac (cache-first world) while the relay + // path works: the attach report alone corroborates the route. + lastSeenAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: Array> }>; + }; + expect(discovered.bindings[0]?.path_hints.map((hint) => hint.value)) + .toEqual([MANAGED_RELAY_URLS[0]!]); + }); + test("withholds a fleet-reported custom relay the account no longer saves", async () => { const fixture = makeFixture(); fixture.repository.bindings.push(binding({ diff --git a/web/tests/relay-report-db-behavior.test.ts b/web/tests/relay-report-db-behavior.test.ts index 04bab7e6bfbf..0499933a8e2b 100644 --- a/web/tests/relay-report-db-behavior.test.ts +++ b/web/tests/relay-report-db-behavior.test.ts @@ -224,6 +224,27 @@ describe("relay attach report registry behavior", () => { expect((await attachState()).url).toBe(CUSTOM_URL); }); + dbTest("a custom relay deleted from preferences can still detach cleanly", async () => { + await insertBinding(); + await saveCustomRelay(USER_ID, CUSTOM_URL); + await applyRelayAttachReport(report({ relayId: CUSTOM_HOSTNAME })); + await requiredSql()`delete from iroh_relay_preferences where account_id = ${USER_ID}`; + expect(await applyRelayAttachReport(report({ + event: "detach", + relayId: CUSTOM_HOSTNAME, + reportedAt: new Date(T0 + 1_000), + }))).toBe("applied"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("a detach for a hostname nothing is attached to is superseded", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0), + }))).toBe("superseded"); + }); + dbTest("another account's saved custom relay grants no trust", async () => { await insertBinding(); await saveCustomRelay("user-other", CUSTOM_URL); From b4ddad5eeb25f1e75faf4b3541c886c5b8b2eb92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:14:59 -0700 Subject: [PATCH 25/71] review: bound report event age against replay The Reporter sends each event once with a 3s timeout and no retry, so a legitimate report is seconds old. Reports older than 15 minutes are now rejected, so a captured signed attach (the HMAC carries no nonce) can no longer be replayed into an empty attachment slot and served as current reachability. --- web/services/relay/report.ts | 13 ++++++++++++- web/tests/relay-report-route.test.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/services/relay/report.ts b/web/services/relay/report.ts index a1feb7215b80..d166a993f4de 100644 --- a/web/services/relay/report.ts +++ b/web/services/relay/report.ts @@ -70,6 +70,16 @@ export const RELAY_REPORT_SETTLE_MS = 4_000; */ export const RELAY_REPORT_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; +/** + * Reject event timestamps this far behind server time. The Reporter sends + * each event once, immediately, with a 3s HTTP timeout and no retry, so a + * legitimate report is at most seconds old; the allowance is for relay clock + * drift. Without the bound, a captured signed attach (HMAC has no nonce) + * could be replayed much later into an empty attachment slot and would then + * be re-served as current reachability until the liveness window expired. + */ +export const RELAY_REPORT_MAX_PAST_SKEW_MS = 15 * 60 * 1_000; + const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; // RFC 1123 hostname labels, lowercase; total length bounded below. const RELAY_HOSTNAME_RE = @@ -160,7 +170,8 @@ export function parseRelayAttachReport( typeof ts !== "number" || !Number.isSafeInteger(ts) || ts <= 0 || - ts > now.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS + ts > now.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS || + ts < now.getTime() - RELAY_REPORT_MAX_PAST_SKEW_MS ) { return { ok: false, error: "invalid_report_time" }; } diff --git a/web/tests/relay-report-route.test.ts b/web/tests/relay-report-route.test.ts index 3effa5b49b33..3b011107cbbc 100644 --- a/web/tests/relay-report-route.test.ts +++ b/web/tests/relay-report-route.test.ts @@ -9,6 +9,7 @@ import { relayAllowSignature } from "../services/relay/allow"; import { RELAY_REPORT_MAX_CONCURRENT, RELAY_REPORT_MAX_FUTURE_SKEW_MS, + RELAY_REPORT_MAX_PAST_SKEW_MS, RELAY_REPORT_SIGNATURE_HEADER, RelayReportSaturatedError, parseRelayAttachReport, @@ -214,6 +215,17 @@ describe("POST /api/relay/report", () => { expect(await response.json()).toEqual({ error: "invalid_report_time" }); }); + test("rejects a replayed old event timestamp", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody({ + ts: NOW.getTime() - RELAY_REPORT_MAX_PAST_SKEW_MS - 1, + })), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_report_time" }); + }); + test("rejects an oversized declared body without reading it", async () => { const response = await handleRelayReportRequest( new Request("https://cmux.dev/api/relay/report", { From 00da9ae440ba4d768fd9e96c7aee144febb2698b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:33:51 -0700 Subject: [PATCH 26/71] iroh: delete client-held relay token machinery end to end Identity rides the iroh handshake and the relay's server-side allow hook (web /api/relay/token successor: /api/relay/allow, cmux-relay#9) is the only admission path, so the client-held 300 s relay tokens are dead weight. Deleted end to end: Swift (CmuxIrohTransport): - CmxIrohRelayCredentialCoordinator and its every-~4-minutes re-mint loop - CmxIrohRelayTokenResponse / ManagedRelayCredential / StoredRelayCredential / RelayConfiguration / RelayBootstrapResponse / RelayTokenServing / RelayRefreshSchedule / RelayEndpointControlling - token attach on managed connects: managed relay profiles are now built tokenless straight from the verified policy snapshot; custom relays keep user-configured static tokens - cachedRelayCredential plumbing in both runtime configurations, the handleRelayCredential persistence hooks, relay coordinator wiring in host/client runtimes, and the credential half of CmxIrohBrokerCredentialRepository (binding persistence stays) - TrustBrokerClient issueRelayToken/issueRelayBootstrap replaced by a credential-free fetchRelayPolicy() hitting GET /api/relay/policy web/: - /api/relay/token route, services/relay/token.ts minting, their tests, and CMUX_RELAY_JWT_PRIVATE_KEY_PEM; the signed relay policy now serves from the new credential-free GET /api/relay/policy App layer: - Mac host and iOS composition cached/fresh credential threading - the debug release-gate relay_rollover / relay_expiry scenarios, which existed only to verify credential rotation Registration keeps its wire-compatible relay status field; binding registration and discovery are untouched. Tests updated; new coverage proves the managed connect path builds no Authorization/token. REQUIRES the allow-hook fleet rollout (cmux-relay#9 deployed with dual-accept) before merge. --- .../CmxConnectivityEngine.swift | 13 - .../CmxIrohBackpressuredBroker.swift | 30 +- .../CmxIrohBrokerCredentialRepository.swift | 135 +--- .../CmxIrohBrokerModels.swift | 8 +- .../CmxIrohClientBrokerServing.swift | 2 +- .../CmxIrohClientRuntime+Lifecycle.swift | 14 - .../CmxIrohClientRuntime+Policy.swift | 45 +- .../CmxIrohClientRuntime+PolicyRefresh.swift | 2 +- .../CmxIrohClientRuntime+RelayPolicy.swift | 55 +- .../CmxIrohClientRuntime.swift | 39 +- .../CmxIrohClientRuntimeConfiguration.swift | 16 +- .../CmxIrohDiagnosticFailure.swift | 12 - .../CmxIrohEffectiveRelayPolicy.swift | 9 +- .../CmuxIrohTransport/CmxIrohEndpoint.swift | 9 +- .../CmxIrohEndpointConfiguration.swift | 12 +- .../CmxIrohEndpointConfigurationError.swift | 12 - .../CmxIrohEndpointRelayProfile.swift | 121 +-- .../CmxIrohEndpointSupervisor.swift | 98 +-- .../CmxIrohHostBrokerServing.swift | 2 +- .../CmxIrohHostRuntime+PolicyRefresh.swift | 91 +-- .../CmxIrohHostRuntime+RelayPolicy.swift | 55 +- .../CmxIrohHostRuntime+SignOut.swift | 4 - .../CmxIrohHostRuntime.swift | 151 +--- .../CmxIrohHostRuntimeConfiguration.swift | 14 +- .../CmxIrohLibEndpoint.swift | 9 - .../CmxIrohLibEndpointFactory.swift | 4 - .../CmuxIrohTransport/CmxIrohLibError.swift | 1 - .../CmxIrohManagedRelayCredential.swift | 55 -- .../CmxIrohRelayBootstrapResponse.swift | 17 - .../CmxIrohRelayConfiguration.swift | 87 --- .../CmxIrohRelayConfigurationError.swift | 11 - .../CmxIrohRelayCredentialCoordinator.swift | 640 ---------------- ...xIrohRelayCredentialCoordinatorError.swift | 5 - .../CmxIrohRelayEndpointControlling.swift | 18 - .../CmxIrohRelayPolicyFailure.swift | 3 - .../CmxIrohRelayPolicyResolution.swift | 35 +- .../CmxIrohRelayPolicyService.swift | 52 +- .../CmxIrohRelayPolicyServiceError.swift | 3 - .../CmxIrohRelayPolicyServing.swift | 8 +- .../CmxIrohRelayRefreshSchedule.swift | 47 -- .../CmxIrohRelayTokenResponse.swift | 115 --- .../CmxIrohRelayTokenServing.swift | 12 - .../CmxIrohRuntimeRelayProfile.swift | 53 -- .../CmxIrohStoredRelayCredential.swift | 63 -- .../CmxIrohTrustBrokerClient.swift | 213 +----- .../ClientRuntimeTestFixture.swift | 8 - .../CmxIrohBackpressuredHostBrokerTests.swift | 57 +- ...xIrohBrokerCredentialRepositoryTests.swift | 382 +--------- ...xIrohClientRuntimeAuthorizationTests.swift | 22 - .../CmxIrohClientRuntimeEmptyFleetTests.swift | 6 +- ...xIrohClientRuntimeLifecycleRaceTests.swift | 25 +- .../CmxIrohClientRuntimeTests.swift | 86 +-- .../CmxIrohConfigurationTests.swift | 68 +- .../CmxIrohCustomRelayLiveEnvironment.swift | 1 + .../CmxIrohCustomRelayLiveTests.swift | 40 +- .../CmxIrohCustomRelayProbeTests.swift | 3 +- .../CmxIrohCustomRelayRuntimeTests.swift | 89 +-- .../CmxIrohDebugRelayOverrideTests.swift | 20 +- .../CmxIrohDirectTransportGateTests.swift | 3 +- .../CmxIrohEndpointServerTests+Capacity.swift | 12 +- .../CmxIrohEndpointServerTests.swift | 22 +- .../CmxIrohEndpointSupervisorTests.swift | 63 +- ...CmxIrohHostRuntimeLifecycleRaceTests.swift | 38 +- ...IrohHostRuntimeRequestedRefreshTests.swift | 11 - ...ohHostRuntimeStartupPublicationTests.swift | 18 +- .../CmxIrohHostRuntimeTests.swift | 22 - .../CmxIrohLibEndpointCancellationTests.swift | 3 +- .../CmxIrohLibEndpointTests.swift | 14 +- ...rohOnlineAdmissionRegistryLeaseTests.swift | 3 +- ...hOnlineAdmissionRegistryOfflineTests.swift | 3 +- ...CmxIrohPersistenceLifecycleRaceTests.swift | 14 - ...CmxIrohPrivatePathTransportGateTests.swift | 6 +- .../CmxIrohRegistryContextProviderTests.swift | 3 +- ...ayCredentialCoordinatorTests+Refresh.swift | 328 -------- ...xIrohRelayCredentialCoordinatorTests.swift | 706 ------------------ .../CmxIrohRelayPolicyBrokerTests.swift | 22 +- ...mxIrohRelayPolicyServiceRefreshTests.swift | 83 +- ...hRelayPolicyServiceTests+Preferences.swift | 8 - .../CmxIrohRelayPolicyServiceTests.swift | 28 +- .../CmxIrohRelayPolicyTests.swift | 35 +- .../CmxIrohSelectedTransportPathTests.swift | 3 +- .../CmxIrohTrustBrokerClientTests.swift | 205 +---- .../RelayPolicyServiceTestFixture.swift | 11 - .../TestBlockingRelayUpdateEndpoint.swift | 2 +- .../TestCancellableDialEndpoint.swift | 1 - .../TestDialingIrohEndpoint.swift | 1 - .../TestGatedDialEndpoint.swift | 1 - .../TestHangingDialEndpoint.swift | 1 - .../TestIrohClientBroker.swift | 19 +- .../TestIrohEndpoint.swift | 20 +- ...leIrohReleaseGateArtifactPreparation.swift | 30 - .../MobileIrohReleaseGateProbeFailure.swift | 34 - .../MobileIrohReleaseGateProbeResult.swift | 42 +- ...bileIrohReleaseGateResponseValidator.swift | 38 - .../MobileIrohReleaseGateScenario.swift | 11 - ...MobileShellComposite+IrohReleaseGate.swift | 611 +-------------- ...hReleaseGateArtifactPreparationTests.swift | 44 -- ...rohReleaseGateResponseValidatorTests.swift | 74 -- .../MobileHostIrohRuntime+Activation.swift | 53 +- ...obileHostIrohRuntime+SettingsControl.swift | 3 - .../MobileIrohReleaseGateHostView.swift | 8 +- .../MobileIrohReleaseGateRunner.swift | 158 +--- .../MobileIrohReleaseGateScene.swift | 6 +- ...leIrohRuntimeComposition+ReleaseGate.swift | 5 - .../MobileIrohRuntimeComposition.swift | 61 +- .../MobileIrohReleaseGateRunnerTests.swift | 141 +--- ...eIrohRuntimeCompositionCooldownTests.swift | 52 +- .../MobileIrohRuntimeCompositionTests.swift | 14 - scripts/mobile-dev-launch.sh | 1 - scripts/run-iroh-release-gate.sh | 47 +- web/.env.example | 6 +- web/app/api/relay/policy/route.ts | 96 +++ web/app/api/relay/token/route.ts | 335 --------- web/app/env.ts | 11 +- web/services/iroh/README.md | 9 +- web/services/iroh/trustBroker.ts | 10 +- web/services/relay/token.ts | 133 ---- web/tests/client-config-env.test.ts | 2 - web/tests/relay-token-route.test.ts | 612 --------------- web/tests/relay-token.test.ts | 158 ---- 120 files changed, 490 insertions(+), 7231 deletions(-) delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift delete mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift delete mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift delete mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift delete mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift create mode 100644 web/app/api/relay/policy/route.ts delete mode 100644 web/app/api/relay/token/route.ts delete mode 100644 web/services/relay/token.ts delete mode 100644 web/tests/relay-token-route.test.ts delete mode 100644 web/tests/relay-token.test.ts diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index b899d28489b8..4de4ddea1fa7 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift @@ -345,17 +345,6 @@ public actor CmxConnectivityEngine { ) } - /// Replaces active managed relay credentials without changing identity. - public func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await supervisor.replaceRelays( - relays, - expectedIdentity: expectedIdentity - ) - } - /// Returns the selected live path after removing raw coordinates. public func selectedTransportPath( relayPolicy: CmxIrohEffectiveRelayPolicy? @@ -929,5 +918,3 @@ public actor CmxConnectivityEngine { return lhs.deviceID < rhs.deviceID } } - -extension CmxConnectivityEngine: CmxIrohRelayEndpointControlling {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift index 8a8e79439bdf..d563710b06cc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift @@ -74,18 +74,6 @@ public struct CmxIrohBackpressuredClientBroker: } } - public func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointID - ) - } - } - public func revoke(bindingID: String) async throws { try await gate.perform(accountID: accountID, operation: .revocation) { try await broker.revoke(bindingID: bindingID) @@ -167,18 +155,6 @@ public struct CmxIrohBackpressuredHostBroker: } } - public func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointID - ) - } - } - public func revoke(bindingID: String) async throws { try await gate.perform(accountID: accountID, operation: .revocation) { try await broker.revoke(bindingID: bindingID) @@ -209,11 +185,9 @@ public struct CmxIrohBackpressuredRelayPolicyBroker: CmxIrohRelayPolicyServing, self.accountID = accountID } - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + public func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayBootstrap(endpointID: endpointID) + try await broker.fetchRelayPolicy() } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift index ea30aabd86df..decc8d7417be 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift @@ -1,7 +1,9 @@ import CryptoKit public import Foundation -/// Persists one active account's broker binding and relay capability. +/// Persists one active account's broker binding. The Keychain-backed secure +/// store survives only to delete legacy relay-credential records; no relay +/// credentials exist any more (relay admission is the relay's allow hook). public actor CmxIrohBrokerCredentialRepository { private static let activeScopeKey = "cmux.iroh.broker-credentials.scope.v1" private static let bindingKey = "cmux.iroh.broker-credentials.binding.v1" @@ -69,136 +71,11 @@ public actor CmxIrohBrokerCredentialRepository { 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, - 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, - 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: @@ -347,12 +224,6 @@ public actor CmxIrohBrokerCredentialRepository { } } - private func hasExactFleet(_ fleet: [String], expected: Set) -> 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 diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift index df3d7332e2bd..8ec13d9ca57a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift @@ -352,9 +352,9 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable { } } -/// Result of the registration route's best-effort initial relay mint. +/// Wire-compatibility status field of the registration response. Clients hold +/// no relay credentials; relay admission is the relay's server-side allow hook. public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable { - case issued(CmxIrohRelayTokenResponse) case unavailable case notRequested @@ -364,9 +364,7 @@ public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable { 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": + case "unavailable", "issued": self = .unavailable case "not_requested": self = .notRequested diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift index 0d3719a5c0d2..910d635f81fc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift @@ -1,6 +1,6 @@ /// Trust-broker operations required by an iOS Iroh client runtime. public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking + CmxIrohBindingRevoking { /// Checks a caller-owned broker floor without performing network work. func preflight(operation: CmxIrohBrokerOperation) async throws diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift index 9f3068f099e0..3e472a6b0d06 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift @@ -72,8 +72,6 @@ extension CmxIrohClientRuntime { registrationRefreshEnabled = false supervisorEventTask?.cancel() supervisorEventTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil await contextRouter.clear() authoritativeDiscovery = nil if !preserveBinding { @@ -101,18 +99,6 @@ extension CmxIrohClientRuntime { } } - static func cachedRelayConfigurations( - configuration: CmxIrohClientRuntimeConfiguration, - now: Date - ) -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - cached.relayFleet.count == configuration.managedRelayURLs.count, - Set(cached.relayFleet) == configuration.managedRelayURLs else { - return [] - } - return (try? cached.relayConfigurations(now: now)) ?? [] - } - static func isConnectivity(_ error: any Error) -> Bool { (error as? CmxIrohTrustBrokerClientError) == .connectivity } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift index 4872842ad2d4..a08fe5a41b60 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift @@ -354,8 +354,7 @@ extension CmxIrohClientRuntime { func install( policy: ResolvedPolicy, - revision: UInt64, - startRelays: Bool + revision: UInt64 ) async throws { try requireCurrent(revision) let offlinePolicy = try policy.offlineExpectation.map { expectation in @@ -399,47 +398,5 @@ extension CmxIrohClientRuntime { } await contextRouter.install(provider) localBinding = policy.binding - - guard endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty else { - await relayCoordinator?.deactivate() - relayCoordinator = nil - return - } - - let coordinator: CmxIrohRelayCredentialCoordinator - if let relayCoordinator { - coordinator = relayCoordinator - } else { - coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - retrySchedule: .foregroundClient, - automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - relayCoordinator = coordinator - } - - let bootstrap = startRelays ? configuration.cachedRelayCredential : nil - if startRelays || bootstrap != nil { - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission - do { - try await coordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: policy.binding.endpointID, - bootstrap: bootstrap, - waitForInitialCredential: requiresRelayReadiness - ) - } catch { - if requiresRelayReadiness { throw error } - // Registration remains authoritative; direct paths remain usable. - } - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift index 9510b095d8e2..f49993ebe484 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift @@ -98,7 +98,7 @@ extension CmxIrohClientRuntime { guard policy.binding.bindingID == previousBinding.bindingID else { throw CmxIrohClientRuntimeError.invalidLocalBinding } - try await install(policy: policy, revision: revision, startRelays: false) + try await install(policy: policy, revision: revision) try requireCurrent(revision) currentSnapshot = CmxIrohClientRuntimeSnapshot( state: .active, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift index 457f757f0753..e562b4aac031 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift @@ -8,8 +8,7 @@ extension CmxIrohClientRuntime { } ?? managedRelayURLs try await replaceRelayProfile( policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap + managedRelayURLs: verifiedManagedURLs ) } @@ -19,15 +18,13 @@ extension CmxIrohClientRuntime { ) async throws { try await replaceRelayProfile( profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil + managedRelayURLs: managedRelayURLs ) } private func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set, - relayBootstrap: CmxIrohRelayTokenResponse? + managedRelayURLs replacementManagedURLs: Set ) async throws { // A debug-only forced relay pins every profile installation, so a // broker policy refresh cannot displace the test relay mid-run. @@ -47,48 +44,10 @@ extension CmxIrohClientRuntime { } let revision = lifecycleRevision - await relayCoordinator?.deactivate() - relayCoordinator = nil - if profile.source == .managed, !profile.allowedRelayURLs.isEmpty { - let refreshSchedule = CmxIrohRelayRefreshSchedule( - role: .client, - endpointIdentity: binding.endpointID - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - jitter: { now, refreshAfter in - refreshSchedule.deadline(now: now, refreshAfter: refreshAfter) - }, - retrySchedule: .foregroundClient, - automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activateManagedPolicy( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - profile: profile, - bootstrap: relayBootstrap - ) - } catch { - await coordinator.deactivate() - if relayCoordinator === coordinator { - relayCoordinator = nil - } - throw error - } - } else { - try await connectivityEngine.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - } + try await connectivityEngine.replaceRelayProfile( + profile, + expectedIdentity: binding.endpointID + ) try requireCurrent(revision) managedRelayURLs = replacementManagedURLs diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift index 444d10faecb8..c817bb66491c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift @@ -20,12 +20,6 @@ public actor CmxIrohClientRuntime { public typealias CustomPrivateFallbackProvider = CmxIrohRegistryContextProvider.CustomPrivateFallbackProvider - /// Runs after a relay credential is installed on the exact active binding. - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBinding - ) async -> Void - /// Removes account-local identity, binding, relay, and route cache state. public typealias LocalDeactivationHandler = @Sendable () async -> Void @@ -83,17 +77,14 @@ public actor CmxIrohClientRuntime { let customPrivateFallback: CustomPrivateFallbackProvider? let diagnosticLog: DiagnosticLog? let now: @Sendable () -> Date - let automaticRelayCredentialRefreshEnabled: Bool let handleBinding: BindingHandler let handleCachedBindings: CachedBindingsHandler - let handleRelayCredential: RelayCredentialHandler let handleLocalDeactivation: LocalDeactivationHandler let handlePolicyInvalidation: PolicyInvalidationHandler var lifecycleRevision: UInt64 = 0 var lifecyclePhase = LifecyclePhase.inactive var signOutOperation: Task? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? var supervisorEventTask: Task? var registrationRefreshTask: Task? var registrationRefreshTaskID: UUID? @@ -130,7 +121,6 @@ public actor CmxIrohClientRuntime { /// private-network profiles. An empty profile set disables explicit hints. /// - now: Wall-clock injection for route and relay validation. /// - handleBinding: Persists the exact verified binding and discovery state. - /// - handleRelayCredential: Persists an installed relay credential. /// - handleLocalDeactivation: Wipes account-local Iroh caches during sign-out. /// - handlePolicyInvalidation: Clears persisted broker routes after a terminal refresh. /// - Throws: An endpoint configuration error for an invalid cached relay set. @@ -148,16 +138,12 @@ public actor CmxIrohClientRuntime { lanFallback: LANFallbackProvider? = nil, customPrivateFallback: CustomPrivateFallbackProvider? = nil, now: @escaping @Sendable () -> Date = { Date() }, - automaticRelayCredentialRefreshEnabled: Bool = true, handleBinding: @escaping BindingHandler = { _, _ in true }, handleCachedBindings: @escaping CachedBindingsHandler = { _, _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, handleLocalDeactivation: @escaping LocalDeactivationHandler = {}, handlePolicyInvalidation: @escaping PolicyInvalidationHandler = {} ) throws { - let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile( - now: now() - ) + let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile() let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, alpns: [protocolConfiguration.alpn], @@ -190,10 +176,8 @@ public actor CmxIrohClientRuntime { self.customPrivateFallback = customPrivateFallback self.diagnosticLog = diagnosticLog self.now = now - self.automaticRelayCredentialRefreshEnabled = automaticRelayCredentialRefreshEnabled self.handleBinding = handleBinding self.handleCachedBindings = handleCachedBindings - self.handleRelayCredential = handleRelayCredential self.handleLocalDeactivation = handleLocalDeactivation self.handlePolicyInvalidation = handlePolicyInvalidation transportFactory = CmxConnectivityByteTransportFactory( @@ -206,12 +190,6 @@ public actor CmxIrohClientRuntime { currentSnapshot } - /// Returns the non-secret hard expiry of the relay credential currently - /// installed on the live endpoint. - public func relayCredentialExpiresAt() async -> Date? { - await relayCoordinator?.credentialExpiresAt() - } - /// Monotonic count of online broker snapshots verified by this runtime. public func liveDiscoverySnapshotGeneration() -> UInt64 { liveDiscoveryGeneration @@ -373,8 +351,7 @@ public actor CmxIrohClientRuntime { cachedTargetBindings: [], cachedLANRendezvous: nil ), - revision: revision, - startRelays: false + revision: revision ) try requireCurrent(revision) let published = await handleBinding(discoveredBinding, discovery) @@ -485,14 +462,6 @@ public actor CmxIrohClientRuntime { ) do { - let startingRelayProfile = try endpointRelayProfile - .droppingExpiredManagedCredentials(at: now()) - if startingRelayProfile != endpointRelayProfile { - try await connectivityEngine.replaceRelayProfile( - startingRelayProfile - ) - endpointRelayProfile = startingRelayProfile - } await startSupervisorObservation(revision: revision) let cachedDiscoveryTask: Task? if configuration.cachedBinding != nil { @@ -519,7 +488,7 @@ public actor CmxIrohClientRuntime { brokerPreparationComplete: cachedDiscoveryTask != nil ) try requireCurrent(revision) - try await install(policy: policy, revision: revision, startRelays: true) + try await install(policy: policy, revision: revision) if !protocolConfiguration.allowsNATTraversalAfterAdmission { guard await connectivityEngine.hasConfiguredRelay() else { throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut @@ -617,8 +586,6 @@ public actor CmxIrohClientRuntime { registrationRefreshEnabled = true _ = try await refreshLiveDiscoveryThrowing() try requireCurrent(revision) - try await relayCoordinator?.refreshIfNeeded() - try requireCurrent(revision) } catch { if lifecyclePhase == .active, lifecycleRevision == revision { registrationRefreshEnabled = true diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift index fe4813303713..48e938678adf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift @@ -34,9 +34,6 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// `nil` preserves automatic use of the complete managed fleet. public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - /// A previously validated endpoint-scoped relay credential, when available. - public let cachedRelayCredential: CmxIrohRelayTokenResponse? - /// The exact locally persisted binding tuple from a prior verified discovery. /// /// When it still appears exactly once in an authenticated connectivity @@ -64,7 +61,6 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// - capabilities: The advertised protocol capabilities. /// - managedRelayURLs: The exact managed relay fleet. /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated cached relay capability. /// - cachedBinding: A previously verified exact local binding tuple. /// - dialPhaseTimeout: The per-phase dial deadline, injectable so tests /// can shrink it. @@ -79,7 +75,6 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { capabilities: [String], managedRelayURLs: Set, endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, cachedBinding: CmxIrohBrokerBindingMetadata? = nil, dialPhaseTimeout: Duration = .seconds(5) ) { @@ -93,8 +88,17 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { self.capabilities = capabilities self.managedRelayURLs = managedRelayURLs self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential self.cachedBinding = cachedBinding self.dialPhaseTimeout = dialPhaseTimeout } } + +extension CmxIrohClientRuntimeConfiguration { + func resolvedEndpointRelayProfile( + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try endpointRelayProfile + ?? CmxIrohEndpointRelayProfile(managedRelayURLs: managedRelayURLs) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift index 9e2cefa514df..3cfe3cbbbb7f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift @@ -264,8 +264,6 @@ extension CmxIrohLibError: DiagnosticFailureProviding { switch self { case .invalidEndpointIdentity, .remoteIdentityMismatch: .identityMismatch - case .expiredRelayCredential: - .credentialUnavailable case .unmanagedRelayURL, .unsupportedRelayIdentifier: .policyUnavailable case .unexpectedALPN, .invalidReceiveLimit: @@ -288,22 +286,12 @@ extension CmxIrohRelayPolicyServiceError: DiagnosticFailureProviding { public var diagnosticFailureKind: DiagnosticFailureKind { switch self { case .brokerUnavailable: .policyUnavailable - case .managedCredentialUnavailable: .credentialUnavailable case .preferenceRollback: .policyUnavailable case .superseded: .superseded } } } -extension CmxIrohRelayCredentialCoordinatorError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive: .endpointUnavailable - case .relayFleetMismatch: .policyUnavailable - } - } -} - extension CmxIrohRegistryContextError: DiagnosticFailureProviding { public var diagnosticFailureKind: DiagnosticFailureKind { switch self { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift index fe178327b1c6..80382b64f70c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift @@ -35,11 +35,6 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { /// Monotonic broker preference revision, when one was restored. public let preferenceRevision: Int64? - /// The endpoint-scoped credential returned with this exact broker policy. - /// - /// Kept internal so tokens cannot cross the transport/settings boundary. - let relayBootstrap: CmxIrohRelayTokenResponse? - init( endpointRelayProfile: CmxIrohEndpointRelayProfile, managedSnapshot: CmxIrohRelayPolicySnapshot?, @@ -50,8 +45,7 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { missingCredentialRelayIDs: Set = [], source: CmxIrohRelayPolicySource, usedCachedPolicy: Bool, - preferenceRevision: Int64?, - relayBootstrap: CmxIrohRelayTokenResponse? = nil + preferenceRevision: Int64? ) { self.endpointRelayProfile = endpointRelayProfile self.managedSnapshot = managedSnapshot @@ -63,6 +57,5 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { self.source = source self.usedCachedPolicy = usedCachedPolicy self.preferenceRevision = preferenceRevision - self.relayBootstrap = relayBootstrap } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift index c15df53f582a..102473ef4a42 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift @@ -34,12 +34,6 @@ public protocol CmxIrohEndpoint: Sendable { /// - Throws: A transport error for a failed handshake. func accept() async throws -> (any CmxIrohConnection)? - /// Replaces relay credentials without changing the EndpointID. - /// - /// - Parameter relays: The new complete managed relay set. - /// - Throws: A transport error when the update cannot be applied. - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws - /// Replaces the complete managed or custom relay profile without changing EndpointID. /// /// - Parameter profile: The exact new allowlist and active relay configurations. @@ -65,11 +59,10 @@ public extension CmxIrohEndpoint { /// Test and alternate endpoints opt out of local advertisement by default. func localDirectAddresses() async -> [String] { [] } - /// Alternate endpoints retain managed credential refresh compatibility. + /// Test and alternate endpoints reject relay profile replacement by default. func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { guard profile.source == .managed else { throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement } - try await replaceRelays(profile.managedRelays) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift index b0adb818afc1..e83c56d6f569 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift @@ -19,11 +19,6 @@ public struct CmxIrohEndpointConfiguration: Equatable, Sendable { relayProfile.source == .managed ? relayProfile.allowedRelayURLs : [] } - /// Endpoint-scoped credentials for some or all allowed relays. - public var relays: [CmxIrohRelayConfiguration] { - relayProfile.managedRelays - } - /// Creates a validated endpoint bind configuration. /// /// - Parameters: @@ -31,18 +26,15 @@ public struct CmxIrohEndpointConfiguration: Equatable, Sendable { /// - alpns: ALPNs advertised by the endpoint. /// - bindPolicy: Ephemeral by default, or an exact required socket address. /// - managedRelayURLs: Exact relay origins permitted by app or MDM policy. - /// - relays: Current endpoint-scoped relay credentials. /// - Throws: ``CmxIrohEndpointConfigurationError`` for fleet-policy violations. public init( secretKey: CmxIrohSecretKey, alpns: [Data], bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - managedRelayURLs: Set, - relays: [CmxIrohRelayConfiguration] + managedRelayURLs: Set ) throws { let relayProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: relays + managedRelayURLs: managedRelayURLs ) self.secretKey = secretKey self.alpns = alpns diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift index 1a6a2b5dac10..78b28a0a9c5f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift @@ -3,18 +3,6 @@ public enum CmxIrohEndpointConfigurationError: Error, Equatable, Sendable { /// The relay fleet is larger than the endpoint policy permits. case tooManyRelays(Int) - /// A relay URL appears more than once. - case duplicateRelayURL(String) - - /// A credential names a relay outside the explicit fleet allowlist. - case unmanagedRelayURL(String) - - /// A verified managed selection is missing one or more relay credentials. - case incompleteManagedRelayCredentials - - /// Managed broker credentials cannot mutate a strict custom relay override. - case managedCredentialUpdateInCustomProfile - /// The endpoint implementation cannot apply a complete profile replacement. case unsupportedRelayProfileReplacement } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift index e0718237d03e..1081fb609306 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift @@ -1,6 +1,10 @@ import Foundation /// The complete relay policy installed on one Iroh endpoint generation. +/// +/// Managed relays carry no client credentials: the relay handshake proves the +/// endpoint key and the relay's server-side allow hook decides admission. +/// Custom relays may still carry a user-configured static token. public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { enum Source: Equatable, Sendable { case managed @@ -10,11 +14,6 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { struct Relay: Equatable, Sendable { let url: String let authenticationToken: String? - let expiresAt: Date? - - func isUsable(at now: Date) -> Bool { - expiresAt.map { $0 > now } ?? true - } } /// Exact relay origins accepted in peer reachability hints. @@ -22,15 +21,13 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { let source: Source let activeRelays: [Relay] - let managedRelays: [CmxIrohRelayConfiguration] /// A fail-closed profile used when a selected custom relay profile cannot /// be restored. Direct P2P stays enabled, while every relay is disabled. public static let unavailableCustomOverride = CmxIrohEndpointRelayProfile( allowedRelayURLs: [], source: .custom, - activeRelays: [], - managedRelays: [] + activeRelays: [] ) /// A fail-closed profile used when a managed relay selection cannot be @@ -38,72 +35,41 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { public static let unavailableManagedSelection = CmxIrohEndpointRelayProfile( allowedRelayURLs: [], source: .managed, - activeRelays: [], - managedRelays: [] + activeRelays: [] ) private init( allowedRelayURLs: Set, source: Source, - activeRelays: [Relay], - managedRelays: [CmxIrohRelayConfiguration] + activeRelays: [Relay] ) { self.allowedRelayURLs = allowedRelayURLs self.source = source self.activeRelays = activeRelays - self.managedRelays = managedRelays } - /// Creates a managed profile whose credentials are constrained by an - /// app-pinned or root-verified relay allowlist. - /// - /// The allowlist may contain relays without a current credential so an - /// endpoint can bind before broker refresh completes. + /// Creates a managed profile in which every allowed relay is active with + /// no client credential. /// - /// - Parameters: - /// - allowedRelayURLs: Exact managed relay origins accepted by policy. - /// - relays: Current endpoint-scoped credentials for a subset of the allowlist. + /// - Parameter allowedRelayURLs: Exact managed relay origins accepted by policy. /// - Throws: ``CmxIrohEndpointConfigurationError`` for a policy violation. - public init( - managedRelayURLs allowedRelayURLs: Set, - relays: [CmxIrohRelayConfiguration] - ) throws { - try Self.validate( - allowedRelayURLs: allowedRelayURLs, - relayURLs: relays.map(\.url) - ) + public init(managedRelayURLs allowedRelayURLs: Set) throws { + guard allowedRelayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { + throw CmxIrohEndpointConfigurationError.tooManyRelays(allowedRelayURLs.count) + } self.allowedRelayURLs = allowedRelayURLs source = .managed - activeRelays = relays.map { - Relay( - url: $0.url, - authenticationToken: $0.token, - expiresAt: $0.expiresAt - ) + activeRelays = allowedRelayURLs.sorted().map { + Relay(url: $0, authenticationToken: nil) } - managedRelays = relays } - /// Creates a managed profile from one verified catalog selection and its - /// exact endpoint-scoped credential set. + /// Creates a managed profile from one verified catalog selection. /// - /// - Parameters: - /// - snapshot: Root-verified managed catalog and local selection. - /// - relays: Credentials for every selected relay and no other origin. - /// - Throws: ``CmxIrohEndpointConfigurationError`` for credential substitution. - public init( - snapshot: CmxIrohRelayPolicySnapshot, - relays: [CmxIrohRelayConfiguration] - ) throws { - let selectedURLs = snapshot.relayURLs - let credentialURLs = Set(relays.map(\.url)) - guard credentialURLs == selectedURLs else { - if let substituted = credentialURLs.subtracting(selectedURLs).first { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted) - } - throw CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials - } - try self.init(managedRelayURLs: selectedURLs, relays: relays) + /// - Parameter snapshot: Root-verified managed catalog and local selection. + /// - Throws: ``CmxIrohEndpointConfigurationError`` for a policy violation. + public init(snapshot: CmxIrohRelayPolicySnapshot) throws { + try self.init(managedRelayURLs: snapshot.relayURLs) } /// Creates a strict custom override with no managed-provider fallback. @@ -118,51 +84,8 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { activeRelays = customProfile.relays.map { Relay( url: $0.url, - authenticationToken: $0.authenticationToken, - expiresAt: nil + authenticationToken: $0.authenticationToken ) } - managedRelays = [] - } - - func replacingManagedRelays( - _ relays: [CmxIrohRelayConfiguration] - ) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { - throw CmxIrohEndpointConfigurationError.managedCredentialUpdateInCustomProfile - } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: relays - ) - } - - func droppingExpiredManagedCredentials(at now: Date) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { return self } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: managedRelays.filter { $0.expiresAt > now } - ) - } - - private static func validate( - allowedRelayURLs: Set, - relayURLs: [String] - ) throws { - guard allowedRelayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(allowedRelayURLs.count) - } - guard relayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(relayURLs.count) - } - var observedURLs = Set() - for url in relayURLs { - guard allowedRelayURLs.contains(url) else { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(url) - } - guard observedURLs.insert(url).inserted else { - throw CmxIrohEndpointConfigurationError.duplicateRelayURL(url) - } - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift index a8ce2875377d..54f929d2a2b5 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift @@ -284,101 +284,9 @@ public actor CmxIrohEndpointSupervisor { return try await activate() } - /// Installs a fresh relay set on the live endpoint before committing it for future binds. - /// - /// The concrete endpoint must add replacement credentials before removing - /// stale credentials. A failed update leaves this supervisor's last-known - /// good configuration unchanged. - /// - /// - Parameter relays: The complete new relay credential set. - /// - Throws: A fleet validation or endpoint update error. - public func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - try await replaceRelays( - relays, - expectedIdentity: Optional.none - ) - } - - /// Installs relay credentials only on the active endpoint identity that requested them. - /// - /// A lifecycle transition during the update leaves the next generation's - /// configuration unchanged. This prevents a delayed token response for an - /// old binding from being committed to a replacement endpoint. - public func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await replaceRelays(relays, expectedIdentity: Optional(expectedIdentity)) - } - - private func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity? - ) async throws { - let candidateProfile = try configuration.relayProfile.replacingManagedRelays(relays) - let candidateConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.secretKey, - alpns: configuration.alpns, - bindPolicy: configuration.bindPolicy, - relayProfile: candidateProfile - ) - guard let endpoint else { - guard expectedIdentity == nil else { - throw CmxIrohEndpointSupervisorError.inactive - } - configuration = candidateConfiguration - return - } - let revision = lifecycleRevision - if let expectedIdentity { - let actualIdentity = await endpoint.identity() - guard lifecycleRevision == revision, - snapshot.state == .active, - actualIdentity == expectedIdentity else { - throw CmxIrohEndpointSupervisorError.superseded - } - } - let previousAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - let priorRelayReadyGeneration = relayReadyGeneration - relayReadyGeneration = nil - do { - try await endpoint.replaceRelays(relays) - } catch { - if lifecycleRevision == revision, - snapshot.state == .active, - priorRelayReadyGeneration == snapshot.runtimeGeneration { - markRelayReady(generation: snapshot.runtimeGeneration) - } - throw error - } - let updatedAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - configuration = candidateConfiguration - if Self.hasUsableRelayHint(updatedAddress) { - markRelayReady(generation: snapshot.runtimeGeneration) - } - // The endpoint's address watcher may observe the new home relay while - // `replaceRelays` is suspended, before the endpoint commits the matching - // allowlist. That early event is filtered by the old profile and may be - // the only native address callback. Republish after both endpoint and - // supervisor configuration commit so owners re-read one coherent route. - if updatedAddress != previousAddress { - publish(.networkChanged(runtimeGeneration: snapshot.runtimeGeneration)) - } - } - - /// Installs a complete managed selection or custom relay override live. - /// - /// The endpoint keeps its stable key and adds replacement relays before it - /// removes stale relays. A failed update leaves the supervisor's future bind - /// configuration unchanged. - /// - /// - Parameter profile: Exact relay allowlist and active configurations. + /// Installs a fresh relay profile on the live endpoint before committing + /// it for future binds. A failed update leaves this supervisor's + /// last-known good configuration unchanged. public func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile ) async throws { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift index fb5b8f9f60b2..258f88221809 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift @@ -1,6 +1,6 @@ /// Trust-broker operations required by a Mac host runtime. public protocol CmxIrohHostBrokerServing: CmxIrohDiscoveryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking + CmxIrohBindingRevoking { /// Checks a caller-owned broker floor without performing network work. func preflight(operation: CmxIrohBrokerOperation) async throws diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index a4fa11687b4c..522703f70511 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -94,7 +94,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: nil, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -116,7 +115,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: nil, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -167,7 +165,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: registration.binding, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -205,7 +202,6 @@ extension CmxIrohHostRuntime { pairingEnabled: discovered.pairingEnabled, grantVerificationKeys: discovery.grantVerificationKeys, attestation: attestation, - relayBootstrap: configuration.cachedRelayCredential, lanRendezvous: discovery.lanRendezvous, routePathHints: discovered.pathHints, registrationRetryAfterSeconds: nil @@ -292,7 +288,6 @@ extension CmxIrohHostRuntime { pairingEnabled: cached.pairingEnabled, grantVerificationKeys: cached.grantVerificationKeys, attestation: cached.endpointAttestation, - relayBootstrap: configuration.cachedRelayCredential, lanRendezvous: cached.lanRendezvous, routePathHints: [], registrationRetryAfterSeconds: nil @@ -303,7 +298,6 @@ extension CmxIrohHostRuntime { after error: any Error, expectedEndpointID: CmxIrohPeerIdentity, confirmedBinding: CmxIrohBrokerBinding?, - relayBootstrap: CmxIrohRelayTokenResponse?, allowFallback: Bool ) throws -> ResolvedPolicy { if let confirmedBinding, let localBinding, @@ -332,7 +326,6 @@ extension CmxIrohHostRuntime { pairingEnabled: cached.pairingEnabled, grantVerificationKeys: cached.grantVerificationKeys, attestation: cached.endpointAttestation, - relayBootstrap: relayBootstrap ?? configuration.cachedRelayCredential, lanRendezvous: cached.lanRendezvous, routePathHints: [], registrationRetryAfterSeconds: ( @@ -392,15 +385,6 @@ extension CmxIrohHostRuntime { } } - func cachedRelayConfigurations() -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - Set(cached.relayFleet) == managedRelayURLs, - cached.relayFleet.count == managedRelayURLs.count else { - return [] - } - return (try? cached.relayConfigurations(now: now())) ?? [] - } - func startConnectivityObservation( engine: CmxConnectivityEngine, revision: UInt64 @@ -698,88 +682,29 @@ extension CmxIrohHostRuntime { /// Rebinds binding-scoped components to an authenticated replacement /// binding. `CmxIrohAdmissionController.update` already propagates the new - /// acceptor to online and offline admission; only the relay credential - /// coordinator pins a binding ID at activation and must be recreated. + /// acceptor to online and offline admission. private func adoptReplacedBinding( - policy: ResolvedPolicy, + policy _: ResolvedPolicy, revision: UInt64 ) async throws { try requireCurrent(revision) - guard let connectivityEngine else { - throw CmxIrohHostRuntimeError.inactive - } - // The startup ready gate retains the superseded cached binding and - // relay bootstrap it was armed with. Cancel and drain it before - // rebinding so it can never activate the replacement coordinator with - // the stale identity or install a stale relay credential; the deferred - // first publication is re-armed onto the adopted binding below. + // The startup ready gate was armed with the superseded cached binding. + // Cancel and drain it before rebinding; the deferred first publication + // is re-armed below. if let staleReadyGate = initialPublicationTask { staleReadyGate.cancel() initialPublicationTask = nil await staleReadyGate.value try requireCurrent(revision) } - try await rebindRelayCoordinator( - policy: policy, - engine: connectivityEngine, - revision: revision - ) if initialPublicationPending { // The drained gate owned the relay-readiness wait for the deferred - // first publication. Re-arm it bound to the adopted identity so - // the endpoint still publishes once the relay becomes usable. - scheduleInitialPublication( - binding: policy.binding, - endpointID: policy.binding.endpointID, - bootstrap: policy.relayBootstrap, - revision: revision - ) + // first publication. Re-arm it so the endpoint still publishes + // once the relay becomes usable. + scheduleInitialPublication(revision: revision) } } - /// Deactivates the coordinator pinned to the replaced binding and, for a - /// managed relay profile, activates a replacement pinned to the adopted - /// binding. - private func rebindRelayCoordinator( - policy: ResolvedPolicy, - engine: CmxConnectivityEngine, - revision: UInt64 - ) async throws { - guard let coordinator = relayCoordinator else { return } - relayActivationTask?.cancel() - relayActivationTask = nil - await coordinator.deactivate() - if relayCoordinator === coordinator { - relayCoordinator = nil - } - try requireCurrent(revision) - let binding = policy.binding - guard let profile = currentEndpointRelayProfile, - profile.source == .managed, - !profile.allowedRelayURLs.isEmpty else { return } - let replacement = CmxIrohRelayCredentialCoordinator( - supervisor: engine, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: profile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = replacement - do { - try await replacement.activate( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - bootstrap: policy.relayBootstrap - ) - } catch { - // The replacement coordinator owns bounded retry. An unavailable - // relay credential must not fail the adopted live policy. - } - try requireCurrent(revision) - } - static func seconds(_ date: Date) -> Int64? { let value = date.timeIntervalSince1970 guard value.isFinite, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift index f9144154b496..2a0a07fd07e3 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift @@ -8,8 +8,7 @@ extension CmxIrohHostRuntime { } ?? managedRelayURLs try await replaceRelayProfile( policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap + managedRelayURLs: verifiedManagedURLs ) } @@ -19,15 +18,13 @@ extension CmxIrohHostRuntime { ) async throws { try await replaceRelayProfile( profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil + managedRelayURLs: managedRelayURLs ) } private func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set, - relayBootstrap: CmxIrohRelayTokenResponse? + managedRelayURLs replacementManagedURLs: Set ) async throws { // A debug-only forced relay pins every profile installation, so a // broker policy refresh cannot displace the test relay mid-run. @@ -49,48 +46,10 @@ extension CmxIrohHostRuntime { } let revision = lifecycleRevision - relayActivationTask?.cancel() - relayActivationTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil - if profile.source == .managed, !profile.allowedRelayURLs.isEmpty { - let refreshSchedule = CmxIrohRelayRefreshSchedule( - role: .host, - endpointIdentity: binding.endpointID - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - jitter: { now, refreshAfter in - refreshSchedule.deadline(now: now, refreshAfter: refreshAfter) - }, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activateManagedPolicy( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - profile: profile, - bootstrap: relayBootstrap - ) - } catch { - await coordinator.deactivate() - if relayCoordinator === coordinator { - relayCoordinator = nil - } - throw error - } - } else { - try await connectivityEngine.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - } + try await connectivityEngine.replaceRelayProfile( + profile, + expectedIdentity: binding.endpointID + ) try requireCurrent(revision) managedRelayURLs = replacementManagedURLs diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift index 24bedd4d19df..0739d9954fb0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift @@ -86,8 +86,6 @@ extension CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 - relayActivationTask?.cancel() - relayActivationTask = nil initialPublicationTask?.cancel() initialPublicationTask = nil initialPublicationPending = false @@ -102,8 +100,6 @@ extension CmxIrohHostRuntime { for task in activePathObservationTasks.values { task.cancel() } activePathObservationTasks.removeAll(keepingCapacity: false) publishSelectedPathChange() - await relayCoordinator?.deactivate() - relayCoordinator = nil await offlineSessions?.invalidate() offlineSessions = nil await onlineAdmissionRegistry?.stop() diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 92b1cb2863d4..20c064e68b17 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -22,10 +22,6 @@ public actor CmxIrohHostRuntime { /// Persistent identity and credential deletion belongs to the caller and /// must remain conditional on a successfully queued sign-out revocation. public typealias DeactivationHandler = @Sendable (_ bindingID: String?) async -> Void - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBindingMetadata - ) async -> Void public typealias LANRefreshHandler = @Sendable () async -> Void public typealias LANDirectAddressProvider = @Sendable () async -> [String] public typealias LANPolicyHandler = @Sendable ( @@ -40,7 +36,6 @@ public actor CmxIrohHostRuntime { let pairingEnabled: Bool let grantVerificationKeys: CmxIrohGrantVerificationKeySet let attestation: CmxIrohEndpointAttestationResponse? - let relayBootstrap: CmxIrohRelayTokenResponse? let lanRendezvous: CmxIrohLANRendezvous let routePathHints: [CmxIrohPathHint] let registrationRetryAfterSeconds: Int? @@ -81,7 +76,6 @@ public actor CmxIrohHostRuntime { let handleBinding: BindingHandler let handleRoute: RouteHandler let handleDeactivation: DeactivationHandler - let handleRelayCredential: RelayCredentialHandler let handleLANRefresh: LANRefreshHandler let handleLANPolicy: LANPolicyHandler @@ -89,13 +83,11 @@ public actor CmxIrohHostRuntime { var lifecyclePhase = LifecyclePhase.inactive var signOutOperation: Task? var connectivityEngine: CmxConnectivityEngine? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? var endpointServer: CmxIrohEndpointServer? var admissionController: CmxIrohAdmissionController? var onlineAdmissionRegistry: CmxIrohOnlineAdmissionRegistry? var offlineSessions: CmxIrohOfflinePairingSessions? var connectivityEventTask: Task? - var relayActivationTask: Task? var initialPublicationTask: Task? /// True while activation still owes the first ready publication. It makes /// the next refresh round publish even when reachability is unchanged. @@ -147,7 +139,6 @@ public actor CmxIrohHostRuntime { handleBinding: @escaping BindingHandler = { _, _, _ in }, handleRoute: @escaping RouteHandler = { _, _ in }, handleDeactivation: @escaping DeactivationHandler = { _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, handleLANRefresh: @escaping LANRefreshHandler = {}, handleLANPolicy: @escaping LANPolicyHandler = { _, _ in } ) { @@ -166,7 +157,6 @@ public actor CmxIrohHostRuntime { self.handleBinding = handleBinding self.handleRoute = handleRoute self.handleDeactivation = handleDeactivation - self.handleRelayCredential = handleRelayCredential self.handleLANRefresh = handleLANRefresh self.handleLANPolicy = handleLANPolicy managedRelayURLs = configuration.managedRelayURLs @@ -199,9 +189,8 @@ public actor CmxIrohHostRuntime { ) do { - let endpointRelayProfile = try (currentEndpointRelayProfile - ?? configuration.resolvedEndpointRelayProfile(now: now())) - .droppingExpiredManagedCredentials(at: now()) + let endpointRelayProfile = try currentEndpointRelayProfile + ?? configuration.resolvedEndpointRelayProfile() currentEndpointRelayProfile = endpointRelayProfile let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, @@ -265,26 +254,9 @@ public actor CmxIrohHostRuntime { offlineSessions: offlineSessions, onlineRegistry: onlineAdmissionRegistry ) - let relayCoordinator: CmxIrohRelayCredentialCoordinator? - if endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty { - relayCoordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - } else { - relayCoordinator = nil - } - self.offlineSessions = offlineSessions self.onlineAdmissionRegistry = onlineAdmissionRegistry self.admissionController = admissionController - self.relayCoordinator = relayCoordinator localBinding = policy.binding endpointAttestation = policy.attestation lanRendezvous = policy.lanRendezvous @@ -313,15 +285,6 @@ public actor CmxIrohHostRuntime { ) var publishedPolicy = policy if requiresRelayReadiness { - if let relayCoordinator { - try await relayCoordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: endpointID, - bootstrap: policy.relayBootstrap, - waitForInitialCredential: true - ) - } - try requireCurrent(revision) guard await connectivityEngine.hasConfiguredRelay() else { throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut } @@ -423,29 +386,15 @@ public actor CmxIrohHostRuntime { // publication inside the refresh round. scheduleRegistrationRefresh(revision: revision) } - // The ready gate activates the relay, waits for a usable - // home relay, and then runs the round that performs the - // deferred first publication with fresh path hints. - scheduleInitialPublication( - binding: publishedPolicy.binding, - endpointID: endpointID, - bootstrap: publishedPolicy.relayBootstrap, - revision: revision - ) + // The ready gate waits for a usable home relay and then + // runs the round that performs the deferred first + // publication with fresh path hints. + scheduleInitialPublication(revision: revision) } } else if registrationRefreshPending { registrationRefreshPending = false scheduleRegistrationRefresh(revision: revision) } - if let relayCoordinator, !requiresRelayReadiness, publishedFreshBinding { - scheduleRelayActivation( - relayCoordinator, - binding: policy.binding, - endpointID: endpointID, - bootstrap: policy.relayBootstrap, - revision: revision - ) - } scheduleLANPublication( binding: publishedPolicy.binding, rendezvous: publishedPolicy.lanRendezvous, @@ -642,57 +591,22 @@ public actor CmxIrohHostRuntime { engine: CmxConnectivityEngine ) async -> Bool { if await engine.hasUsableHomeRelay() { return true } - guard relayCoordinator == nil else { return false } return !(await engine.hasConfiguredRelay()) } - func scheduleInitialPublication( - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) { + func scheduleInitialPublication(revision: UInt64) { initialPublicationTask?.cancel() initialPublicationTask = Task { [weak self] in - await self?.runInitialPublication( - binding: binding, - endpointID: endpointID, - bootstrap: bootstrap, - revision: revision - ) + await self?.runInitialPublication(revision: revision) } } - private func runInitialPublication( - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) async { - guard lifecyclePhase == .active, - lifecycleRevision == revision, - !Task.isCancelled else { return } - if let coordinator = relayCoordinator { - // Credential installation happens before the readiness wait so a - // cached relay bootstrap makes the home relay usable without a - // broker round. The coordinator owns bounded retry on failure. - try? await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: endpointID, - bootstrap: bootstrap - ) - } + private func runInitialPublication(revision: UInt64) async { guard lifecyclePhase == .active, lifecycleRevision == revision, !Task.isCancelled else { return } guard let connectivityEngine else { return } - let relayExpected: Bool - if relayCoordinator != nil { - relayExpected = true - } else { - relayExpected = await connectivityEngine.hasConfiguredRelay() - } - if relayExpected { + if await connectivityEngine.hasConfiguredRelay() { // The Mac must never be discoverable-but-undialable: a readiness // timeout keeps the endpoint unpublished and retries the wait with // bounded backoff on the injected clock until a verified usable @@ -748,51 +662,6 @@ public actor CmxIrohHostRuntime { scheduleRegistrationRefresh(revision: revision) } - func scheduleRelayActivation( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) { - relayActivationTask?.cancel() - relayActivationTask = Task { [weak self] in - await self?.activateRelaySidecar( - coordinator, - binding: binding, - endpointID: endpointID, - bootstrap: bootstrap, - revision: revision - ) - } - } - - private func activateRelaySidecar( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) async { - guard lifecyclePhase == .active, - lifecycleRevision == revision, - relayCoordinator === coordinator, - !Task.isCancelled else { return } - do { - try await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: endpointID, - bootstrap: bootstrap - ) - } catch { - // The coordinator owns bounded retry. A verified direct route stays - // authoritative when relay credential installation is unavailable. - } - if relayCoordinator === coordinator { - relayActivationTask = nil - } - } - func scheduleLANPublication( binding: CmxIrohBrokerBindingMetadata, rendezvous: CmxIrohLANRendezvous, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift index f0280a4821fb..bd778a02dc93 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift @@ -21,7 +21,6 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { /// /// `nil` preserves automatic use of the complete managed fleet. public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - public let cachedRelayCredential: CmxIrohRelayTokenResponse? /// A previously verified last-good policy. When it still verifies for this /// exact binding it activates the host immediately (cache-first) while the /// live broker resolve reconciles in the background; it also remains the @@ -43,7 +42,6 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { /// - bindPolicy: The UDP bind behavior, ephemeral by default. /// - managedRelayURLs: The exact managed relay allowlist. /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated relay bootstrap for this endpoint. /// - cachedHostPolicy: A policy previously verified by ``CmxIrohHostPolicyCache``. public init( accountID: String, @@ -58,7 +56,6 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, managedRelayURLs: Set, endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, cachedHostPolicy: CmxIrohCachedHostPolicy? = nil ) { self.accountID = accountID @@ -73,7 +70,16 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { self.bindPolicy = bindPolicy self.managedRelayURLs = managedRelayURLs self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential self.cachedHostPolicy = cachedHostPolicy } } + +extension CmxIrohHostRuntimeConfiguration { + func resolvedEndpointRelayProfile( + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try endpointRelayProfile + ?? CmxIrohEndpointRelayProfile(managedRelayURLs: managedRelayURLs) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift index cdc6867eb366..7092f4d02fd4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift @@ -149,11 +149,6 @@ actor CmxIrohLibEndpoint: CmxIrohEndpoint { return try CmxIrohLibConnection(driver: await accepting.connect()) } - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - let profile = try relayProfile.replacingManagedRelays(relays) - try await replaceRelayProfile(profile) - } - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { if transportVerificationMode == .directOnly { relayProfile = profile @@ -163,14 +158,10 @@ actor CmxIrohLibEndpoint: CmxIrohEndpoint { let next = Dictionary( uniqueKeysWithValues: profile.activeRelays.map { ($0.url, $0) } ) - let now = Date() for relay in profile.activeRelays { guard profile.allowedRelayURLs.contains(relay.url) else { throw CmxIrohLibError.unmanagedRelayURL(relay.url) } - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } } let previous = relayConfigurations diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift index 4c1f6aff52e4..8ed0cd118224 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift @@ -47,11 +47,7 @@ public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { ) async throws -> Endpoint { let relayMap = RelayMap.empty() if transportVerificationMode != .directOnly { - let now = Date() for relay in configuration.relayProfile.activeRelays { - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } try relayMap.insert(config: CmxIrohLibEndpoint.relayConfig(relay)) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift index 694ca4f48243..d581ed279e5b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift @@ -3,7 +3,6 @@ public enum CmxIrohLibError: Error, Equatable, Sendable { case invalidEndpointIdentity case remoteIdentityMismatch case unmanagedRelayURL(String) - case expiredRelayCredential(String) case unsupportedRelayIdentifier case unexpectedALPN case invalidReceiveLimit(Int) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift deleted file mode 100644 index d45520df91dc..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift +++ /dev/null @@ -1,55 +0,0 @@ -/// One broker-issued credential associated with one exact managed relay URL. -public struct CmxIrohManagedRelayCredential: Codable, Equatable, Sendable, - CustomStringConvertible, CustomDebugStringConvertible -{ - /// The exact canonical relay URL covered by this credential. - public let relayURL: String - - /// The opaque relay authentication token. - public let token: String - - /// The provider-enforced expiry in ISO 8601 format. - public let expiresAt: String - - /// The replacement time in ISO 8601 format. - public let refreshAfter: String - - /// Creates one URL-bound managed relay credential. - /// - /// Structural and lifetime validation is centralized in - /// ``CmxIrohRelayTokenResponse/relayConfigurations(now:)`` so network and - /// restored credentials follow the same validation path. - /// - /// - Parameters: - /// - relayURL: The exact managed relay URL covered by the token. - /// - token: The provider-issued opaque relay token. - /// - expiresAt: The provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The replacement time in ISO 8601 format. - public init( - relayURL: String, - token: String, - expiresAt: String, - refreshAfter: String - ) { - self.relayURL = relayURL - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - /// A log-safe representation that never includes the opaque token. - public var description: String { - "CmxIrohManagedRelayCredential(relayURL: \(relayURL), token: , " - + "expiresAt: \(expiresAt), refreshAfter: \(refreshAfter))" - } - - /// A debug representation that never includes the opaque token. - public var debugDescription: String { description } - - private enum CodingKeys: String, CodingKey { - case relayURL = "relay_url" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift deleted file mode 100644 index c7f274a5a67b..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Relay credential and signed policy returned by one bootstrap request. -public struct CmxIrohRelayBootstrapResponse: Equatable, Sendable { - /// Managed relay credential, absent for custom or direct-only preferences. - public let relayToken: CmxIrohRelayTokenResponse? - - /// Signed policy and account preference resolved by the broker. - public let relayPolicy: CmxIrohRelayPolicyResponse - - /// Creates one validated bootstrap response. - public init( - relayToken: CmxIrohRelayTokenResponse?, - relayPolicy: CmxIrohRelayPolicyResponse - ) { - self.relayToken = relayToken - self.relayPolicy = relayPolicy - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift deleted file mode 100644 index 0ec058c4a124..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift +++ /dev/null @@ -1,87 +0,0 @@ -public import Foundation - -/// A short-lived endpoint-scoped credential for one managed relay. -public struct CmxIrohRelayConfiguration: Equatable, Sendable { - /// The exact canonical relay URL accepted by the app configuration. - public let url: String - - /// The compact JWT, or pre-migration RCAN, used as Iroh's relay auth token. - public let token: String - - /// The hard time after which the relay must reject the token. - public let expiresAt: Date - - /// The time at which cmux should obtain a replacement before expiry. - public let refreshAfter: Date - - /// Creates a validated managed-relay configuration. - /// - /// - Parameters: - /// - url: A canonical HTTPS relay origin with a trailing slash. - /// - token: A compact Base64URL JWT or legacy lowercase Base32 RCAN. - /// - expiresAt: The provider-enforced token expiry. - /// - refreshAfter: A replacement time strictly before expiry. - /// - now: The validation time, injected for deterministic tests. - /// - Throws: ``CmxIrohRelayConfigurationError`` for malformed or expired input. - public init( - url: String, - token: String, - expiresAt: Date, - refreshAfter: Date, - now: Date - ) throws { - guard Self.isCanonicalRelayURL(url) else { - throw CmxIrohRelayConfigurationError.invalidURL - } - guard (1 ... 8 * 1_024).contains(token.utf8.count), - Self.isCompactJWT(token) || Self.isLegacyRCAN(token) else { - throw CmxIrohRelayConfigurationError.invalidToken - } - guard now < refreshAfter, refreshAfter < expiresAt else { - throw CmxIrohRelayConfigurationError.invalidLifetime - } - self.url = url - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - private static func isBase64URLByte(_ byte: UInt8) -> Bool { - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "A") ... UInt8(ascii: "Z")).contains(byte) - || (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte) - || byte == UInt8(ascii: "-") - || byte == UInt8(ascii: "_") - } - - private static func isCompactJWT(_ value: String) -> Bool { - let segments = value.split(separator: ".", omittingEmptySubsequences: false) - return segments.count == 3 && segments.allSatisfy { segment in - !segment.isEmpty && segment.utf8.allSatisfy(Self.isBase64URLByte) - } - } - - private static func isLegacyRCAN(_ value: String) -> Bool { - value.utf8.allSatisfy { byte in - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "2") ... UInt8(ascii: "7")).contains(byte) - } - } - - 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 - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift deleted file mode 100644 index 8c40f7bad709..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Validation failures for a managed Iroh relay credential. -public enum CmxIrohRelayConfigurationError: Error, Equatable, Sendable { - /// The relay URL is not a canonical HTTPS origin ending in `/`. - case invalidURL - - /// The RCAN token is empty, too large, or not lowercase unpadded Base32. - case invalidToken - - /// The token expiry or refresh schedule is already invalid when decoded. - case invalidLifetime -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift deleted file mode 100644 index 5e39c1d90275..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +++ /dev/null @@ -1,640 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Keeps endpoint-scoped relay credentials fresh without recreating the endpoint. -public actor CmxIrohRelayCredentialCoordinator { - private static let minimumUsefulValidity: TimeInterval = 10 - private static let postExpiryRetryDelay: TimeInterval = 1 - - private struct Binding: Equatable, Sendable { - let id: String - let endpointIdentity: CmxIrohPeerIdentity - } - - private struct InstalledCredential: Equatable, Sendable { - let refreshAfter: Date - let expiresAt: Date - } - - private struct PendingPersistence: Sendable { - let response: CmxIrohRelayTokenResponse - let binding: Binding - let revision: UInt64 - } - - private struct InFlightRefresh { - let id: UUID - let task: Task - } - - private let supervisor: any CmxIrohRelayEndpointControlling - private let broker: any CmxIrohRelayTokenServing - private let managedRelayURLs: Set - private let selectedRelayURLs: Set - private let clock: any CmxIrohRelayClock - private let jitter: @Sendable (_ now: Date, _ refreshAfter: Date) -> Date - private let retrySchedule: CmxIrohRetrySchedule - private let retryJitter: @Sendable () -> Double - private let automaticRefreshEnabled: Bool - private let credentialDidInstall: @Sendable (CmxIrohRelayTokenResponse) async -> Void - private var binding: Binding? - private var installedCredential: InstalledCredential? - private var lifecycleRevision: UInt64 = 0 - private var refreshTask: Task? - private var inFlightRefresh: InFlightRefresh? - private var persistenceTask: Task? - private var pendingPersistence: PendingPersistence? - - /// Creates an inactive relay credential coordinator. - public init( - supervisor: any CmxIrohRelayEndpointControlling, - broker: any CmxIrohRelayTokenServing, - managedRelayURLs: Set, - selectedRelayURLs: Set? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - jitter: @escaping @Sendable (_ now: Date, _ refreshAfter: Date) -> Date = { - now, - refreshAfter in - let window = min(30, max(0, refreshAfter.timeIntervalSince(now))) - return refreshAfter.addingTimeInterval(-Double.random(in: 0 ... window)) - }, - retrySchedule: CmxIrohRetrySchedule = CmxIrohRetrySchedule(), - retryJitter: @escaping @Sendable () -> Double = { - Double.random(in: 0 ... 1) - }, - automaticRefreshEnabled: Bool = true, - credentialDidInstall: @escaping @Sendable ( - CmxIrohRelayTokenResponse - ) async -> Void = { _ in } - ) { - self.supervisor = supervisor - self.broker = broker - self.managedRelayURLs = managedRelayURLs - self.selectedRelayURLs = selectedRelayURLs ?? managedRelayURLs - self.clock = clock - self.jitter = jitter - self.retrySchedule = retrySchedule - self.retryJitter = retryJitter - self.automaticRefreshEnabled = automaticRefreshEnabled - self.credentialDidInstall = credentialDidInstall - } - - /// Starts refresh scheduling for one exact registered endpoint binding. - /// - /// A bootstrap credential is installed before scheduling. Bootstrap - /// validation failure is returned while an immediate broker retry is - /// scheduled by default. Relay-required callers instead wait through the - /// same bounded-backoff schedule until one credential installs or activation - /// is cancelled. - public func activate( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse? = nil, - waitForInitialCredential: Bool = false - ) async throws { - let (expectedBinding, revision) = beginActivation( - bindingID: bindingID, - endpointIdentity: endpointIdentity - ) - - if let bootstrap { - do { - let installed = try await install( - bootstrap, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - return - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - if waitForInitialCredential { - try await installInitialCredentialAfterRetry( - binding: expectedBinding, - revision: revision, - firstRetry: nil, - initialFailureCount: 0 - ) - } else { - startLoopIfEnabled(revision: revision, firstRefresh: nil) - throw error - } - return - } - } - do { - let response = try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointIdentity - ) - let installed = try await install( - response, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: 0, error: error) - let firstRetry = retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - if waitForInitialCredential { - try await installInitialCredentialAfterRetry( - binding: expectedBinding, - revision: revision, - firstRetry: firstRetry, - initialFailureCount: 1 - ) - } else { - startLoopIfEnabled( - revision: revision, - firstRefresh: firstRetry, - initialFailureCount: 1 - ) - } - } - } - - /// Replaces one live managed relay policy and starts credential refresh. - /// - /// The coordinator owns the endpoint mutation so a policy bootstrap is - /// installed exactly once. This preserves active QUIC sessions while the - /// endpoint's relay client adopts the replacement credentials. - /// - /// - Parameters: - /// - bindingID: The broker binding that owns the endpoint. - /// - endpointIdentity: The pinned endpoint identity being updated. - /// - profile: The complete managed relay profile to install. - /// - bootstrap: Credentials already represented by `profile`, when available. - /// - Throws: A policy mismatch, endpoint mutation failure, or cancellation. - public func activateManagedPolicy( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity, - profile: CmxIrohEndpointRelayProfile, - bootstrap: CmxIrohRelayTokenResponse? - ) async throws { - guard profile.source == .managed, - !selectedRelayURLs.isEmpty, - selectedRelayURLs.isSubset(of: managedRelayURLs), - profile.allowedRelayURLs == selectedRelayURLs else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - - let bootstrapInstallation: ( - response: CmxIrohRelayTokenResponse, - configurations: [CmxIrohRelayConfiguration] - )? = try bootstrap.map { response in - let selectedConfigurations = try validatedSelectedConfigurations(response) - guard profile.managedRelays.count == selectedConfigurations.count, - profile.managedRelays.allSatisfy(selectedConfigurations.contains) else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - return (response, selectedConfigurations) - } - - let (expectedBinding, revision) = beginActivation( - bindingID: bindingID, - endpointIdentity: endpointIdentity - ) - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: endpointIdentity - ) - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - - if let bootstrapInstallation { - let installed = try recordInstallation( - bootstrapInstallation.response, - selectedConfigurations: bootstrapInstallation.configurations, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - return - } - - do { - let response = try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointIdentity - ) - let installed = try await install( - response, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: 0, error: error) - startLoopIfEnabled( - revision: revision, - firstRefresh: retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ), - initialFailureCount: 1 - ) - } - } - - private func beginActivation( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity - ) -> (Binding, UInt64) { - lifecycleRevision &+= 1 - let revision = lifecycleRevision - refreshTask?.cancel() - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity) - binding = expectedBinding - installedCredential = nil - return (expectedBinding, revision) - } - - private func installInitialCredentialAfterRetry( - binding: Binding, - revision: UInt64, - firstRetry: Date?, - initialFailureCount: Int - ) async throws { - var deadline = firstRetry - var failureCount = initialFailureCount - while isCurrent(revision), !Task.isCancelled { - if let deadline { - try await clock.sleep(until: deadline) - } - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - startLoopIfEnabled( - revision: revision, - firstRefresh: installed.refreshAfter - ) - return - } catch is CancellationError { - throw CancellationError() - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: failureCount, error: error) - deadline = retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - failureCount = min(failureCount + 1, 20) - } - } - throw CancellationError() - } - - /// Cancels all scheduled refresh work and forgets binding-scoped state. - public func deactivate() { - lifecycleRevision &+= 1 - refreshTask?.cancel() - refreshTask = nil - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - persistenceTask?.cancel() - persistenceTask = nil - pendingPersistence = nil - binding = nil - installedCredential = nil - } - - /// Returns the hard expiry of the last successfully installed credential. - public func credentialExpiresAt() -> Date? { - installedCredential?.expiresAt - } - - /// Immediately catches up a missing or refresh-due relay credential. - /// - /// iOS suspends task scheduling in the background, so the ordinary sleep - /// loop may not run before an installed credential expires. Foreground - /// connection readiness calls this method before dialing. Concurrent - /// callers share one mint-and-install operation, and a failure preserves - /// the existing endpoint while resuming the bounded retry loop. - public func refreshIfNeeded() async throws { - guard let binding else { - throw CmxIrohRelayCredentialCoordinatorError.inactive - } - guard automaticRefreshEnabled else { return } - let now = clock.now() - if let installedCredential, - now < installedCredential.refreshAfter, - installedCredential.expiresAt.timeIntervalSince(now) - > Self.minimumUsefulValidity { - return - } - let revision = lifecycleRevision - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - refreshTask?.cancel() - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - refreshTask?.cancel() - let delay = retryDelay(failureCount: 0, error: error) - startLoopIfEnabled( - revision: revision, - firstRefresh: retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ), - initialFailureCount: 1 - ) - // Fail open while a last-good credential is installed on the - // endpoint: a failed mint must not fail the caller's foreground - // path into zero-route dial churn (cmux#10375). The bounded retry - // loop above keeps refreshing in the background, and the relay is - // the authority that rejects a credential that truly went bad. - if installedCredential != nil, !(error is CancellationError) { - return - } - throw error - } - } - - private func startLoopIfEnabled( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int = 0 - ) { - guard automaticRefreshEnabled else { return } - refreshTask = Task { [weak self] in - await self?.run( - revision: revision, - firstRefresh: firstRefresh, - initialFailureCount: initialFailureCount - ) - } - } - - private func run( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int - ) async { - var deadline = firstRefresh - var failureCount = initialFailureCount - while isCurrent(revision) { - if let deadline { - do { - try await clock.sleep(until: deadline) - } catch { - return - } - } - guard isCurrent(revision), !Task.isCancelled, let binding else { return } - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - failureCount = 0 - deadline = installed.refreshAfter - } catch is CancellationError { - return - } catch { - guard isCurrent(revision), !Task.isCancelled else { return } - let now = clock.now() - let delay = retryDelay(failureCount: failureCount, error: error) - deadline = retryDeadline( - now: now, - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - failureCount = min(failureCount + 1, 20) - } - } - } - - private func refreshCredential( - binding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - if let inFlightRefresh { - return try await inFlightRefresh.task.value - } - let refreshID = UUID() - let task = Task { [weak self] in - guard let self else { throw CancellationError() } - let response = try await self.broker.issueRelayToken( - bindingID: binding.id, - endpointID: binding.endpointIdentity - ) - return try await self.install( - response, - binding: binding, - revision: revision - ) - } - inFlightRefresh = InFlightRefresh(id: refreshID, task: task) - do { - let installed = try await task.value - clearInFlightRefresh(id: refreshID) - return installed - } catch { - clearInFlightRefresh(id: refreshID) - throw error - } - } - - private func clearInFlightRefresh(id: UUID) { - guard inFlightRefresh?.id == id else { return } - inFlightRefresh = nil - } - - /// Keeps refresh retries inside the useful lifetime of an installed token. - /// - /// Exponential backoff alone can place the first retry at expiry because - /// five-minute relay tokens refresh only one minute early. Halving the - /// remaining lifetime preserves multiple bounded attempts. Once too little - /// validity remains for a useful mint-and-install round trip, retry just - /// after expiry and reset the backoff instead of growing a long outage. - private func retryDeadline( - now: Date, - backoff: TimeInterval, - honorsServerFloor: Bool - ) -> Date { - if honorsServerFloor { - return now.addingTimeInterval(backoff) - } - guard let expiresAt = installedCredential?.expiresAt, - now < expiresAt else { - return now.addingTimeInterval(backoff) - } - let remainingValidity = expiresAt.timeIntervalSince(now) - guard remainingValidity > Self.minimumUsefulValidity else { - return expiresAt.addingTimeInterval(Self.postExpiryRetryDelay) - } - return min( - now.addingTimeInterval(backoff), - now.addingTimeInterval(remainingValidity / 2) - ) - } - - private func retryDelay(failureCount: Int, error: any Error) -> TimeInterval { - retrySchedule.delay( - failureCount: failureCount, - retryAfterSeconds: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds, - jitterUnitInterval: retryJitter() - ) - } - - private func install( - _ response: CmxIrohRelayTokenResponse, - binding expectedBinding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - let selectedConfigurations = try validatedSelectedConfigurations(response) - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - if selectedRelayURLs == managedRelayURLs { - try await supervisor.replaceRelays( - selectedConfigurations, - expectedIdentity: expectedBinding.endpointIdentity - ) - } else { - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: selectedRelayURLs, - relays: selectedConfigurations - ) - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: expectedBinding.endpointIdentity - ) - } - return try recordInstallation( - response, - selectedConfigurations: selectedConfigurations, - binding: expectedBinding, - revision: revision - ) - } - - private func validatedSelectedConfigurations( - _ response: CmxIrohRelayTokenResponse - ) throws -> [CmxIrohRelayConfiguration] { - guard response.relayFleet.count == managedRelayURLs.count, - Set(response.relayFleet) == managedRelayURLs else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - let configurations = try response.relayConfigurations(now: clock.now()) - let selectedConfigurations = configurations.filter { - selectedRelayURLs.contains($0.url) - } - guard !selectedRelayURLs.isEmpty, - selectedConfigurations.count == selectedRelayURLs.count, - selectedRelayURLs.isSubset(of: managedRelayURLs) else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - return selectedConfigurations - } - - private func recordInstallation( - _ response: CmxIrohRelayTokenResponse, - selectedConfigurations: [CmxIrohRelayConfiguration], - binding expectedBinding: Binding, - revision: UInt64 - ) throws -> InstalledCredential { - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding, - let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(), - let expiresAt = selectedConfigurations.map(\.expiresAt).min() else { - throw CancellationError() - } - let installed = InstalledCredential( - refreshAfter: scheduledRefresh(refreshAfter), - expiresAt: expiresAt - ) - installedCredential = installed - enqueuePersistence( - response: response, - binding: expectedBinding, - revision: revision - ) - return installed - } - - /// Persists only the newest installed credential on one cancellable serial lane. - /// Runtime installation and refresh scheduling never await secure storage. - private func enqueuePersistence( - response: CmxIrohRelayTokenResponse, - binding: Binding, - revision: UInt64 - ) { - pendingPersistence = PendingPersistence( - response: response, - binding: binding, - revision: revision - ) - guard persistenceTask == nil else { return } - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - - private func runPersistenceQueue() async { - while !Task.isCancelled, let next = pendingPersistence { - pendingPersistence = nil - guard isCurrent(next.revision), binding == next.binding else { continue } - await credentialDidInstall(next.response) - } - persistenceTask = nil - if pendingPersistence != nil, !Task.isCancelled { - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - } - - private func scheduledRefresh(_ refreshAfter: Date) -> Date { - let now = clock.now() - let candidate = jitter(now, refreshAfter) - return min(refreshAfter, max(now, candidate)) - } - - private func isCurrent(_ revision: UInt64) -> Bool { - lifecycleRevision == revision - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift deleted file mode 100644 index cdc52cc72f47..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Relay credential scheduling failures owned by the app transport layer. -public enum CmxIrohRelayCredentialCoordinatorError: Error, Equatable, Sendable { - case inactive - case relayFleetMismatch -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift deleted file mode 100644 index ff893afe79de..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift +++ /dev/null @@ -1,18 +0,0 @@ -public import CMUXMobileCore - -/// Endpoint relay mutations required by the credential coordinator. -public protocol CmxIrohRelayEndpointControlling: Sendable { - /// Replaces managed relay credentials on the expected endpoint identity. - func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws - - /// Replaces the complete relay profile on the expected endpoint identity. - func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - expectedIdentity: CmxIrohPeerIdentity - ) async throws -} - -extension CmxIrohEndpointSupervisor: CmxIrohRelayEndpointControlling {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift index c73aa1da6803..2560e0ccc89c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift @@ -26,7 +26,4 @@ public enum CmxIrohRelayPolicyFailure: String, Codable, Equatable, Sendable { /// The server committed an account change that this device could not cache. case preferencePersistenceUnavailable - - /// The signed managed allowlist is active without a usable current token. - case managedCredentialUnavailable } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift index 662489f42d4d..29642b03a238 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift @@ -13,7 +13,6 @@ enum CmxIrohRelayPolicyResolution { configuration: CmxIrohAccountRelayConfiguration, revision: Int64, policy: CmxIrohManagedRelayPolicy?, - relayCredential: CmxIrohRelayTokenResponse?, accountID: String, credentialStore: CmxIrohCustomRelayCredentialStore, usedCachedPolicy: Bool, @@ -35,11 +34,9 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: configuration, effectivePreference: .automatic, policy: policy, - credential: relayCredential, staleRelayIDs: [], revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now + usedCachedPolicy: usedCachedPolicy ) case let .managed(requestedIDs): guard let policy else { @@ -69,11 +66,9 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: configuration, effectivePreference: .managed(surviving), policy: policy, - credential: relayCredential, staleRelayIDs: stale, revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now + usedCachedPolicy: usedCachedPolicy ) case let .custom(definitions): let tokens: [String: String] @@ -154,30 +149,13 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: CmxIrohAccountRelayConfiguration, effectivePreference: CmxIrohAccountRelayPreference, policy: CmxIrohManagedRelayPolicy, - credential: CmxIrohRelayTokenResponse?, staleRelayIDs: Set, revision: Int64, - usedCachedPolicy: Bool, - now: Date + usedCachedPolicy: Bool ) -> Resolution { do { let snapshot = try CmxIrohRelayPolicySnapshot(policy: policy, selection: selection) - var selectedCredentials: [CmxIrohRelayConfiguration] = [] - var failure: CmxIrohRelayPolicyFailure? - var relayBootstrap: CmxIrohRelayTokenResponse? - if let credential, - Set(credential.relayFleet) == Set(policy.relays.map(\.url)), - credential.relayFleet.count == policy.relays.count, - let configurations = try? credential.relayConfigurations(now: now) { - selectedCredentials = configurations.filter { snapshot.relayURLs.contains($0.url) } - relayBootstrap = credential - } else { - failure = .managedCredentialUnavailable - } - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: snapshot.relayURLs, - relays: selectedCredentials - ) + let profile = try CmxIrohEndpointRelayProfile(snapshot: snapshot) return Resolution( effective: CmxIrohEffectiveRelayPolicy( endpointRelayProfile: profile, @@ -188,10 +166,9 @@ enum CmxIrohRelayPolicyResolution { staleRelayIDs: staleRelayIDs, source: .managed, usedCachedPolicy: usedCachedPolicy, - preferenceRevision: revision, - relayBootstrap: relayBootstrap + preferenceRevision: revision ), - failure: failure + failure: nil ) } catch { return unavailableResolution( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift index 408f2305caaa..046b5d1b2988 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift @@ -39,53 +39,21 @@ public actor CmxIrohRelayPolicyService { self.expiredPolicyReuseGrace = max(0, expiredPolicyReuseGrace) } - /// Fetches and installs the broker's current relay bootstrap response. + /// Fetches and installs the broker's current signed relay policy. @discardableResult public func refresh( - endpointID: CmxIrohPeerIdentity, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { - try await refreshWithCredential( - endpointID: endpointID, - accountID: accountID, - trustRoot: trustRoot, - now: now - ).effective - } - - /// One resolved bootstrap: the effective policy plus the broker-minted - /// relay credential from the same response, so activation can install the - /// credential without a second mint request. - public struct RefreshOutcome: Sendable { - public let effective: CmxIrohEffectiveRelayPolicy - public let relayCredential: CmxIrohRelayTokenResponse? - } - - /// Fetches and installs the broker's current relay bootstrap response, - /// returning the minted credential alongside the effective policy. - public func refreshWithCredential( - endpointID: CmxIrohPeerIdentity, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date = Date() - ) async throws -> RefreshOutcome { guard let broker else { throw CmxIrohRelayPolicyServiceError.brokerUnavailable } - let bootstrap = try await broker.issueRelayBootstrap(endpointID: endpointID) - let effective = try await install( - response: bootstrap.relayPolicy, + let response = try await broker.fetchRelayPolicy() + return try await install( + response: response, accountID: accountID, trustRoot: trustRoot, - relayCredential: bootstrap.relayToken, now: now ) - return RefreshOutcome( - effective: effective, - // Return only the credential accepted by policy resolution. A - // rejected bootstrap must not displace a valid cached credential. - relayCredential: effective.relayBootstrap - ) } /// Verifies and resolves one broker response without replacing last-known-good @@ -95,7 +63,6 @@ public actor CmxIrohRelayPolicyService { response: CmxIrohRelayPolicyResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -116,7 +83,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.preferenceRevision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: false, @@ -154,7 +120,6 @@ public actor CmxIrohRelayPolicyService { public func restore( accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -191,7 +156,6 @@ public actor CmxIrohRelayPolicyService { configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: nil, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, @@ -230,7 +194,6 @@ public actor CmxIrohRelayPolicyService { configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: true, @@ -270,7 +233,6 @@ public actor CmxIrohRelayPolicyService { _ preference: CmxIrohAccountRelayPreference, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let current: CmxIrohAccountRelayConfiguration @@ -284,7 +246,6 @@ public actor CmxIrohRelayPolicyService { current.updatingActivePreference(preference), accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now ) } @@ -297,7 +258,6 @@ public actor CmxIrohRelayPolicyService { _ configuration: CmxIrohAccountRelayConfiguration, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -322,7 +282,6 @@ public actor CmxIrohRelayPolicyService { authoritative, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -333,7 +292,6 @@ public actor CmxIrohRelayPolicyService { response, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -359,7 +317,6 @@ public actor CmxIrohRelayPolicyService { _ response: CmxIrohRelayPreferenceResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date, operation: UInt64 ) async throws -> CmxIrohEffectiveRelayPolicy { @@ -375,7 +332,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift index 0474fb51ee6b..5e3d6115fde1 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift @@ -3,9 +3,6 @@ public enum CmxIrohRelayPolicyServiceError: Error, Equatable, Sendable { /// No broker was injected for a network-backed operation. case brokerUnavailable - /// A managed bootstrap omitted its endpoint-scoped relay credential. - case managedCredentialUnavailable - /// A preference revision rolled back or equivocated. case preferenceRollback diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift index 6387636c0bd2..3616d7c087ee 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift @@ -1,11 +1,7 @@ -public import CMUXMobileCore - /// Authenticated broker operations used by the relay policy service. public protocol CmxIrohRelayPolicyServing: Sendable { - /// Issues endpoint-scoped relay bootstrap material. - func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse + /// Fetches the signed managed relay policy and account preference. + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse /// Fetches the current account relay preference. func relayPreference() async throws -> CmxIrohRelayPreferenceResponse diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift deleted file mode 100644 index c75242039988..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift +++ /dev/null @@ -1,47 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Assigns endpoint-stable, non-overlapping relay credential refresh slots. -struct CmxIrohRelayRefreshSchedule: Sendable { - enum Role: Sendable { - case host - case client - - fileprivate var phaseStart: Int { - switch self { - case .host: 0 - case .client: 30 - } - } - } - - private static let phaseWidth = 15 - private static let minuteDuration: TimeInterval = 60 - private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037 - private static let fnvPrime: UInt64 = 1_099_511_628_211 - - private let secondWithinMinute: Int - - init(role: Role, endpointIdentity: CmxIrohPeerIdentity) { - var hash = Self.fnvOffsetBasis - for byte in endpointIdentity.endpointID.utf8 { - hash ^= UInt64(byte) - hash &*= Self.fnvPrime - } - secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth)) - } - - func deadline(now: Date, refreshAfter: Date) -> Date { - let refreshEpoch = refreshAfter.timeIntervalSince1970 - let minuteStart = floor(refreshEpoch / Self.minuteDuration) - * Self.minuteDuration - var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute) - if candidateEpoch > refreshEpoch { - candidateEpoch -= Self.minuteDuration - } - return min( - refreshAfter, - max(now, Date(timeIntervalSince1970: candidateEpoch)) - ) - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift deleted file mode 100644 index 2d18da34bc70..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift +++ /dev/null @@ -1,115 +0,0 @@ -public import Foundation - -/// Endpoint-scoped credentials for one exact managed relay fleet. -public struct CmxIrohRelayTokenResponse: Codable, Equatable, Sendable { - /// URL-keyed relay credentials returned by the broker. - public let credentials: [CmxIrohManagedRelayCredential] - - /// The complete ordered managed relay fleet covered by the response. - public var relayFleet: [String] { - credentials.map(\.relayURL) - } - - /// Creates a response containing independently issued relay credentials. - /// - /// - Parameter credentials: One credential for every signed managed relay. - public init(credentials: [CmxIrohManagedRelayCredential]) { - self.credentials = credentials - } - - /// Creates a legacy homogeneous-fleet response for cache and API migration. - /// - /// New broker responses should use ``init(credentials:)``. This initializer - /// remains so a single legacy token can be expanded into the URL-keyed model. - /// - /// - Parameters: - /// - token: One token accepted by every relay in `relayFleet`. - /// - expiresAt: The shared provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The shared replacement time in ISO 8601 format. - /// - relayFleet: The complete managed relay fleet covered by the token. - public init( - token: String, - expiresAt: String, - refreshAfter: String, - relayFleet: [String] - ) { - credentials = relayFleet.map { - CmxIrohManagedRelayCredential( - relayURL: $0, - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter - ) - } - } - - /// Decodes the URL-keyed wire format or the legacy homogeneous-fleet format. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.credentials) { - self.init( - credentials: try container.decode( - [CmxIrohManagedRelayCredential].self, - forKey: .credentials - ) - ) - return - } - let token = try container.decode(String.self, forKey: .token) - let expiresAt = try container.decode(String.self, forKey: .expiresAt) - let refreshAfter = try container.decode(String.self, forKey: .refreshAfter) - let relayFleet = try container.decode([String].self, forKey: .relayFleet) - self.init( - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - relayFleet: relayFleet - ) - } - - /// Encodes only the URL-keyed format so newly persisted state is unambiguous. - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(credentials, forKey: .credentials) - } - - /// Validates every URL-token association and creates endpoint credentials. - /// - /// - Parameter now: The validation time. - /// - Returns: One configuration for every unique relay URL. - /// - Throws: A coarse invalid-response error for malformed, stale, duplicate, - /// or over-sized credential sets. - public func relayConfigurations(now: Date) throws -> [CmxIrohRelayConfiguration] { - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ), - Set(credentials.map(\.relayURL)).count == credentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - do { - return try credentials.map { credential in - guard let expiresAt = CmxIrohISO8601Date.parse(credential.expiresAt), - let refreshAfter = CmxIrohISO8601Date.parse(credential.refreshAfter) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return try CmxIrohRelayConfiguration( - url: credential.relayURL, - token: credential.token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - now: now - ) - } - } catch { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - } - - private enum CodingKeys: String, CodingKey { - case credentials = "relay_credentials" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - case relayFleet = "relay_fleet" - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift deleted file mode 100644 index 3b7e0e366aba..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift +++ /dev/null @@ -1,12 +0,0 @@ -public import CMUXMobileCore - -/// Narrow trust-broker boundary used by relay credential rotation. -public protocol CmxIrohRelayTokenServing: Sendable { - /// Issues a fresh endpoint-bound credential for the managed relay fleet. - func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohRelayTokenServing {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift deleted file mode 100644 index 2924156cdb56..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -extension CmxIrohHostRuntimeConfiguration { - func resolvedEndpointRelayProfile( - now: Date, - debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() - ) throws -> CmxIrohEndpointRelayProfile { - if let debugOverride { return debugOverride } - return try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -extension CmxIrohClientRuntimeConfiguration { - func resolvedEndpointRelayProfile( - now: Date, - debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() - ) throws -> CmxIrohEndpointRelayProfile { - if let debugOverride { return debugOverride } - return try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -private func resolveEndpointRelayProfile( - configured: CmxIrohEndpointRelayProfile?, - managedRelayURLs: Set, - cachedRelayCredential: CmxIrohRelayTokenResponse?, - now: Date -) throws -> CmxIrohEndpointRelayProfile { - let base = try configured ?? CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: [] - ) - guard base.source == .managed, - let cachedRelayCredential, - cachedRelayCredential.relayFleet.count == managedRelayURLs.count, - Set(cachedRelayCredential.relayFleet) == managedRelayURLs, - let cached = try? cachedRelayCredential.relayConfigurations(now: now) else { - return base - } - let selected = cached.filter { base.allowedRelayURLs.contains($0.url) } - guard selected.count == base.allowedRelayURLs.count else { return base } - return try base.replacingManagedRelays(selected) -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift deleted file mode 100644 index 7372cf490dd9..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -/// Versioned Keychain payload for one binding-scoped relay capability. -struct CmxIrohStoredRelayCredential: Codable, Equatable, Sendable { - static let currentVersion = 2 - - let version: Int - let binding: CmxIrohBrokerBindingMetadata - let response: CmxIrohRelayTokenResponse - - init( - binding: CmxIrohBrokerBindingMetadata, - response: CmxIrohRelayTokenResponse - ) { - version = Self.currentVersion - self.binding = binding - self.response = response - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let storedVersion = try container.decode(Int.self, forKey: .version) - binding = try container.decode(CmxIrohBrokerBindingMetadata.self, forKey: .binding) - switch storedVersion { - case 1: - response = CmxIrohRelayTokenResponse( - token: try container.decode(String.self, forKey: .token), - expiresAt: try container.decode(String.self, forKey: .expiresAt), - refreshAfter: try container.decode(String.self, forKey: .refreshAfter), - relayFleet: try container.decode([String].self, forKey: .relayFleet) - ) - case Self.currentVersion: - response = try container.decode( - CmxIrohRelayTokenResponse.self, - forKey: .response - ) - default: - throw DecodingError.dataCorruptedError( - forKey: .version, - in: container, - debugDescription: "Unsupported relay credential version" - ) - } - version = Self.currentVersion - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(version, forKey: .version) - try container.encode(binding, forKey: .binding) - try container.encode(response, forKey: .response) - } - - private enum CodingKeys: String, CodingKey { - case version - case binding - case response - case token - case expiresAt - case refreshAfter - case relayFleet - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index 970206b712f5..af52f63d8c14 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -230,41 +230,10 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } private struct BindingRequest: Encodable { let bindingId: String } - private struct EndpointRequest: Encodable { let endpointId: String } - private struct RelayAccessCredential: Decodable, Sendable { - let relayUrl: String - let token: String - let expiresAt: Int64 - let refreshAfter: Int64 - let ttlSeconds: Int64 - } - private struct RelayAccessResponse: Decodable, Sendable { - let token: String? - let expiresAt: Int64? - let ttlSeconds: Int64? - let relays: [String]? - let endpointId: String? - let relayCredentials: [RelayAccessCredential]? - let policy: String? - let preference: CmxIrohAccountRelayConfiguration? - let preferenceRevision: Int64? - } - private struct RelayTokenHeader: Decodable { - let alg: String - let typ: String - } - private struct RelayTokenClaims: Decodable { - let issuer: String - let audience: String - let expiresAt: Int64 - let endpointID: String - - private enum CodingKeys: String, CodingKey { - case issuer = "iss" - case audience = "aud" - case expiresAt = "exp" - case endpointID = "endpoint_id" - } + private struct RelayPolicyBootstrapResponse: Decodable, Sendable { + let policy: String + let preference: CmxIrohAccountRelayConfiguration + let preferenceRevision: Int64 } private struct PairGrantRequest: Encodable { let initiatorBindingId: String @@ -467,54 +436,22 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { ) } - public func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID), - operation: .relayCredential - ) - return try Self.relayTokenResponse(response, endpointID: endpointID) - } - - /// Issues a managed credential together with signed, server-driven relay policy. - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID), + /// Fetches the signed, server-driven relay policy for the account. + public func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + let response: RelayPolicyBootstrapResponse = try await sendWithoutBody( + path: "api/relay/policy", + method: "GET", operation: .relayCredential ) - guard let policy = response.policy, - let preference = response.preference, - let preferenceRevision = response.preferenceRevision else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let policyResponse: CmxIrohRelayPolicyResponse do { - policyResponse = try CmxIrohRelayPolicyResponse( - policy: policy, - preference: preference, - preferenceRevision: preferenceRevision + return try CmxIrohRelayPolicyResponse( + policy: response.policy, + preference: response.preference, + preferenceRevision: response.preferenceRevision ) } catch { throw CmxIrohTrustBrokerClientError.invalidResponse } - let relayToken: CmxIrohRelayTokenResponse? - if response.relayCredentials == nil, response.token == nil { - relayToken = nil - } else { - relayToken = try Self.relayTokenResponse(response, endpointID: endpointID) - } - return CmxIrohRelayBootstrapResponse( - relayToken: relayToken, - relayPolicy: policyResponse - ) } /// Fetches the current account relay preference. @@ -984,130 +921,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { return seconds } - private static func relayTokenResponse( - _ response: RelayAccessResponse, - endpointID: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - if let credentials = response.relayCredentials { - guard response.endpointId == endpointID.endpointID, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayCredentials = try credentials.map { credential in - guard (30 ... 24 * 60 * 60).contains(credential.ttlSeconds), - credential.expiresAt > credential.refreshAfter, - credential.refreshAfter - >= credential.expiresAt - credential.ttlSeconds, - (1 ... 8 * 1_024).contains(credential.token.utf8.count) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohManagedRelayCredential( - relayURL: try canonicalRelayOrigin(credential.relayUrl), - token: credential.token, - expiresAt: iso8601(epochSeconds: credential.expiresAt), - refreshAfter: iso8601(epochSeconds: credential.refreshAfter) - ) - } - guard Set(relayCredentials.map(\.relayURL)).count - == relayCredentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse(credentials: relayCredentials) - } - - guard let token = response.token, - let expiresAtSeconds = response.expiresAt, - let ttlSeconds = response.ttlSeconds, - let relays = response.relays, - ttlSeconds == 300, - expiresAtSeconds > ttlSeconds, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - relays.count - ), - validRelayToken( - token, - expiresAt: expiresAtSeconds, - endpointID: endpointID - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayFleet = try relays.map(canonicalRelayOrigin) - guard Set(relayFleet).count == relayFleet.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let refreshLead = min(60, ttlSeconds / 2) - return CmxIrohRelayTokenResponse( - token: token, - expiresAt: iso8601(epochSeconds: expiresAtSeconds), - refreshAfter: iso8601(epochSeconds: expiresAtSeconds - refreshLead), - relayFleet: relayFleet - ) - } - - private static func iso8601(epochSeconds: Int64) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string( - from: Date(timeIntervalSince1970: TimeInterval(epochSeconds)) - ) - } - - private static func validRelayToken( - _ token: String, - expiresAt: Int64, - endpointID: CmxIrohPeerIdentity - ) -> Bool { - guard (1 ... 8 * 1_024).contains(token.utf8.count) else { return false } - let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, - let headerData = base64URLData(segments[0]), - let claimsData = base64URLData(segments[1]), - let header = try? JSONDecoder().decode(RelayTokenHeader.self, from: headerData), - let claims = try? JSONDecoder().decode(RelayTokenClaims.self, from: claimsData) else { - return false - } - return header.alg == "EdDSA" - && header.typ == "JWT" - && claims.issuer == "cmux" - && claims.audience == "cmux-relay" - && claims.expiresAt == expiresAt - && claims.endpointID == endpointID.endpointID - } - - private static func base64URLData(_ value: Substring) -> Data? { - var encoded = String(value) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = encoded.utf8.count % 4 - if remainder != 0 { - encoded.append(String(repeating: "=", count: 4 - remainder)) - } - return Data(base64Encoded: encoded) - } - - private static func canonicalRelayOrigin(_ value: String) throws -> String { - guard var 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.isEmpty || components.path == "/" else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - components.path = "/" - guard let canonical = components.string else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return canonical - } - private static func isConnectivityFailure(_ code: URLError.Code) -> Bool { switch code { case .timedOut, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift index e0a1ae918e16..866a27be01b2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift @@ -45,14 +45,6 @@ struct ClientRuntimeTestFixture { ) } - func relayResponse() -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: Self.relayURLs - ) - } func pendingRevocations() -> CmxIrohPendingRevocationOutbox { CmxIrohPendingRevocationOutbox( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift index 416483e0084a..c736ca5f0613 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct CmxIrohBackpressuredHostBrokerTests { @Test - func endpointAttestationFloorIsIsolatedAndRelayCredentialIsShared() async throws { + func endpointAttestationFloorIsIsolatedFromRelayPolicyFloor() async throws { let now = Date(timeIntervalSince1970: 1_782_000_000) let accountID = "account-a" let gate = CmxIrohBrokerBackpressureGate(now: { now }) @@ -21,9 +21,6 @@ struct CmxIrohBackpressuredHostBrokerTests { gate: gate, accountID: accountID ) - let endpointID = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) let attestationLimit = CmxIrohTrustBrokerClientError.rateLimited( code: "attestation_rate_limited", retryAfterSeconds: 600 @@ -38,8 +35,7 @@ struct CmxIrohBackpressuredHostBrokerTests { } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 0, - relayBootstrap: 0 + relayPolicy: 0 )) #expect(await gate.remainingSeconds( accountID: accountID, @@ -51,40 +47,31 @@ struct CmxIrohBackpressuredHostBrokerTests { ) == nil) await #expect(throws: relayLimit) { - _ = try await host.issueRelayToken( - bindingID: "binding-a", - endpointID: endpointID - ) + _ = try await relayPolicy.fetchRelayPolicy() } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 1, - relayBootstrap: 0 + relayPolicy: 1 )) - #expect(await gate.remainingSeconds( - accountID: accountID, - operation: .endpointAttestation - ) == 600) #expect(await gate.remainingSeconds( accountID: accountID, operation: .relayCredential ) == 600) + // The armed floor gates the next policy fetch without a broker call. await #expect(throws: relayLimit) { - _ = try await relayPolicy.issueRelayBootstrap(endpointID: endpointID) + _ = try await relayPolicy.fetchRelayPolicy() } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 1, - relayBootstrap: 0 + relayPolicy: 1 )) } } private struct BackpressuredHostBrokerProbeCalls: Equatable, Sendable { let endpointAttestation: Int - let relayToken: Int - let relayBootstrap: Int + let relayPolicy: Int } private enum BackpressuredHostBrokerProbeError: Error, Sendable { @@ -96,8 +83,7 @@ private actor BackpressuredHostBrokerProbe: CmxIrohRelayPolicyServing { private var endpointAttestationCalls = 0 - private var relayTokenCalls = 0 - private var relayBootstrapCalls = 0 + private var relayPolicyCalls = 0 func register( prepared _: CmxIrohPreparedRegistration, @@ -120,17 +106,6 @@ private actor BackpressuredHostBrokerProbe: ) } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - relayTokenCalls += 1 - throw CmxIrohTrustBrokerClientError.rateLimited( - code: "relay_rate_limited", - retryAfterSeconds: 600 - ) - } - func revoke(bindingID _: String) async throws { throw BackpressuredHostBrokerProbeError.unexpectedCall } @@ -139,11 +114,12 @@ private actor BackpressuredHostBrokerProbe: throw BackpressuredHostBrokerProbeError.unexpectedCall } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - relayBootstrapCalls += 1 - throw BackpressuredHostBrokerProbeError.unexpectedCall + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + relayPolicyCalls += 1 + throw CmxIrohTrustBrokerClientError.rateLimited( + code: "relay_rate_limited", + retryAfterSeconds: 600 + ) } func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { @@ -159,8 +135,7 @@ private actor BackpressuredHostBrokerProbe: func calls() -> BackpressuredHostBrokerProbeCalls { BackpressuredHostBrokerProbeCalls( endpointAttestation: endpointAttestationCalls, - relayToken: relayTokenCalls, - relayBootstrap: relayBootstrapCalls + relayPolicy: relayPolicyCalls ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift index d5027cfdd40c..4cd36fa51d30 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift @@ -5,22 +5,12 @@ import Testing @Suite("Iroh broker credential repository") struct CmxIrohBrokerCredentialRepositoryTests { - private let now = Date(timeIntervalSince1970: 1_800_000_000) private let relayFleet = [ "https://use1-1.relay.lawrence.cmux.iroh.link/", "https://usw1-1.relay.lawrence.cmux.iroh.link/", ] - @Test("credential descriptions redact opaque tokens") - func credentialDescriptionsRedactTokens() { - let credential = relayResponse().credentials[0] - - #expect(!String(describing: credential).contains(credential.token)) - #expect(!String(reflecting: credential).contains(credential.token)) - #expect(String(describing: credential).contains("")) - } - - @Test("binding metadata and relay credentials survive repository recreation") + @Test("binding metadata survives repository recreation") func roundTripsDurableState() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } @@ -32,17 +22,9 @@ struct CmxIrohBrokerCredentialRepositoryTests { privacyScope: .publicInternet ) let binding = try metadata(pathHints: [pathHint]) - let response = relayResponse() let repository = makeRepository(defaults: defaults, secureStore: secureStore) try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) let recreated = makeRepository(defaults: defaults, secureStore: secureStore) #expect( @@ -51,343 +33,41 @@ struct CmxIrohBrokerCredentialRepositoryTests { appInstanceID: binding.appInstanceID ) == binding ) - #expect( - try await recreated.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - #expect( - await secureStore.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - #expect( - !defaults.dictionaryRepresentation().values.contains(where: { value in - response.credentials.contains { credential in - String(describing: value).contains(credential.token) - } - }) - ) - } - - @Test("distinct per-relay credentials survive device-only persistence") - func roundTripsDistinctPerRelayCredentials() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let binding = try metadata() - let response = CmxIrohRelayTokenResponse(credentials: [ - CmxIrohManagedRelayCredential( - relayURL: relayFleet[0], - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)) - ), - CmxIrohManagedRelayCredential( - relayURL: relayFleet[1], - token: "def567", - expiresAt: iso8601(now.addingTimeInterval(3 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(90 * 60)) - ), - ]) - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - let stored = try #require(await secureStore.onlyStoredData()) - let object = try #require( - JSONSerialization.jsonObject(with: stored) as? [String: Any] - ) - #expect(object["version"] as? Int == 2) - #expect(object["token"] == nil) - #expect(object["response"] != nil) + // Tokenless transport: nothing is ever written to secure storage. + #expect(await secureStore.recordCount() == 0) } - @Test("version-one homogeneous credentials migrate without a new network mint") - func loadsVersionOneCredentialRecord() async throws { + @Test("scope rotation deletes prior state and legacy secure records") + func scopeRotationDeletesPriorState() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } let secureStore = TestSecureCredentialStore() let binding = try metadata() let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let legacyResponse = relayResponse() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - legacyResponse, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - let bindingObject = try JSONSerialization.jsonObject( - with: JSONEncoder().encode(binding) - ) - let legacyRecord: [String: Any] = [ - "version": 1, - "binding": bindingObject, - "token": "abc234", - "expiresAt": iso8601(now.addingTimeInterval(2 * 60 * 60)), - "refreshAfter": iso8601(now.addingTimeInterval(60 * 60)), - "relayFleet": relayFleet, - ] - await secureStore.seed( - try JSONSerialization.data(withJSONObject: legacyRecord), - account: account - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == legacyResponse - ) - } - - @Test("a different account or app instance cannot resurrect prior state") - func scopeRotationDeletesPriorState() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now + // A legacy relay-credential record left behind by a token-era build. + try await secureStore.write( + Data("legacy-token".utf8), + account: "legacy-scope", + accessibility: .afterFirstUnlockThisDeviceOnly ) #expect( try await repository.loadBinding( accountID: "account-b", - appInstanceID: original.appInstanceID + appInstanceID: binding.appInstanceID ) == nil ) #expect(await secureStore.recordCount() == 0) - - let replacementAppInstanceID = "123e4567-e89b-42d3-a456-426614174099" - #expect( - try await repository.loadBinding( - accountID: "account-b", - appInstanceID: replacementAppInstanceID - ) == nil - ) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: original.appInstanceID - ) == nil - ) - #expect(await secureStore.deleteAllCount() == 4) - } - - @Test("replacing the exact broker binding invalidates its relay capability") - func bindingRotationDeletesRelayCredential() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - let rotated = try metadata( - bindingID: "123e4567-e89b-42d3-a456-426614174020", - endpointByte: "cd", - generation: 2 - ) - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) - try await repository.saveBinding(rotated, accountID: "account-a") - #expect( try await repository.loadBinding( accountID: "account-a", - appInstanceID: original.appInstanceID - ) == rotated - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) == nil - ) - } - - @Test("saving an incomplete relay fleet fails without persisting the token") - func saveRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - - await #expect( - throws: CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch - ) { - try await repository.saveRelayCredential( - relayResponse(relayFleet: [relayFleet[0]]), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - } - #expect(await secureStore.recordCount() == 0) - } - - @Test("loading with a changed managed fleet deletes the stale capability") - func loadRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set([relayFleet[0]]), - now: now - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("expired or refresh-stale relay capabilities are deleted") - func loadRejectsStaleCredential() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - let response = relayResponse() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(60 * 60) - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(2 * 60 * 60) - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("corrupt secure records fail closed and are removed") - func corruptCredentialIsDeleted() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - await secureStore.seed(Data("not-json".utf8), account: account) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now + appInstanceID: binding.appInstanceID ) == nil ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("persisted binding metadata is revalidated during decoding") - func corruptBindingMetadataIsRejected() throws { - let binding = try metadata() - let encoded = try JSONEncoder().encode(binding) - var object = try #require( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] - ) - object["bindingID"] = "not-a-uuid" - let corrupted = try JSONSerialization.data(withJSONObject: object) - - #expect(throws: CmxIrohBrokerCredentialRepositoryError.invalidBinding) { - try JSONDecoder().decode( - CmxIrohBrokerBindingMetadata.self, - from: corrupted - ) - } } - @Test("explicit deletion preserves or clears binding metadata as requested") + @Test("explicit deletion and deactivation clear binding metadata") func explicitDeletion() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } @@ -395,25 +75,6 @@ struct CmxIrohBrokerCredentialRepositoryTests { let repository = makeRepository(defaults: defaults, secureStore: secureStore) let binding = try metadata() try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - try await repository.deleteRelayCredential( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == binding - ) try await repository.deleteBinding( accountID: "account-a", @@ -472,21 +133,4 @@ struct CmxIrohBrokerCredentialRepositoryTests { pathHints: pathHints ) } - - private func relayResponse( - relayFleet: [String]? = nil - ) -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)), - relayFleet: relayFleet ?? self.relayFleet - ) - } - - private func iso8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) - } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift index fad3d4506842..a476e726c017 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift @@ -28,16 +28,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, pairGrant: try fixture.pairGrantResponse( issuedAt: fixture.nowSeconds, expiresAt: fixture.nowSeconds + 3_600 @@ -100,16 +93,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, pairGrant: try fixture.pairGrantResponse( issuedAt: fixture.nowSeconds, expiresAt: fixture.nowSeconds + 3_600 @@ -173,16 +159,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, registrationError: CmxIrohTrustBrokerClientError.connectivity ) let recorder = ClientRuntimeTestRecorder() @@ -218,7 +197,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rejected( statusCode: 401, code: "unauthorized" diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift index f066ecde6869..9b5a3cf43d81 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift @@ -16,8 +16,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let configuration = CmxIrohClientRuntimeConfiguration( @@ -43,8 +42,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests { handleBinding: { _, _ in await recorder.recordBinding() return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } + } ) try await runtime.start() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift index a31f898a126e..5b8190eed083 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift @@ -19,8 +19,7 @@ extension CmxIrohClientRuntimeTests { ) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let configuration = CmxIrohClientRuntimeConfiguration( accountID: fixture.configuration.accountID, @@ -77,8 +76,7 @@ extension CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -113,8 +111,7 @@ extension CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -146,7 +143,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ], @@ -188,7 +184,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationHook: { count in if count == 1 { await endpoint.emit(.networkChanged) } } @@ -215,8 +210,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -250,8 +244,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -277,7 +270,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -311,7 +303,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -343,7 +334,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -374,8 +364,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -463,8 +452,6 @@ private actor ClientRuntimeBlockingCloseEndpoint: CmxIrohEndpoint { func accept() async throws -> (any CmxIrohConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - func healthEvents() -> AsyncStream { healthStream } func isHealthy() -> Bool { healthy } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index 47d58effaf44..e37ab3828f31 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -42,8 +42,7 @@ struct CmxIrohClientRuntimeTests { ]), broker: TestRevisionedClientBroker( binding: fixture.binding, - discoveries: [discovery], - relay: fixture.relayResponse() + discoveries: [discovery] ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -69,7 +68,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), registrationRevision: 2 ), configuration: fixture.configuration, @@ -96,7 +94,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), embedInitialDiscovery: true, registrationRevision: 1 ), @@ -164,12 +161,6 @@ struct CmxIrohClientRuntimeTests { managedRelayURLs: [fixture.relayURL], cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [ TestIrohEndpoint(identity: fixture.initiator.endpointID), @@ -177,7 +168,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: localBinding, discoveries: [rejectedRevision], - relay: relay, registrationError: .connectivity ), configuration: configuration, @@ -203,7 +193,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), embedInitialDiscovery: true ) let runtime = try CmxIrohClientRuntime( @@ -245,7 +234,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [authoritativeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: staleDiscovery, embeddedRegistrationDiscoveryIsComplete: true, registrationRevision: 1 @@ -282,7 +270,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [truncatedRegistrationDiscovery, completeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: truncatedRegistrationDiscovery, connectivitySnapshotsProvenComplete: nil ) @@ -335,7 +322,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [revisionOne, revisionTwo], - relay: fixture.relayResponse(), blockedRegistrationCount: 1 ) let runtime = try CmxIrohClientRuntime( @@ -384,8 +370,7 @@ struct CmxIrohClientRuntimeTests { ) let broker = TestRevisionedClientBroker( binding: fixture.binding, - discoveries: [revisionOne, revisionTwo], - relay: fixture.relayResponse() + discoveries: [revisionOne, revisionTwo] ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -435,7 +420,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: discoveries, - relay: fixture.relayResponse(), blockedSyncCount: 2 ) let runtime = try CmxIrohClientRuntime( @@ -474,8 +458,7 @@ struct CmxIrohClientRuntimeTests { let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -487,8 +470,7 @@ struct CmxIrohClientRuntimeTests { handleBinding: { _, _ in await recorder.recordBinding() return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } + } ) try await runtime.start() @@ -503,10 +485,10 @@ struct CmxIrohClientRuntimeTests { #expect(prepared.challengeRequest.tag == fixture.binding.tag) #expect(prepared.challengeRequest.endpointId == fixture.endpointID.endpointID) #expect(prepared.challengeRequest.identityGeneration == fixture.identity.generation) - #expect(await endpoint.observedRelayUpdates().last?.count == 4) + // Tokenless transport: relays are configured at bind time and the + // connect path installs no credential and mutates no relay. + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) #expect(await recorder.observedBindingCount() == 1) - await recorder.waitForRelayCount(1) - #expect(await recorder.observedRelayCount() == 1) #expect(runtime.transportFactory.supportedKinds == [.iroh]) await runtime.stop() } @@ -516,8 +498,7 @@ struct CmxIrohClientRuntimeTests { let fixture = try ClientRuntimeTestFixture() let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -551,7 +532,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -591,7 +571,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: rateLimit] ) let runtime = try CmxIrohClientRuntime( @@ -620,7 +599,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -659,7 +637,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -701,7 +678,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), bindingAuthorizationAvailable: false, registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", @@ -743,7 +719,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -794,7 +769,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -839,7 +813,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -873,8 +846,7 @@ struct CmxIrohClientRuntimeTests { ]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -899,8 +871,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: []), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -923,8 +894,7 @@ struct CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: substitutedDiscovery, - relay: fixture.relayResponse() + discovery: substitutedDiscovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -950,7 +920,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -988,8 +957,7 @@ struct CmxIrohClientRuntimeTests { ) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: factory, @@ -1024,7 +992,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: terminal] ) let offlineStore = TestSecureCredentialStore() @@ -1059,8 +1026,7 @@ struct CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let offlineStore = TestSecureCredentialStore() let recorder = ClientRuntimeTestRecorder() @@ -1108,7 +1074,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: failure] ) let offlineStore = TestSecureCredentialStore() @@ -1144,7 +1109,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: TestIrohTransportError.unsupported ) let recorder = ClientRuntimeTestRecorder() @@ -1221,8 +1185,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: []), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -1245,8 +1208,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: pendingRevocations, @@ -1284,8 +1246,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: pendingRevocations, @@ -1336,7 +1297,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: CmxIrohTrustBrokerClientError.connectivity ) let runtime = try CmxIrohClientRuntime( @@ -1374,7 +1334,6 @@ private actor TestRevisionedClientBroker: { private let binding: CmxIrohBrokerBinding private var discoveries: [CmxIrohDiscoveryResponse] - private let relay: CmxIrohRelayTokenResponse private let blockedSyncCount: Int? private let blockedRegistrationCount: Int? private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? @@ -1391,7 +1350,6 @@ private actor TestRevisionedClientBroker: init( binding: CmxIrohBrokerBinding, discoveries: [CmxIrohDiscoveryResponse], - relay: CmxIrohRelayTokenResponse, blockedSyncCount: Int? = nil, blockedRegistrationCount: Int? = nil, embedInitialDiscovery: Bool = false, @@ -1403,7 +1361,6 @@ private actor TestRevisionedClientBroker: ) { self.binding = binding self.discoveries = discoveries - self.relay = relay self.blockedSyncCount = blockedSyncCount self.blockedRegistrationCount = blockedRegistrationCount self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery @@ -1431,7 +1388,7 @@ private actor TestRevisionedClientBroker: ?? embeddedRegistrationDiscovery?.revision ?? discoveries.first?.revision, binding: binding, - relay: .issued(relay), + relay: .unavailable, discovery: embeddedRegistrationDiscovery, discoveryComplete: embeddedRegistrationDiscoveryIsComplete ) @@ -1472,13 +1429,6 @@ private actor TestRevisionedClientBroker: throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relay - } - func revoke(bindingID _: String) {} func revokeStale(bindingID _: String) {} @@ -1536,8 +1486,6 @@ private actor TestSubstitutedAddressEndpoint: CmxIrohEndpoint { func accept() async throws -> (any CmxIrohConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) {} func healthEvents() -> AsyncStream { diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift index d748e8563c72..f7418021d49e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift @@ -17,50 +17,24 @@ struct CmxIrohConfigurationTests { } @Test - func relayCredentialRequiresCanonicalURLTokenAndFutureRefresh() throws { - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "http://relay.example/", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "https://relay.example", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidToken) { - try relay(url: "https://relay.example/", token: "upperCASE") - } - #expect( - try relay(url: "https://relay.example/", token: "aB_-.cD-_.eF_-").token - == "aB_-.cD-_.eF_-" - ) - #expect(throws: CmxIrohRelayConfigurationError.invalidLifetime) { - try CmxIrohRelayConfiguration( - url: "https://relay.example/", - token: "aaaa", - expiresAt: now.addingTimeInterval(10), - refreshAfter: now, - now: now - ) - } - } - - @Test - func endpointConfigurationRejectsUnmanagedAndDuplicateRelays() throws { - let relay = try relay(url: "https://relay.example/", token: "aaaa") + func managedEndpointConfigurationIsTokenlessAndBoundedBySize() throws { let secret = try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 32)) + let configuration = try CmxIrohEndpointConfiguration( + secretKey: secret, + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: ["https://relay.example/"] + ) + #expect(configuration.relayProfile.activeRelays.map(\.url) == ["https://relay.example/"]) + #expect(configuration.relayProfile.activeRelays.allSatisfy { + $0.authenticationToken == nil + }) - #expect(throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(relay.url)) { + let oversized = Set((0 ..< 17).map { "https://relay\($0).example/" }) + #expect(throws: CmxIrohEndpointConfigurationError.tooManyRelays(oversized.count)) { try CmxIrohEndpointConfiguration( secretKey: secret, alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [relay] - ) - } - #expect(throws: CmxIrohEndpointConfigurationError.duplicateRelayURL(relay.url)) { - try CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [relay.url], - relays: [relay, relay] + managedRelayURLs: oversized ) } } @@ -84,7 +58,6 @@ struct CmxIrohConfigurationTests { #expect(configuration.relayProfile.allowedRelayURLs == [custom.relays[0].url]) #expect(configuration.managedRelayURLs.isEmpty) - #expect(configuration.relays.isEmpty) } @Test @@ -93,8 +66,7 @@ struct CmxIrohConfigurationTests { let defaultConfiguration = try CmxIrohEndpointConfiguration( secretKey: secret, alpns: [], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) #expect(defaultConfiguration.bindPolicy == .ephemeral) #expect(defaultConfiguration.bindPolicy.socketAddress == nil) @@ -126,16 +98,4 @@ struct CmxIrohConfigurationTests { } } - private func relay( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now - ) - } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift index 4a23d62cc118..44d7dde4cc35 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift @@ -35,6 +35,7 @@ enum CmxIrohCustomRelayLiveEnvironment { static var hasBrokerCredentials: Bool { [ "CMUX_IROH_CUSTOM_RELAY_BROKER_URL", + "CMUX_IROH_CUSTOM_RELAY_BROKER_RELAY_URL", "CMUX_IROH_CUSTOM_RELAY_ACCESS_TOKEN", "CMUX_IROH_CUSTOM_RELAY_REFRESH_TOKEN", ].allSatisfy { environment[$0]?.isEmpty == false } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift index 6d67c7b8aff4..b90a9f8649d9 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift @@ -121,7 +121,7 @@ struct CmxIrohCustomRelayLiveTests { } @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasBrokerCredentials)) - func brokerBoundTokensCarryBidirectionalRoundTrip() async throws { + func brokerRegisteredEndpointsCarryTokenlessRoundTrip() async throws { let baseURL = try #require(URL(string: try CmxIrohCustomRelayLiveEnvironment.required( "CMUX_IROH_CUSTOM_RELAY_BROKER_URL" ))) @@ -166,40 +166,18 @@ struct CmxIrohCustomRelayLiveTests { ) bindingIDs.append(second.bindingID) - stage = "mint first endpoint-bound token" - print("Iroh live relay: \(stage)") - let firstToken = try await broker.issueRelayToken( - bindingID: first.bindingID, - endpointID: first.endpointID - ) - stage = "mint second endpoint-bound token" - print("Iroh live relay: \(stage)") - let secondToken = try await broker.issueRelayToken( - bindingID: second.bindingID, - endpointID: second.endpointID - ) - let firstCredentials = Dictionary( - firstToken.credentials.map { ($0.relayURL, $0.token) }, - uniquingKeysWith: { _, latestToken in latestToken } - ) - let commonRelayURL = try #require( - secondToken.credentials.lazy - .map(\.relayURL) - .first(where: { firstCredentials[$0] != nil }) - ) - let firstAuthenticationToken = try #require(firstCredentials[commonRelayURL]) - let secondAuthenticationToken = try #require( - secondToken.credentials.first(where: { - $0.relayURL == commonRelayURL - })?.token + let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( + "CMUX_IROH_CUSTOM_RELAY_BROKER_RELAY_URL" ) - stage = "carry bidirectional relay-only stream" + // Tokenless connect: the relay's allow hook admits the registered + // endpoint keys proven in the handshake; no credential is attached. + stage = "carry tokenless bidirectional relay-only stream" print("Iroh live relay: \(stage)") try await assertBidirectionalRoundTrip( - relayURL: commonRelayURL, - firstAuthenticationToken: firstAuthenticationToken, - secondAuthenticationToken: secondAuthenticationToken, + relayURL: relayURL, + firstAuthenticationToken: nil, + secondAuthenticationToken: nil, firstSecretKey: firstSecretKey, secondSecretKey: secondSecretKey ) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift index 005f34e90ff9..e696418cd427 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift @@ -45,8 +45,7 @@ struct CmxIrohCustomRelayProbeTests { endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] ) let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: fixture.configuration.managedRelayURLs, - relays: [] + managedRelayURLs: fixture.configuration.managedRelayURLs ) let result = await CmxIrohCustomRelayProbe(factory: factory).probe( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift index 3b26153fbf2f..7d0242ffb956 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift @@ -12,29 +12,22 @@ struct CmxIrohCustomRelayRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), now: { fixture.now } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) - let initialCredentialUpdates = await endpoint.observedRelayUpdates().count let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: fixture.relayResponse(), - relayURLs: Set(ClientRuntimeTestFixture.relayURLs), - now: fixture.now + relayURLs: Set(ClientRuntimeTestFixture.relayURLs) )) - let credentialUpdates = await endpoint.observedRelayUpdates().count - - initialCredentialUpdates let profileUpdates = await endpoint.observedRelayProfileUpdates().count - initialProfileUpdates - #expect(credentialUpdates + profileUpdates == 1) + #expect(profileUpdates == 1) #expect(await endpoint.observedCloseCallCount() == 0) #expect(await runtime.snapshot().endpointID == fixture.endpointID) await runtime.stop() @@ -56,61 +49,49 @@ struct CmxIrohCustomRelayRuntimeTests { handleTransport: { session, _ in await session.close() } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) - let initialCredentialUpdates = await endpoint.observedRelayUpdates().count let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count - let response = try ClientRuntimeTestFixture().relayResponse() try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: response, - relayURLs: fixture.managedRelays, - now: Date(timeIntervalSince1970: 1_800_000_000) + relayURLs: fixture.managedRelays )) - let credentialUpdates = await endpoint.observedRelayUpdates().count - - initialCredentialUpdates let profileUpdates = await endpoint.observedRelayProfileUpdates().count - initialProfileUpdates - #expect(credentialUpdates + profileUpdates == 1) + #expect(profileUpdates == 1) #expect(await endpoint.observedCloseCallCount() == 0) #expect(await runtime.snapshot().endpointID == fixture.endpointID) await runtime.stop() } @Test - func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws { + func clientManagedPolicyFailureLeavesEndpointOpen() async throws { let fixture = try ClientRuntimeTestFixture() let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), now: { fixture.now } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) await endpoint.setRelayUpdateShouldFail(true) await #expect(throws: TestIrohTransportError.relayUpdateFailed) { try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: fixture.relayResponse(), - relayURLs: Set(ClientRuntimeTestFixture.relayURLs), - now: fixture.now + relayURLs: Set(ClientRuntimeTestFixture.relayURLs) )) } - #expect(await runtime.relayCoordinator == nil) #expect(await endpoint.observedCloseCallCount() == 0) await runtime.stop() } @Test - func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws { + func hostManagedPolicyFailureLeavesEndpointOpen() async throws { let fixture = try HostRuntimeFixture() let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let runtime = CmxIrohHostRuntime( @@ -125,25 +106,20 @@ struct CmxIrohCustomRelayRuntimeTests { handleTransport: { session, _ in await session.close() } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) await endpoint.setRelayUpdateShouldFail(true) - let response = try ClientRuntimeTestFixture().relayResponse() await #expect(throws: TestIrohTransportError.relayUpdateFailed) { try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: response, - relayURLs: fixture.managedRelays, - now: Date(timeIntervalSince1970: 1_800_000_000) + relayURLs: fixture.managedRelays )) } - #expect(await runtime.relayCoordinator == nil) #expect(await endpoint.observedCloseCallCount() == 0) await runtime.stop() } @Test - func clientOverrideSkipsManagedTokenIssuance() async throws { + func clientOverrideInstallsCustomProfileAtBind() async throws { let fixture = try ClientRuntimeTestFixture() let custom = try CmxIrohCustomRelayProfile( relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] @@ -165,8 +141,7 @@ struct CmxIrohCustomRelayRuntimeTests { let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: factory, @@ -179,14 +154,13 @@ struct CmxIrohCustomRelayRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) - #expect(await endpoint.observedRelayUpdates().isEmpty) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) #expect(await factory.observedConfigurations().first?.relayProfile == profile) await runtime.stop() } @Test - func hostOverrideSkipsManagedTokenIssuance() async throws { + func hostOverrideInstallsCustomProfileAtBind() async throws { let fixture = try HostRuntimeFixture() let custom = try CmxIrohCustomRelayProfile( relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] @@ -209,7 +183,6 @@ struct CmxIrohCustomRelayRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) #expect(await factory.observedConfigurations().first?.relayProfile == profile) await runtime.stop() } @@ -244,8 +217,7 @@ struct CmxIrohCustomRelayRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -296,14 +268,9 @@ struct CmxIrohCustomRelayRuntimeTests { } private static func managedPolicy( - response: CmxIrohRelayTokenResponse, - relayURLs: Set, - now: Date + relayURLs: Set ) throws -> CmxIrohEffectiveRelayPolicy { - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: relayURLs, - relays: response.relayConfigurations(now: now) - ) + let profile = try CmxIrohEndpointRelayProfile(managedRelayURLs: relayURLs) return CmxIrohEffectiveRelayPolicy( endpointRelayProfile: profile, managedSnapshot: nil, @@ -312,27 +279,7 @@ struct CmxIrohCustomRelayRuntimeTests { effectivePreference: .automatic, source: .managed, usedCachedPolicy: false, - preferenceRevision: nil, - relayBootstrap: response + preferenceRevision: nil ) } - - private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - while clock.now < deadline { - let credentialUpdates = await endpoint.observedRelayUpdates().count - let profileUpdates = await endpoint.observedRelayProfileUpdates().count - if credentialUpdates + profileUpdates > 0 { return } - await Task.yield() - } - let credentialUpdates = await endpoint.observedRelayUpdates().count - let profileUpdates = await endpoint.observedRelayProfileUpdates().count - let counts = "credential updates: \(credentialUpdates), " - + "profile updates: \(profileUpdates)" - Issue.record("Timed out waiting for relay mutation (\(counts))") - throw RelayMutationTimeout() - } } - -private struct RelayMutationTimeout: Error {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift index 66e64f2cc1f0..ca36798eacaa 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift @@ -60,16 +60,10 @@ import Testing let override = try #require( CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") ) - let resolved = try fixture.configuration.resolvedEndpointRelayProfile( - now: Date(), - debugOverride: override - ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: override) #expect(resolved == override) - let managed = try fixture.configuration.resolvedEndpointRelayProfile( - now: Date(), - debugOverride: nil - ) + let managed = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) #expect(managed.source == .managed) #expect(managed.allowedRelayURLs == fixture.managedRelays) } @@ -79,16 +73,10 @@ import Testing let override = try #require( CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") ) - let resolved = try fixture.configuration.resolvedEndpointRelayProfile( - now: fixture.now, - debugOverride: override - ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: override) #expect(resolved == override) - let managed = try fixture.configuration.resolvedEndpointRelayProfile( - now: fixture.now, - debugOverride: nil - ) + let managed = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) #expect(managed.source == .managed) #expect(managed.allowedRelayURLs == Set(ClientRuntimeTestFixture.relayURLs)) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift index 6e7cb8d2fd6e..332cf28c4f33 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift @@ -86,8 +86,7 @@ struct CmxIrohDirectTransportGateTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: secretByte, count: 32)), alpns: [alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) let options = CmxIrohLibEndpointFactory.endpointOptions( configuration: configuration, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift index 4074fe48ea4e..85e43b19cfa8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift @@ -21,8 +21,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -100,8 +99,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 3, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -161,8 +159,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 5, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -245,8 +242,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift index e1ef5a1a260f..dc7cf3d24a65 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift @@ -19,8 +19,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 1, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) let snapshot = try await supervisor.activate() @@ -65,8 +64,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) let snapshot = try await supervisor.activate() @@ -112,8 +110,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -152,8 +149,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 2, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -214,8 +210,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 8, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -278,8 +273,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -333,8 +327,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -519,7 +512,6 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { return try Self.resolve(event) } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift index 6d20b5eaf69e..59340c8185fe 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift @@ -177,20 +177,16 @@ struct CmxIrohEndpointSupervisorTests { configuration: initialConfiguration ) _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) await #expect(throws: TestIrohTransportError.relayUpdateFailed) { - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) } await supervisor.deactivate() _ = try await supervisor.activate() let configurations = await factory.observedConfigurations() #expect(configurations.count == 2) - #expect(configurations[1].relays == initialConfiguration.relays) + #expect(configurations[1].relayProfile == initialConfiguration.relayProfile) } @Test @@ -207,12 +203,8 @@ struct CmxIrohEndpointSupervisorTests { configuration: initial ) _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) await supervisor.deactivate() _ = try await supervisor.activate() @@ -247,12 +239,8 @@ struct CmxIrohEndpointSupervisorTests { } } _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) let emittedChange = await changes.waitForRefresh(timeout: .seconds(1)) #expect( @@ -263,7 +251,7 @@ struct CmxIrohEndpointSupervisorTests { await supervisor.deactivate() } - @Test("relay credential rotation does not republish an unchanged address") + @Test("relay profile replacement does not republish an unchanged address") func unchangedRelayAddressDoesNotPublishNetworkChange() async throws { let endpoint = TestIrohEndpoint(identity: identity) let supervisor = CmxIrohEndpointSupervisor( @@ -280,17 +268,13 @@ struct CmxIrohEndpointSupervisorTests { } } _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) let emittedChange = await changes.waitForRefresh(timeout: .milliseconds(50)) #expect( !emittedChange, - "Rotating credentials without changing the published address must not refresh policy" + "Replacing relays without changing the published address must not refresh policy" ) observation.cancel() await supervisor.deactivate() @@ -342,12 +326,8 @@ struct CmxIrohEndpointSupervisorTests { ) _ = try await supervisor.activate() var updateEvents = await firstEndpoint.updateEvents().makeAsyncIterator() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) let refresh = Task { - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) } _ = await updateEvents.next() @@ -362,7 +342,7 @@ struct CmxIrohEndpointSupervisorTests { let configurations = await factory.observedConfigurations() #expect(configurations.count == 3) - #expect(configurations[2].relays == initialConfiguration.relays) + #expect(configurations[2].relayProfile == initialConfiguration.relayProfile) } @Test("an already-online generation replays relay readiness") @@ -497,33 +477,20 @@ struct CmxIrohEndpointSupervisorTests { private func endpointConfiguration( bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral ) throws -> CmxIrohEndpointConfiguration { - let relay = try relayConfiguration( - url: "https://use1-1.relay.lawrence.cmux.iroh.link/", - token: "aaaa" - ) - return try CmxIrohEndpointConfiguration( + try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: bindPolicy, managedRelayURLs: [ - relay.url, + "https://use1-1.relay.lawrence.cmux.iroh.link/", "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ], - relays: [relay] + ] ) } - private func relayConfiguration( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - let now = Date(timeIntervalSince1970: 1_000) - return try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now + private func replacementProfile() throws -> CmxIrohEndpointRelayProfile { + try CmxIrohEndpointRelayProfile( + managedRelayURLs: ["https://usw1-1.relay.lawrence.cmux.iroh.link/"] ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift index 7d488c322057..e041b4baf2f6 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift @@ -46,12 +46,10 @@ extension CmxIrohHostRuntimeTests { } ) try await runtime.start() - // Native iroh reports the home relay online once the installed - // credential connects; the readiness gate publishes only after this. - for _ in 0 ..< 20_000 { - if await !endpoint.observedRelayUpdates().isEmpty { break } - await Task.yield() - } + // Native iroh reports the home relay online once the configured relay + // connects; the address then carries the relay URL and the readiness + // gate publishes only after this. + await endpoint.setPathHints([relayHint]) await endpoint.emit(.online) let republished = await broker.waitForRegistrationCount( @@ -101,34 +99,6 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } - @Test - func validatedBindingPublishesBeforeRelayCredentialInstallationCompletes() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = try fixture.relayReadyEndpoint() - let gate = HostRuntimeSuspensionGate() - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - relayIssueHook: { await gate.suspend() } - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() } - ) - let start = Task { try await runtime.start() } - await gate.waitUntilSuspended() - - #expect(await bindings.count() == 1) - - await gate.resume() - try await start.value - await runtime.stop() - } - @Test func validatedBindingPublishesBeforeLANAdvertisementCompletes() async throws { let fixture = try HostRuntimeFixture() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift index 9aa06cfe1d97..6c3e55c29a52 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift @@ -218,17 +218,6 @@ private actor TestRevisionedHostBroker: throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: HostRuntimeFixture.relayURLs - ) - } func revoke(bindingID _: String) {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 2138b4593944..5a9ea91c7b9d 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -87,13 +87,8 @@ extension CmxIrohHostRuntimeTests { #expect(await routes.values().isEmpty) #expect(await runtime.snapshot().state == .active) - // The relay credential installs, then native iroh reports the home - // relay online. Publication must follow, exactly once, with the - // post-relay hints. - for _ in 0 ..< 20_000 { - if await !endpoint.observedRelayUpdates().isEmpty { break } - await Task.yield() - } + // Native iroh reports the home relay online. Publication must follow, + // exactly once, with the post-relay hints. await endpoint.emit(.online) #expect(await bindings.waitForCount(1, timeout: .seconds(5))) let republished = await routes.values() @@ -411,13 +406,8 @@ extension CmxIrohHostRuntimeTests { #expect(adopted) #expect(await bindings.count() == 0) - // The relay credential installs for the adopted binding, then the - // home relay comes online. Publication must follow, exactly once, - // with the adopted identity. - for _ in 0 ..< 20_000 { - if await !endpoint.observedRelayUpdates().isEmpty { break } - await Task.yield() - } + // The home relay comes online for the adopted binding. Publication + // must follow, exactly once, with the adopted identity. await endpoint.emit(.online) #expect(await bindings.waitForCount(1, timeout: .seconds(5))) #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index 1a25463a0ade..b12f48b22501 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift @@ -58,7 +58,6 @@ struct CmxIrohHostRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) await runtime.stop() } @@ -381,7 +380,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { private let revokeError: CmxIrohTrustBrokerClientError? private let registrationHook: (@Sendable () async -> Bool)? private let subsequentRegistrationHook: (@Sendable () async -> Void)? - private let relayIssueHook: (@Sendable () async -> Void)? private let embedDiscoveryStartingAtRegistrationCount: Int? private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? private let embeddedRegistrationDiscoveryIsComplete: Bool? @@ -391,7 +389,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { private var preflightOperations: [CmxIrohBrokerOperation] = [] private var registrationCount = 0 private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] - private var relayIssueCount = 0 private var discoveryCount = 0 private var registrationHookResult: Bool? private var revokedBindingIDs: [String] = [] @@ -409,7 +406,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { revokeError: CmxIrohTrustBrokerClientError? = nil, registrationHook: (@Sendable () async -> Bool)? = nil, subsequentRegistrationHook: (@Sendable () async -> Void)? = nil, - relayIssueHook: (@Sendable () async -> Void)? = nil, embedDiscoveryInRegistration: Bool = false, embedDiscoveryStartingAtRegistrationCount: Int? = nil, embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil, @@ -425,7 +421,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { self.revokeError = revokeError self.registrationHook = registrationHook self.subsequentRegistrationHook = subsequentRegistrationHook - self.relayIssueHook = relayIssueHook self.embedDiscoveryStartingAtRegistrationCount = embedDiscoveryInRegistration ? 1 @@ -503,21 +498,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) async -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - if let relayIssueHook { - await relayIssueHook() - } - return CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: HostRuntimeFixture.relayURLs - ) - } func revoke(bindingID: String) throws { revokedBindingIDs.append(bindingID) @@ -535,7 +515,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { func observedPreparedRegistrations() -> [CmxIrohPreparedRegistration] { preparedRegistrations } - func observedRelayIssueCount() -> Int { relayIssueCount } func observedDiscoveryCount() -> Int { discoveryCount } func enqueueSubsequentRegistrationError( @@ -833,7 +812,6 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { return connection } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift index 1488f54a2503..c473300577f5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift @@ -62,8 +62,7 @@ private struct LibEndpointCancellationFixture { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) endpoint = CmxIrohLibEndpoint( driver: AttemptOnlyEndpoint(attempt: attempt), diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift index d8a29e91c0d8..0d2c5655942e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift @@ -19,8 +19,7 @@ struct CmxIrohLibEndpointTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ), socketAddress: nil, relayMap: RelayMap.empty(), @@ -47,8 +46,7 @@ struct CmxIrohLibEndpointTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ), socketAddress: nil, relayMap: RelayMap.empty() @@ -217,7 +215,7 @@ struct CmxIrohLibEndpointTests { #expect(await endpoint.identity() == identity) try await endpoint.replaceRelayProfile( - CmxIrohEndpointRelayProfile(managedRelayURLs: [], relays: []) + CmxIrohEndpointRelayProfile(managedRelayURLs: []) ) await #expect(throws: CmxIrohLibError.unmanagedRelayURL(customURL)) { _ = try await concrete.endpointAddresses( @@ -425,8 +423,7 @@ struct CmxIrohLibEndpointTests { secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: bindPolicy, - managedRelayURLs: managedRelayURLs, - relays: [] + managedRelayURLs: managedRelayURLs ) return try await CmxIrohLibEndpointFactory( transportVerificationMode: transportVerificationMode @@ -439,8 +436,7 @@ struct CmxIrohLibEndpointTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) let driver = try await Endpoint.bind( options: CmxIrohLibEndpointFactory.endpointOptions( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift index 102907f98f3c..a6f25ef1b339 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift @@ -159,8 +159,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift index 12fe8df70db6..272120d321d5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift @@ -173,8 +173,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift index fdff908519b6..5a5355ff7ebd 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift @@ -16,19 +16,7 @@ struct CmxIrohPersistenceLifecycleRaceTests { ) let fixture = try ClientRuntimeTestFixture() let binding = CmxIrohBrokerBindingMetadata(binding: fixture.binding) - let relayFleet = fixture.configuration.managedRelayURLs try await repository.saveBinding(binding, accountID: "account-a") - await store.suspendNextWrite() - let save = Task { - try await repository.saveRelayCredential( - fixture.relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: relayFleet, - now: fixture.now - ) - } - await store.waitUntilWriteIsSuspended() await store.suspendNextDeleteAll() let deactivate = Task { try await repository.deactivate() } @@ -40,8 +28,6 @@ struct CmxIrohPersistenceLifecycleRaceTests { ) } ) - await store.resumeSuspendedWrite() - await #expect(throws: CancellationError.self) { try await save.value } await store.waitUntilDeleteAllIsSuspended() await store.resumeSuspendedDeleteAll() try await deactivate.value diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift index c6614474744b..ca5d4bf88a02 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift @@ -323,8 +323,7 @@ struct CmxIrohPrivatePathTransportGateTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) let supervisor = CmxIrohEndpointSupervisor( factory: CmxIrohLibEndpointFactory(transportVerificationMode: .directOnly), @@ -364,8 +363,7 @@ struct CmxIrohPrivatePathTransportGateTests { secretKey: CmxIrohSecretKey(bytes: fixture.acceptorSecretKey), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: .required(CmxIrohBindAddress(ipAddress: ipAddress, port: port)), - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) return try await CmxIrohLibEndpointFactory( transportVerificationMode: .directOnly diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift index d825dbe19b15..755b6b01f5bb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift @@ -428,8 +428,7 @@ struct RegistryFixture: Sendable { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), alpns: [Data("cmux/mobile/1".utf8)], - managedRelayURLs: [relayURL], - relays: [] + managedRelayURLs: [relayURL] ) let supervisor = CmxIrohEndpointSupervisor( factory: factory, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift deleted file mode 100644 index 30d77319e5f6..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift +++ /dev/null @@ -1,328 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRelayCredentialCoordinatorTests { - @Test - func scheduledRefreshReplacesCredentialWithoutChangingEndpointIdentity() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let initialExpiry = fixture.now.addingTimeInterval(5 * 60) - let initialRefresh = initialExpiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(13 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let expectedInitialClockEvent = TestRelayClock.Event.sleep(initialRefresh) - let expectedReplacementClockEvent = TestRelayClock.Event.sleep(replacementRefresh) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["ghi234", "jkl234"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def234"], - refreshAfter: initialRefresh, - expiresAt: initialExpiry - ) - ) - #expect(await clockEvents.next() == expectedInitialClockEvent) - - clock.advance(to: initialRefresh) - #expect(await clockEvents.next() == expectedReplacementClockEvent) - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - #expect(await endpoint.observedRelayUpdates().last?.map(\.token) == [ - "ghi234", - "jkl234", - ]) - #expect(await coordinator.credentialExpiresAt() == replacementExpiry) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func disabledAutomaticRefreshKeepsTheInstalledShortLivedCredential() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["replacement-a", "replacement-b"], - refreshAfter: fixture.now.addingTimeInterval(10 * 60), - expiresAt: fixture.now.addingTimeInterval(11 * 60) - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - automaticRefreshEnabled: false - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - clock.advance(to: fixture.expiresAt.addingTimeInterval(1)) - try await coordinator.refreshIfNeeded() - for _ in 0 ..< 20 { await Task.yield() } - - #expect(clock.observedSleepDeadlines().isEmpty) - #expect(await broker.observedEndpointIDs().isEmpty) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func concurrentForegroundCatchUpSharesOneBrokerMint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let expiry = fixture.now.addingTimeInterval(5 * 60) - let refresh = expiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let gate = TestRelayIssueGate() - let broker = TestRelayTokenBroker( - steps: [.response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - ))], - issueHook: { count in - if count == 1 { await gate.park() } - } - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: refresh, - expiresAt: expiry - ) - ) - clock.setNowWithoutResuming(expiry.addingTimeInterval(1)) - - let first = Task { try await coordinator.refreshIfNeeded() } - await gate.waitUntilParked() - let second = Task { try await coordinator.refreshIfNeeded() } - for _ in 0 ..< 20 { await Task.yield() } - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - - await gate.release() - try await first.value - try await second.value - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpRefreshesCredentialAfterSuspensionPastDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let initialExpiry = fixture.now.addingTimeInterval(5 * 60) - let initialRefresh = initialExpiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: initialRefresh, - expiresAt: initialExpiry - ) - ) - clock.setNowWithoutResuming(initialExpiry.addingTimeInterval(1)) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - #expect(await endpoint.observedRelayUpdates().last?.map(\.token) == [ - "ghi234", - "jkl567", - ]) - #expect(await coordinator.credentialExpiresAt() == replacementExpiry) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpDoesNotMintBeforeRefreshDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs().isEmpty) - #expect(await endpoint.observedRelayUpdates().count == 1) - await coordinator.deactivate() - } - - @Test - func refreshFailureRetriesBeforeInstalledCredentialSafetyDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let expiresAt = fixture.now.addingTimeInterval(5 * 60) - let refreshAfter = expiresAt.addingTimeInterval(-60) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - refreshAfter: refreshAfter, - expiresAt: expiresAt - ) - ) - #expect(await clockEvents.next() == .sleep(refreshAfter)) - - clock.advance(to: refreshAfter) - - guard case let .sleep(retryDeadline) = await clockEvents.next() else { - Issue.record("Expected a relay retry before credential expiry") - return - } - #expect(retryDeadline == expiresAt.addingTimeInterval(-30)) - #expect(retryDeadline < expiresAt) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - await coordinator.deactivate() - } - - @Test - func mismatchedBootstrapFleetNeverMutatesEndpoint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.failure]), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - let incomplete = try fixture.response(relayURLs: [fixture.relayURLs[0]]) - - await #expect( - throws: CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - ) { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: incomplete - ) - } - await coordinator.deactivate() - - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - } -} - -private actor TestRelayIssueGate { - private var isParked = false - private var parkContinuation: CheckedContinuation? - private var parkedWaiters: [CheckedContinuation] = [] - - func park() async { - isParked = true - let waiters = parkedWaiters - parkedWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { parkContinuation = $0 } - } - - func waitUntilParked() async { - guard !isParked else { return } - await withCheckedContinuation { parkedWaiters.append($0) } - } - - func release() { - parkContinuation?.resume() - parkContinuation = nil - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift deleted file mode 100644 index e6ed9e03f3af..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ /dev/null @@ -1,706 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRelayCredentialCoordinatorTests { - @Test - func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws { - let hostIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "1a", count: 32) - ) - let clientIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "b7", count: 32) - ) - let hostSchedule = CmxIrohRelayRefreshSchedule( - role: .host, - endpointIdentity: hostIdentity - ) - let clientSchedule = CmxIrohRelayRefreshSchedule( - role: .client, - endpointIdentity: clientIdentity - ) - let now = Date(timeIntervalSince1970: 1_700_000_000) - var hostSeconds: [Int] = [] - var clientSeconds: [Int] = [] - - for cycle in 1 ... 8 { - let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240)) - let hostDeadline = hostSchedule.deadline( - now: now, - refreshAfter: refreshAfter - ) - let clientDeadline = clientSchedule.deadline( - now: now, - refreshAfter: refreshAfter - ) - let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60 - let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60 - hostSeconds.append(hostSecond) - clientSeconds.append(clientSecond) - - #expect((0 ... 14).contains(hostSecond)) - #expect((30 ... 44).contains(clientSecond)) - #expect(hostDeadline >= now) - #expect(clientDeadline >= now) - #expect(hostDeadline <= refreshAfter) - #expect(clientDeadline <= refreshAfter) - } - - #expect(Set(hostSeconds).count == 1) - #expect(Set(clientSeconds).count == 1) - } - - @Test - func refreshSlotsSpreadEndpointsWithinEachRole() throws { - let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240) - let now = refreshAfter.addingTimeInterval(-240) - let hostSlots = try (0 ..< 16).map { index in - let identity = try CmxIrohPeerIdentity( - endpointID: String(format: "%064x", index + 1) - ) - return Int( - CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity) - .deadline(now: now, refreshAfter: refreshAfter) - .timeIntervalSince1970 - ) % 60 - } - let clientSlots = try (0 ..< 16).map { index in - let identity = try CmxIrohPeerIdentity( - endpointID: String(format: "%064x", index + 1) - ) - return Int( - CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity) - .deadline(now: now, refreshAfter: refreshAfter) - .timeIntervalSince1970 - ) % 60 - } - - #expect(Set(hostSlots).count > 1) - #expect(hostSlots.allSatisfy { (0 ... 14).contains($0) }) - #expect(Set(clientSlots).count > 1) - #expect(clientSlots.allSatisfy { (30 ... 44).contains($0) }) - } - - @Test - func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let response = try fixture.response() - let installs = TestRelayCredentialInstallRecorder() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await installs.record(response) - } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: response - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - await installs.waitForCount(1) - #expect(await installs.values() == [response]) - #expect(await broker.observedEndpointIDs().isEmpty) - await coordinator.deactivate() - #expect(await clockEvents.next() == .cancelled) - } - - @Test - func stalledCredentialPersistenceDoesNotBlockRefreshScheduling() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let persistence = TestRelayCredentialPersistenceGate() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await persistence.persist(response) - } - ) - - let activation = Task { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - } - await persistence.waitUntilStarted() - for _ in 0 ..< 20 { await Task.yield() } - - #expect(clock.observedSleepDeadlines() == [fixture.refreshAfter]) - #expect(await endpoint.observedRelayUpdates().count == 1) - - await persistence.resume() - try await activation.value - await coordinator.deactivate() - } - - @Test - func bootstrapKeepsEachTokenAssociatedWithItsSignedRelayURL() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response(tokens: ["abc234", "def567"]) - ) - - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(updates[0].map(\.token) == ["abc234", "def567"]) - await coordinator.deactivate() - } - - @Test - func selectedManagedSubsetInstallsOnlyChosenRelayAfterFullFleetValidation() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let selectedURL = fixture.relayURLs[1] - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - selectedRelayURLs: [selectedURL], - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - let profiles = await endpoint.observedRelayProfileUpdates() - #expect(profiles.count == 1) - #expect(profiles[0].allowedRelayURLs == [selectedURL]) - #expect(profiles[0].managedRelays.map(\.url) == [selectedURL]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - await coordinator.deactivate() - } - - @Test - func missingBootstrapRefreshesImmediatelyAndInstallsWithoutRebinding() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.response(try fixture.response())]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func transientMintFailureKeepsEndpointAliveAndBacksOff() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay retry sleep") - return - } - #expect(deadline == fixture.now.addingTimeInterval(30)) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func requiredInitialCredentialWaitsThroughTransientMintFailure() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [ - .failure, - .response(try fixture.response()), - ]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let completions = TestRelayActivationCompletionRecorder() - let installs = TestRelayCredentialInstallRecorder() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retrySchedule: CmxIrohRetrySchedule( - initialDelay: 1, - maximumDelay: 1, - jitterFraction: 0 - ), - retryJitter: { 0 }, - credentialDidInstall: { response in - await installs.record(response) - } - ) - let activation = Task { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - waitForInitialCredential: true - ) - await completions.record() - } - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the initial relay retry sleep") - return - } - for _ in 0 ..< 20 { await Task.yield() } - #expect(deadline == fixture.now.addingTimeInterval(1)) - #expect(await completions.count() == 0) - - clock.advance(to: deadline) - try await activation.value - await installs.waitForCount(1) - #expect(await completions.count() == 1) - #expect(await broker.observedEndpointIDs() == [fixture.identity, fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 1) - await coordinator.deactivate() - } - - @Test - func refreshFailureKeepsLastGoodCredentialWithoutThrowing() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - // The installed credential is due for refresh but far from expiry. - clock.setNowWithoutResuming(fixture.refreshAfter.addingTimeInterval(1)) - - // A transient mint failure must not fail the caller while the - // last-good credential is installed (cmux#10375). The bounded retry - // loop keeps refreshing in the background; only the relay itself can - // reject the installed credential. - try await coordinator.refreshIfNeeded() - - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - await coordinator.deactivate() - } - - @Test - func rateLimitRetryNeverPrecedesValidatedServerFloor() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.rateLimited(600)]), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - let clockEvent = await clockEvents.next() - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(clockEvent == .sleep(fixture.now.addingTimeInterval(600))) - await coordinator.deactivate() - } - - @Test - func restoredCooldownRetryNeverPrecedesPersistedServerFloor() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.cooldown(600)]), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - let clockEvent = await clockEvents.next() - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(clockEvent == .sleep(fixture.now.addingTimeInterval(600))) - await coordinator.deactivate() - } - -} - -private actor TestRelayActivationCompletionRecorder { - private var completionCount = 0 - - func record() { - completionCount += 1 - } - - func count() -> Int { - completionCount - } -} - -private actor TestRelayCredentialInstallRecorder { - private var responses: [CmxIrohRelayTokenResponse] = [] - private var waiters: [(Int, CheckedContinuation)] = [] - - func record(_ response: CmxIrohRelayTokenResponse) { - responses.append(response) - let ready = waiters.filter { responses.count >= $0.0 } - waiters.removeAll { responses.count >= $0.0 } - for (_, continuation) in ready { continuation.resume() } - } - - func values() -> [CmxIrohRelayTokenResponse] { - responses - } - - func waitForCount(_ count: Int) async { - guard responses.count < count else { return } - await withCheckedContinuation { continuation in - waiters.append((count, continuation)) - } - } -} - -private actor TestRelayCredentialPersistenceGate { - private var started = false - private var startWaiters: [CheckedContinuation] = [] - private var persistenceContinuation: CheckedContinuation? - - func persist(_: CmxIrohRelayTokenResponse) async { - started = true - let waiters = startWaiters - startWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { continuation in - persistenceContinuation = continuation - } - } - - func waitUntilStarted() async { - guard !started else { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func resume() { - persistenceContinuation?.resume() - persistenceContinuation = nil - } -} - -actor TestRelayTokenBroker: CmxIrohRelayTokenServing { - enum Step: Sendable { - case response(CmxIrohRelayTokenResponse) - case failure - case rateLimited(Int) - case cooldown(Int) - } - - private var steps: [Step] - private var endpointIDs: [CmxIrohPeerIdentity] = [] - private var issueCount = 0 - private let issueHook: (@Sendable (_ count: Int) async -> Void)? - - init( - steps: [Step], - issueHook: (@Sendable (_ count: Int) async -> Void)? = nil - ) { - self.steps = steps - self.issueHook = issueHook - } - - func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - endpointIDs.append(endpointID) - issueCount += 1 - await issueHook?(issueCount) - guard !steps.isEmpty else { throw TestRelayCoordinatorError.noResponse } - switch steps.removeFirst() { - case let .response(response): - return response - case .failure: - throw TestRelayCoordinatorError.transient - case let .rateLimited(retryAfterSeconds): - throw CmxIrohTrustBrokerClientError.rateLimited( - code: "rate_limited", - retryAfterSeconds: retryAfterSeconds - ) - case let .cooldown(retryAfterSeconds): - throw CmxIrohBrokerCooldownError( - retryAfterSeconds: retryAfterSeconds - ) - } - } - - func observedEndpointIDs() -> [CmxIrohPeerIdentity] { - endpointIDs - } -} - -final class TestRelayClock: CmxIrohRelayClock, @unchecked Sendable { - enum Event: Equatable, Sendable { - case sleep(Date) - case cancelled - } - - private let lock = NSLock() - private var currentDate: Date - private var sleepers: [UUID: CheckedContinuation] = [:] - private var sleepDeadlines: [Date] = [] - private let eventStream: AsyncStream - private let continuation: AsyncStream.Continuation - - init(now: Date) { - currentDate = now - let events = AsyncStream.makeStream() - eventStream = events.stream - continuation = events.continuation - } - - func now() -> Date { - lock.withLock { currentDate } - } - - func sleep(until deadline: Date) async throws { - lock.withLock { sleepDeadlines.append(deadline) } - continuation.yield(.sleep(deadline)) - let id = UUID() - try await withTaskCancellationHandler { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { sleeper in - lock.withLock { - sleepers[id] = sleeper - } - if Task.isCancelled { - cancelSleep(id: id) - } - } - } onCancel: { - cancelSleep(id: id) - } - } - - func advance(to date: Date) { - let pending = lock.withLock { () -> [CheckedContinuation] in - currentDate = date - defer { sleepers.removeAll() } - return Array(sleepers.values) - } - for sleeper in pending { - sleeper.resume() - } - } - - func setNowWithoutResuming(_ date: Date) { - lock.withLock { currentDate = date } - } - - func events() -> AsyncStream { - eventStream - } - - func observedSleepDeadlines() -> [Date] { - lock.withLock { sleepDeadlines } - } - - private func cancelSleep(id: UUID) { - let sleeper = lock.withLock { sleepers.removeValue(forKey: id) } - guard let sleeper else { return } - continuation.yield(.cancelled) - sleeper.resume(throwing: CancellationError()) - } -} - -private enum TestRelayCoordinatorError: Error { - case noResponse - case transient -} - -struct RelayCoordinatorFixture: Sendable { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let bindingID = "123e4567-e89b-42d3-a456-426614174010" - let identity: CmxIrohPeerIdentity - let relayURLs = [ - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - var refreshAfter: Date { - now.addingTimeInterval(12 * 60 * 60) - } - - var expiresAt: Date { - now.addingTimeInterval(24 * 60 * 60) - } - - init() throws { - identity = try CmxIrohPeerIdentity(endpointID: String(repeating: "ab", count: 32)) - } - - func activeSupervisor( - endpoint: TestIrohEndpoint - ) async throws -> CmxIrohEndpointSupervisor { - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: Set(relayURLs), - relays: [] - ) - ) - _ = try await supervisor.activate() - return supervisor - } - - func response( - relayURLs: [String]? = nil, - tokens: [String]? = nil, - refreshAfter: Date? = nil, - expiresAt: Date? = nil - ) throws -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let urls = relayURLs ?? self.relayURLs - if let tokens { - guard tokens.count == urls.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse( - credentials: zip(urls, tokens).map { url, token in - CmxIrohManagedRelayCredential( - relayURL: url, - token: token, - expiresAt: formatter.string( - from: expiresAt ?? self.expiresAt - ), - refreshAfter: formatter.string( - from: refreshAfter ?? self.refreshAfter - ) - ) - } - ) - } - let object: [String: Any] = [ - "token": "abc234", - "expires_at": formatter.string(from: expiresAt ?? self.expiresAt), - "refresh_after": formatter.string(from: refreshAfter ?? self.refreshAfter), - "relay_fleet": urls, - ] - return try JSONDecoder().decode( - CmxIrohRelayTokenResponse.self, - from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - ) - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift index fb419cb6085b..19488096cef5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift @@ -6,16 +6,12 @@ import Testing @Suite(.serialized) struct CmxIrohRelayPolicyBrokerTests { @Test - func bootstrapAcceptsRevisionZeroAndNullableToken() async throws { + func policyFetchUsesTheCredentialFreePolicyRoute() async throws { let transport = RecordingBrokerTransport(responses: [ .json( status: 200, body: """ { - "token": null, - "expiresAt": 1782000300, - "ttlSeconds": 300, - "relays": ["https://usc1.relay.cmux.dev"], "policy": "aaa.bbb.ccc", "preference": {"mode":"automatic"}, "preferenceRevision": 0 @@ -25,16 +21,16 @@ struct CmxIrohRelayPolicyBrokerTests { ]) let client = try makeClient(transport: transport) - let response = try await client.issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID) - ) + let response = try await client.fetchRelayPolicy() - #expect(response.relayToken == nil) - #expect(response.relayPolicy.preference == .automatic) - #expect(response.relayPolicy.preferenceRevision == 0) + #expect(response.preference == .automatic) + #expect(response.preferenceRevision == 0) let request = try #require(await transport.requests().first) - #expect(request.url?.path == "/api/relay/token") - #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/relay/policy") + #expect(request.httpMethod == "GET") + // Tokenless transport: the policy fetch carries no endpoint binding + // payload and mints nothing. + #expect(request.httpBody == nil) } @Test diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift index a7c047ebbf9b..a83d287dbe38 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift @@ -3,19 +3,17 @@ import Foundation import Testing @testable import CmuxIrohTransport -private actor RefreshBootstrapBroker: CmxIrohRelayPolicyServing { - private let bootstrap: CmxIrohRelayBootstrapResponse - private(set) var bootstrapRequestCount = 0 +private actor RefreshPolicyBroker: CmxIrohRelayPolicyServing { + private let response: CmxIrohRelayPolicyResponse + private(set) var policyRequestCount = 0 - init(bootstrap: CmxIrohRelayBootstrapResponse) { - self.bootstrap = bootstrap + init(response: CmxIrohRelayPolicyResponse) { + self.response = response } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - bootstrapRequestCount += 1 - return bootstrap + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + policyRequestCount += 1 + return response } func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { @@ -31,55 +29,17 @@ private actor RefreshBootstrapBroker: CmxIrohRelayPolicyServing { @Suite struct CmxIrohRelayPolicyServiceRefreshTests { + /// A refresh installs the signed policy and yields a tokenless managed + /// profile: every allowed relay is active with no client credential, + /// because relay admission is the relay's server-side allow hook. @Test - func refreshWithCredentialReturnsMintedCredentialWithEffectivePolicy() async throws { + func refreshInstallsTokenlessManagedProfile() async throws { let fixture = RelayPolicyServiceTestFixture() - let credential = fixture.relayCredential() - let broker = RefreshBootstrapBroker( - bootstrap: CmxIrohRelayBootstrapResponse( - relayToken: credential, - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 1 - ) - ) - ) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()), - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ), - broker: broker - ) - - let outcome = try await service.refreshWithCredential( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - - #expect(outcome.relayCredential == credential) - #expect(outcome.effective.endpointRelayProfile.allowedRelayURLs - == Set(fixture.relayURLs)) - #expect(await broker.bootstrapRequestCount == 1) - } - - @Test - func refreshDelegatesToRefreshWithCredential() async throws { - let fixture = RelayPolicyServiceTestFixture() - let broker = RefreshBootstrapBroker( - bootstrap: CmxIrohRelayBootstrapResponse( - relayToken: nil, - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 1 - ) + let broker = RefreshPolicyBroker( + response: try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 ) ) let service = CmxIrohRelayPolicyService( @@ -92,15 +52,16 @@ struct CmxIrohRelayPolicyServiceRefreshTests { ) let effective = try await service.refresh( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, now: fixture.now ) #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) - #expect(await broker.bootstrapRequestCount == 1) + #expect(effective.endpointRelayProfile.activeRelays.count == fixture.relayURLs.count) + #expect(effective.endpointRelayProfile.activeRelays.allSatisfy { + $0.authenticationToken == nil + }) + #expect(await broker.policyRequestCount == 1) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift index bbc5087fd0bf..47955980654c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift @@ -85,14 +85,12 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(effective.source == .customUnavailable) #expect(effective.endpointRelayProfile.allowedRelayURLs.isEmpty) #expect(effective.endpointRelayProfile.activeRelays.isEmpty) - #expect(effective.relayBootstrap == nil) #expect(effective.missingCredentialRelayIDs == [authenticatedRelay.id]) #expect(await stores.service.diagnosticsSnapshot().failure == .missingCustomCredential) } @@ -122,7 +120,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -173,7 +170,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect( @@ -192,7 +188,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -280,7 +275,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) await preferenceSecureStore.setUnavailable(true) @@ -289,7 +283,6 @@ extension CmxIrohRelayPolicyServiceTests { updated, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -342,7 +335,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) let oldActive = try await service.setStaticCredential( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift index cbe688287df4..aa3c9eba62a3 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift @@ -20,7 +20,6 @@ struct CmxIrohRelayPolicyServiceTests { response: response, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -28,7 +27,6 @@ struct CmxIrohRelayPolicyServiceTests { #expect(effective.staleRelayIDs == ["removed-relay"]) #expect(effective.endpointRelayProfile.allowedRelayURLs == [fixture.relayURLs[0]]) #expect(effective.managedSnapshot?.relays.map(\.id) == ["cmux-us"]) - #expect(effective.relayBootstrap == fixture.relayCredential()) let stored = try #require( try await stores.preferenceStore.load(accountID: "account-a") ) @@ -44,13 +42,11 @@ struct CmxIrohRelayPolicyServiceTests { response: fullyStale, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(directOnly.source == .managedUnavailable) #expect(directOnly.effectivePreference == nil) #expect(directOnly.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(directOnly.relayBootstrap == nil) #expect(await service.diagnosticsSnapshot().failure == .staleManagedSelection) } @@ -76,7 +72,6 @@ struct CmxIrohRelayPolicyServiceTests { response: response, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(missing.source == .customUnavailable) @@ -128,7 +123,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now ) @@ -164,7 +158,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now ) @@ -199,7 +192,6 @@ struct CmxIrohRelayPolicyServiceTests { _ = await service.restore( accountID: "account-a", trustRoot: trustRoot, - relayCredential: nil, now: Date() ) } @@ -228,7 +220,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -241,7 +232,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -263,7 +253,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -276,7 +265,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -300,14 +288,12 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) let expired = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now.addingTimeInterval( 3_600 + CmxIrohRelayPolicyService.defaultExpiredPolicyReuseGrace + 1 ) @@ -330,7 +316,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -340,7 +325,6 @@ struct CmxIrohRelayPolicyServiceTests { let rejected = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, - relayCredential: nil, now: fixture.now.addingTimeInterval(3_600 + 300) ) @@ -360,7 +344,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) _ = try await stores.service.install( @@ -371,14 +354,12 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) let restored = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(restored.usedCachedPolicy) @@ -390,7 +371,6 @@ struct CmxIrohRelayPolicyServiceTests { let graced = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now.addingTimeInterval(3_600) ) #expect(graced.source == .managed) @@ -411,7 +391,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -422,7 +401,6 @@ struct CmxIrohRelayPolicyServiceTests { let restored = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now.addingTimeInterval(3_600 + 300) ) @@ -444,7 +422,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -457,7 +434,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -595,9 +571,7 @@ actor RelayPolicyServiceBroker: CmxIrohRelayPolicyServing { self.responses = responses } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { throw Failure.unsupported } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift index 3b8a2909d29d..186eac383faa 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift @@ -298,8 +298,11 @@ struct CmxIrohRelayPolicyTests { #expect(await store.recordCount() == 1) } + /// A verified managed selection installs tokenless: every selected relay + /// is active with no client credential, because relay admission is the + /// relay's server-side allow hook, not a token. @Test - func endpointProfileRequiresExactCredentialsForVerifiedSelection() throws { + func endpointProfileFromVerifiedSelectionIsTokenless() throws { let fixture = try Fixture() let policy = try CmxIrohRelayPolicyVerifier().verify( fixture.token(sequence: 7), @@ -310,25 +313,11 @@ struct CmxIrohRelayPolicyTests { policy: policy, selection: .only(["cmux-eu"]) ) - let selected = try fixture.relayConfiguration(url: fixture.relayURLs[1]) - let profile = try CmxIrohEndpointRelayProfile( - snapshot: snapshot, - relays: [selected] - ) + let profile = try CmxIrohEndpointRelayProfile(snapshot: snapshot) #expect(profile.allowedRelayURLs == [fixture.relayURLs[1]]) - #expect(profile.managedRelays == [selected]) - #expect(throws: CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: []) - } - let substituted = try fixture.relayConfiguration( - url: "https://capture.example.com/" - ) - #expect( - throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted.url) - ) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: [substituted]) - } + #expect(profile.activeRelays.map(\.url) == [fixture.relayURLs[1]]) + #expect(profile.activeRelays.allSatisfy { $0.authenticationToken == nil }) } private struct Fixture { @@ -425,15 +414,5 @@ struct CmxIrohRelayPolicyTests { .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } - - func relayConfiguration(url: String) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: "aaaa", - expiresAt: now.addingTimeInterval(3_600), - refreshAfter: now.addingTimeInterval(1_800), - now: now - ) - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift index 5d863b360390..93c14883014d 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift @@ -51,8 +51,7 @@ struct CmxIrohSelectedTransportPathTests { relays: [descriptor] ) let endpointProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: [url], - relays: [] + managedRelayURLs: [url] ) let effective = CmxIrohEffectiveRelayPolicy( endpointRelayProfile: endpointProfile, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift index 8cd6e833f7d2..541e6b4bcdd0 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift @@ -284,8 +284,11 @@ struct CmxIrohTrustBrokerClientTests { #expect(!response.embeddedDiscoveryComplete) } + /// A legacy "issued" relay status decodes without retaining any token: + /// clients hold no relay credentials, so the payload is dropped on the + /// floor and the status collapses to the credential-free case. @Test - func issuedRegistrationBuildsTheExactManagedRelayFleet() async throws { + func issuedRegistrationRelayStatusDecodesWithoutRetainingTokens() async throws { let transport = RecordingBrokerTransport(responses: [ .json(status: 201, body: Self.registrationResponse), ]) @@ -298,14 +301,7 @@ struct CmxIrohTrustBrokerClientTests { signature: String(repeating: "A", count: 86) ) ) - guard case let .issued(relay) = response.relay else { - Issue.record("Expected an issued relay credential") - return - } - let now = try #require(ISO8601DateFormatter().date(from: "2026-07-10T00:00:00Z")) - let configurations = try relay.relayConfigurations(now: now) - #expect(configurations.map(\.url) == Self.relayURLs) - #expect(configurations.allSatisfy { $0.token == "abc234" }) + #expect(response.relay == .unavailable) } @Test @@ -336,197 +332,6 @@ struct CmxIrohTrustBrokerClientTests { #expect(response.relay == .notRequested) } - @Test - func relayTokenBindsCanonicalHexEndpointAndNormalizesFleetOrigins() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev","https://euw4.relay.cmux.dev/"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://euw4.relay.cmux.dev/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.count == 2) - #expect(configurations.allSatisfy { - $0.token == Self.relayJWT - }) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - let body = try #require(captured.httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object.count == 1) - #expect(object["endpointId"] as? String == Self.endpointID) - } - - @Test - func relayTokenPreservesDistinctCredentialsForEachServerDrivenRelay() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(Self.endpointID)", - "relayCredentials":[ - { - "relayUrl":"https://usc1.relay.cmux.dev", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }, - { - "relayUrl":"https://relay.other.example/", - "token":"def567", - "expiresAt":1782000360, - "refreshAfter":1782000240, - "ttlSeconds":360 - } - ] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://relay.other.example/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.map(\.token) == ["abc234", "def567"]) - #expect(configurations[0].expiresAt != configurations[1].expiresAt) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - } - - @Test - func relayTokenRejectsCredentialAssociationForAnotherEndpoint() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(String(repeating: "f", count: 64))", - "relayCredentials":[{ - "relayUrl":"https://usc1.relay.cmux.dev/", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsCredentialCatalogAboveBound() async throws { - let credentials = (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount + 1) - .map { index in - """ - {"relayUrl":"https://relay-\(index).example/","token":"abc234","expiresAt":1782000300,"refreshAfter":1782000240,"ttlSeconds":300} - """ - } - .joined(separator: ",") - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"endpointId":"\(Self.endpointID)","relayCredentials":[\(credentials)]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsNonOriginFleetURL() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://relay.cmux.dev/capture"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsJWTBoundToAnotherEndpoint() async throws { - let substituted = Self.makeRelayJWT(endpointID: String(repeating: "f", count: 64)) - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(substituted)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - @Test func revokeUsesTheBrokerDeleteRoute() async throws { let transport = RecordingBrokerTransport(responses: [ diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift index d38a5ba43e8f..9ae0d9a086fe 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift @@ -68,17 +68,6 @@ struct RelayPolicyServiceTestFixture { return "\(input).\(Self.base64URL(signature))" } - func relayCredential() -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return CmxIrohRelayTokenResponse( - token: "aaaa", - expiresAt: formatter.string(from: now.addingTimeInterval(3_600)), - refreshAfter: formatter.string(from: now.addingTimeInterval(1_800)), - relayFleet: relayURLs - ) - } - private func trustRoot( includeFirst: Bool, includeSecond: Bool diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift index 69b72c0fddbc..36ac5f3fea60 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift @@ -39,7 +39,7 @@ actor TestBlockingRelayUpdateEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) async { + func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) async { updateContinuation.yield(()) await withCheckedContinuation { continuation in releaseContinuation = continuation diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift index 28535077a35c..d4140c77dcaa 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift @@ -48,7 +48,6 @@ actor TestCancellableDialEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift index bba6540dfd6f..fada16bdbeb5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift @@ -54,7 +54,6 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { healthStream diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift index e39fee9f38a6..ae3c8d829ddb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift @@ -36,7 +36,6 @@ actor TestGatedDialEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift index 1af9fb607849..8560a76187fd 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift @@ -47,7 +47,6 @@ actor TestHangingDialEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift index 49705c8f5c4e..270eb18e63a2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift @@ -5,7 +5,6 @@ import Foundation actor TestIrohClientBroker: CmxIrohClientBrokerServing { private let registration: CmxIrohRegistrationResponse private let discoveryResponse: CmxIrohDiscoveryResponse - private let relayResponse: CmxIrohRelayTokenResponse private let pairGrantResponse: CmxIrohPairGrantResponse? private let bindingAuthorizationAvailable: Bool private let revokeError: (any Error)? @@ -15,7 +14,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { private var registrationErrorsByCount: [Int: any Error] = [:] private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] private var revokedBindingIDs: [String] = [] - private var relayIssueCount = 0 private var discoveryCount = 0 private var discoveryErrorsByCount: [Int: any Error] = [:] private var registrationCountWaiters: [ @@ -28,10 +26,8 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { init( binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse, - relay: CmxIrohRelayTokenResponse, pairGrant: CmxIrohPairGrantResponse? = nil, bindingAuthorizationAvailable: Bool = true, - issueRelayAtRegistration: Bool = true, registrationError: (any Error)? = nil, discoveryErrorsByCount: [Int: any Error] = [:], revokeError: (any Error)? = nil, @@ -40,10 +36,9 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { ) { registration = CmxIrohRegistrationResponse( binding: binding, - relay: issueRelayAtRegistration ? .issued(relay) : .unavailable + relay: .unavailable ) discoveryResponse = discovery - relayResponse = relay pairGrantResponse = pairGrant self.bindingAuthorizationAvailable = bindingAuthorizationAvailable self.revokeError = revokeError @@ -107,14 +102,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { return pairGrantResponse } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - return relayResponse - } - func revoke(bindingID: String) throws { revokedBindingIDs.append(bindingID) if let revokeError { throw revokeError } @@ -136,10 +123,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { revokedBindingIDs } - func observedRelayIssueCount() -> Int { - relayIssueCount - } - func observedDiscoveryCount() -> Int { discoveryCount } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift index 76a118005180..3a761b475581 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift @@ -10,7 +10,6 @@ actor TestIrohEndpoint: CmxIrohEndpoint { private let healthStream: AsyncStream private let healthContinuation: AsyncStream.Continuation private var closeCallCount = 0 - private var relayUpdates: [[CmxIrohRelayConfiguration]] = [] private var relayProfileUpdates: [CmxIrohEndpointRelayProfile] = [] private var relayUpdateShouldFail = false private var healthy = true @@ -75,6 +74,10 @@ actor TestIrohEndpoint: CmxIrohEndpoint { func localDirectAddresses() -> [String] { directAddresses } + func setPathHints(_ hints: [CmxIrohPathHint]) { + pathHints = hints + } + func setDirectAddresses(_ addresses: [String]) { directAddresses = addresses } @@ -90,23 +93,16 @@ actor TestIrohEndpoint: CmxIrohEndpoint { nil } - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) throws { + func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) throws { if relayUpdateShouldFail { throw TestIrohTransportError.relayUpdateFailed } - relayUpdates.append(relays) + relayProfileUpdates.append(profile) if let pathHintsAfterRelayReplacement { pathHints = pathHintsAfterRelayReplacement } } - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) throws { - if relayUpdateShouldFail { - throw TestIrohTransportError.relayUpdateFailed - } - relayProfileUpdates.append(profile) - } - func healthEvents() -> AsyncStream { healthStream } @@ -136,10 +132,6 @@ actor TestIrohEndpoint: CmxIrohEndpoint { closeCallCount } - func observedRelayUpdates() -> [[CmxIrohRelayConfiguration]] { - relayUpdates - } - func observedRelayProfileUpdates() -> [CmxIrohEndpointRelayProfile] { relayProfileUpdates } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift deleted file mode 100644 index 614769d7c4c1..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift +++ /dev/null @@ -1,30 +0,0 @@ -#if DEBUG -import Foundation - -struct MobileIrohReleaseGateArtifactPreparation: Equatable, Sendable { - static let requiredStableStatObservations = 2 - - let path: String - let suffixText: String - let completionMarker: String - let command: String - - static func make( - path: String, - suffixText: String, - marker: String - ) -> MobileIrohReleaseGateArtifactPreparation { - let completionPrefix = "CMUX_IROH_ARTIFACT_READY_" - let completionNonce = String(marker.suffix(24)) - return MobileIrohReleaseGateArtifactPreparation( - path: path, - suffixText: suffixText, - completionMarker: completionPrefix + completionNonce, - command: "dd if=/dev/zero of='\(path)' bs=1048576 count=32 2>/dev/null; " - + "printf '%s' '\(suffixText)' >> '\(path)'; " - + "printf '\\n%s\\n' '\(path)'; " - + "printf '\\n%s%s\\n' '\(completionPrefix)' '\(completionNonce)'\n" - ) - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift index b110b6e03cd8..d607899e183e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift @@ -39,39 +39,5 @@ public enum MobileIrohReleaseGateProbeFailure: String, Error, Equatable, Sendabl case chatSessionsFailed /// The terminal artifact count-only scan failed validation. case artifactScanCountFailed - /// Required local endpoint or QUIC continuity evidence was unavailable. - case continuityEvidenceUnavailable - /// The endpoint, native connection, or credential expiry violated rollover. - case relayRolloverFailed - /// The RPC control stream or held terminal stream did not survive rollover. - case controlStreamContinuityFailed - /// The installed independent event registration did not survive rollover. - case independentEventsContinuityFailed - /// The terminal never confirmed that the rollover artifact was closed. - case artifactCommandNotCompleted - /// The artifact scan never authorized the exact generated path. - case artifactScanPathMissing - /// The artifact did not reach two stable observations at the expected size. - case artifactStatSizeMismatch - /// Artifact scan or stat RPC transport failed before readiness was established. - case artifactReadinessRPCFailed - /// The Mac returned no valid artifact-lane descriptor for the completed file. - case artifactDescriptorInvalid - /// The artifact descriptor RPC failed before returning a response. - case artifactDescriptorRPCFailed - /// The independent Iroh artifact stream could not be opened. - case artifactLaneOpenFailed - /// The independent Iroh artifact stream returned a transport read error. - case artifactLaneReadFailed - /// The independent artifact stream did not begin with the expected byte. - case artifactLaneInitialByteMismatch - /// The independent artifact stream returned more bytes than the descriptor promised. - case artifactLaneOverrun - /// The independent artifact stream ended before the descriptor's promised size. - case artifactLaneTruncated - /// The independent artifact stream ended with unexpected content. - case artifactLaneTailMismatch - /// A held relay credential remained admitted beyond its hard expiry. - case unrefreshedCredentialDidNotDisconnect } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift index 80bdd5a8df45..9b38438fbdbc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift @@ -17,32 +17,8 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { public let chatSessionsVerified: Bool /// Whether a content-free terminal artifact count scan decoded. public let artifactScanCountVerified: Bool - /// Whether the installed relay credential advanced past its old expiry. - public let relayCredentialRolloverVerified: Bool - /// Whether the local EndpointID stayed unchanged through rollover. - public let endpointContinuityVerified: Bool - /// Whether the exact native QUIC connection stayed unchanged. - public let connectionContinuityVerified: Bool - /// Whether the same RPC control stream remained usable through rollover. - public let controlStreamContinuityVerified: Bool - /// Whether one independent event registration remained installed. - public let independentEventsContinuityVerified: Bool - /// Whether an already-open artifact lane delivered after old expiry. - public let artifactLaneVerified: Bool - /// Whether a deliberately unrefreshed relay credential caused disconnect. - public let unrefreshedExpiryDisconnectVerified: Bool - /// Whole seconds spent driving control traffic during rollover. - public let soakDurationSeconds: Int /// Creates a successful probe result. - /// - Parameters: - /// - hostStatusVerified: Host-status verification result. - /// - terminalRoundTripVerified: Terminal round-trip verification result. - /// - workspaceMutationVerified: Reversible workspace mutation result. - /// - independentEventsVerified: Independent event lane verification result. - /// - notificationReconcileVerified: Notification reconcile verification result. - /// - chatSessionsVerified: Chat-session snapshot verification result. - /// - artifactScanCountVerified: Artifact count-only scan verification result. public init( hostStatusVerified: Bool, rpcMethodInventoryVerified: Bool, @@ -51,15 +27,7 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { independentEventsVerified: Bool, notificationReconcileVerified: Bool, chatSessionsVerified: Bool, - artifactScanCountVerified: Bool, - relayCredentialRolloverVerified: Bool = false, - endpointContinuityVerified: Bool = false, - connectionContinuityVerified: Bool = false, - controlStreamContinuityVerified: Bool = false, - independentEventsContinuityVerified: Bool = false, - artifactLaneVerified: Bool = false, - unrefreshedExpiryDisconnectVerified: Bool = false, - soakDurationSeconds: Int = 0 + artifactScanCountVerified: Bool ) { self.hostStatusVerified = hostStatusVerified self.rpcMethodInventoryVerified = rpcMethodInventoryVerified @@ -69,14 +37,6 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { self.notificationReconcileVerified = notificationReconcileVerified self.chatSessionsVerified = chatSessionsVerified self.artifactScanCountVerified = artifactScanCountVerified - self.relayCredentialRolloverVerified = relayCredentialRolloverVerified - self.endpointContinuityVerified = endpointContinuityVerified - self.connectionContinuityVerified = connectionContinuityVerified - self.controlStreamContinuityVerified = controlStreamContinuityVerified - self.independentEventsContinuityVerified = independentEventsContinuityVerified - self.artifactLaneVerified = artifactLaneVerified - self.unrefreshedExpiryDisconnectVerified = unrefreshedExpiryDisconnectVerified - self.soakDurationSeconds = soakDurationSeconds } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift index fce93f23fc01..b5b577904832 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift @@ -89,43 +89,5 @@ enum MobileIrohReleaseGateResponseValidator { return response.sessionArtifactTotal.map { $0 >= 0 } ?? true } - static func artifactPath( - _ data: Data, - expectedPath: String - ) -> Bool { - guard let response = try? ChatWireCoding().decode( - TerminalArtifactScanResponse.self, - from: data - ) else { - return false - } - let expectedIdentity = releaseGateArtifactPathIdentity(expectedPath) - return response.artifacts.contains { - releaseGateArtifactPathIdentity($0.path) == expectedIdentity - } - } - - private static func releaseGateArtifactPathIdentity(_ path: String) -> String { - let standardized = (path as NSString).standardizingPath - let canonicalMacOSTemporaryPrefix = "/private/tmp/" - guard standardized.hasPrefix(canonicalMacOSTemporaryPrefix) else { - return standardized - } - return "/tmp/" + standardized.dropFirst(canonicalMacOSTemporaryPrefix.count) - } - - static func artifactLaneDescriptor(_ data: Data) -> ChatArtifactLaneDescriptor? { - try? ChatWireCoding().decode(ChatArtifactLaneDescriptor.self, from: data) - } - - static func artifactStat( - _ data: Data, - expectedSize: Int64 - ) -> Bool { - guard let stat = try? ChatWireCoding().decode(ChatArtifactStat.self, from: data) else { - return false - } - return stat.exists && !stat.isDirectory && stat.size == expectedSize - } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift deleted file mode 100644 index 7cdca304823e..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift +++ /dev/null @@ -1,11 +0,0 @@ -#if DEBUG -/// Long-running relay credential behavior selected by the isolated release gate. -public enum MobileIrohReleaseGateScenario: String, Equatable, Sendable { - /// Existing short app-RPC coverage. - case standard - /// Keep live application lanes open while the relay credential refreshes. - case relayRollover = "relay_rollover" - /// Hold the initial credential and require the relay to close at expiry. - case relayExpiry = "relay_expiry" -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift index 1e01c35d6a85..46c6a6ee5f74 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift @@ -24,11 +24,7 @@ extension MobileShellComposite { /// - Returns: Credential-free proof that all operations succeeded. /// - Throws: ``MobileIrohReleaseGateProbeFailure`` when an invariant fails. public func runIrohReleaseGateProbe( - marker: String, - scenario: MobileIrohReleaseGateScenario = .standard, - soakDurationSeconds: Int = 0, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity? = { nil }, - relayCredentialExpiry: @escaping @Sendable () async -> Date? = { nil } + marker: String ) async throws -> MobileIrohReleaseGateProbeResult { guard connectionState == .connected, activeRoute?.kind == .iroh, @@ -62,58 +58,19 @@ extension MobileShellComposite { } let workspace = target.workspace let terminalID = target.terminalID.rawValue - var relayCredentialRolloverVerified = false - var endpointContinuityVerified = false - var connectionContinuityVerified = false - var controlStreamContinuityVerified = false - var independentEventsContinuityVerified = false - var artifactLaneVerified = false - var unrefreshedExpiryDisconnectVerified = false - switch scenario { - case .standard: - try await verifyReversibleWorkspaceRename( - workspace: workspace, - marker: marker - ) - try await verifyTerminalRoundTrip( - surfaceID: terminalID, - marker: marker - ) - try await verifyIndependentEvents( - client: remoteClient, - marker: marker - ) - case .relayRollover: - let continuity = try await verifyRelayCredentialRollover( - client: remoteClient, - workspace: workspace, - surfaceID: terminalID, - marker: marker, - soakDurationSeconds: soakDurationSeconds, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) - relayCredentialRolloverVerified = continuity.relayCredentialRolloverVerified - endpointContinuityVerified = continuity.endpointContinuityVerified - connectionContinuityVerified = continuity.connectionContinuityVerified - controlStreamContinuityVerified = continuity.controlStreamContinuityVerified - independentEventsContinuityVerified = continuity.independentEventsContinuityVerified - artifactLaneVerified = continuity.artifactLaneVerified - case .relayExpiry: - try await verifyReversibleWorkspaceRename( - workspace: workspace, - marker: marker - ) - try await verifyTerminalRoundTrip( - surfaceID: terminalID, - marker: marker - ) - try await verifyIndependentEvents( - client: remoteClient, - marker: marker - ) - } + try await verifyReversibleWorkspaceRename( + workspace: workspace, + marker: marker + ) + try await verifyTerminalRoundTrip( + surfaceID: terminalID, + marker: marker + ) + try await verifyIndependentEvents( + client: remoteClient, + marker: marker + ) mobileIrohReleaseGateProbeLog.info("probe stage=workspace_mutation state=completed") mobileIrohReleaseGateProbeLog.info("probe stage=terminal_round_trip state=completed") mobileIrohReleaseGateProbeLog.info("probe stage=independent_events state=completed") @@ -134,14 +91,6 @@ extension MobileShellComposite { ) mobileIrohReleaseGateProbeLog.info("probe stage=artifact_scan_count state=completed") - if scenario == .relayExpiry { - unrefreshedExpiryDisconnectVerified = try await verifyUnrefreshedRelayExpiry( - client: remoteClient, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) - } - return MobileIrohReleaseGateProbeResult( hostStatusVerified: true, rpcMethodInventoryVerified: true, @@ -150,15 +99,7 @@ extension MobileShellComposite { independentEventsVerified: true, notificationReconcileVerified: true, chatSessionsVerified: true, - artifactScanCountVerified: true, - relayCredentialRolloverVerified: relayCredentialRolloverVerified, - endpointContinuityVerified: endpointContinuityVerified, - connectionContinuityVerified: connectionContinuityVerified, - controlStreamContinuityVerified: controlStreamContinuityVerified, - independentEventsContinuityVerified: independentEventsContinuityVerified, - artifactLaneVerified: artifactLaneVerified, - unrefreshedExpiryDisconnectVerified: unrefreshedExpiryDisconnectVerified, - soakDurationSeconds: scenario == .relayRollover ? soakDurationSeconds : 0 + artifactScanCountVerified: true ) } @@ -196,530 +137,6 @@ extension MobileShellComposite { } } - private struct RelayRolloverContinuity { - let relayCredentialRolloverVerified: Bool - let endpointContinuityVerified: Bool - let connectionContinuityVerified: Bool - let controlStreamContinuityVerified: Bool - let independentEventsContinuityVerified: Bool - let artifactLaneVerified: Bool - } - - private func verifyRelayCredentialRollover( - client: MobileCoreRPCClient, - workspace: MobileWorkspacePreview, - surfaceID: String, - marker: String, - soakDurationSeconds: Int, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? - ) async throws -> RelayRolloverContinuity { - guard soakDurationSeconds >= 330, - let endpointBefore = await endpointIdentity(), - let credentialExpiryBefore = await relayCredentialExpiry(), - let connectionBefore = await client.transportContinuityID() else { - throw MobileIrohReleaseGateProbeFailure.continuityEvidenceUnavailable - } - - let streamID = "iroh-release-gate-\(marker.suffix(32))" - let eventMarker = "cmux Iroh gate \(marker.suffix(8))" - let subscribe = try MobileCoreRPCClient.requestData( - method: "mobile.events.subscribe", - params: [ - "stream_id": streamID, - "topics": ["workspace.updated"], - ] - ) - let subscribeData = try await client.sendRequest(subscribe) - guard MobileIrohReleaseGateResponseValidator.independentEventSubscription( - subscribeData, - expectedStreamID: streamID, - expectedAlreadySubscribed: false - ) else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - - let artifactPath = "/tmp/cmux-iroh-gate-\(marker.suffix(24)).bin" - // noq's pinned default per-stream receive window is 1.25 MB. Keeping a - // 32 MB prefix unread guarantees the sender remains flow-controlled, - // rather than letting a fully buffered payload masquerade as a live - // post-rollover artifact lane. - let artifactPrefixByteCount = 32 * 1_024 * 1_024 - let artifactSuffix = Data("CMUX_IROH_ARTIFACT_\(marker.suffix(24))".utf8) - let artifactTotalByteCount = artifactPrefixByteCount + artifactSuffix.count - let artifactSuffixText = String(decoding: artifactSuffix, as: UTF8.self) - let artifactPreparation = MobileIrohReleaseGateArtifactPreparation.make( - path: artifactPath, - suffixText: artifactSuffixText, - marker: marker - ) - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_prepare state=begin") - await submitTerminalRawInput( - Data(artifactPreparation.command.utf8), - surfaceID: surfaceID - ) - - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_readiness state=begin") - let readiness: ArtifactReadiness - do { - readiness = try await waitForArtifact( - client: client, - workspaceID: workspace.rpcWorkspaceID.rawValue, - surfaceID: surfaceID, - path: artifactPath, - expectedSize: Int64(artifactTotalByteCount) - ) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=rpc" - ) - throw MobileIrohReleaseGateProbeFailure.artifactReadinessRPCFailed - } - switch readiness { - case .ready: - mobileIrohReleaseGateProbeLog.info( - "probe stage=artifact_readiness state=completed" - ) - case .scanPathMissing: - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=scan_path_missing" - ) - throw MobileIrohReleaseGateProbeFailure.artifactScanPathMissing - case .statSizeMismatch: - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=stat_size_mismatch" - ) - throw MobileIrohReleaseGateProbeFailure.artifactStatSizeMismatch - } - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_prepare state=completed") - let descriptorData: Data - do { - let descriptorRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.fetch", - params: [ - "workspace_id": workspace.rpcWorkspaceID.rawValue, - "surface_id": surfaceID, - "path": artifactPath, - "transport": "iroh_artifact_v1", - ] - ) - descriptorData = try await client.sendRequest(descriptorRequest) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_descriptor state=failed reason=rpc" - ) - throw MobileIrohReleaseGateProbeFailure.artifactDescriptorRPCFailed - } - guard let descriptor = MobileIrohReleaseGateResponseValidator.artifactLaneDescriptor( - descriptorData - ), descriptor.totalSize == Int64(artifactTotalByteCount) else { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_descriptor state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactDescriptorInvalid - } - let artifactConnection: any MobileArtifactLaneConnection - do { - artifactConnection = try await client.openArtifactLane( - resourceID: descriptor.resourceID, - offset: 0 - ) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_open state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneOpenFailed - } - let initialArtifactByte: Data? - do { - initialArtifactByte = try await artifactConnection.receive(maximumByteCount: 1) - } catch { - await artifactConnection.close() - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_first_byte state=failed reason=read" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneReadFailed - } - guard initialArtifactByte == Data([0]) else { - await artifactConnection.close() - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_first_byte state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneInitialByteMismatch - } - - do { - var remaining = soakDurationSeconds - while remaining > 0 { - let interval = min(15, remaining) - try await Task.sleep(for: .seconds(interval)) - let heartbeat = try MobileCoreRPCClient.requestData( - method: "workspace.list", - params: [:] - ) - _ = try await client.sendRequest(heartbeat) - remaining -= interval - } - - guard let endpointAfter = await endpointIdentity(), - let credentialExpiryAfter = await relayCredentialExpiry(), - let connectionAfter = await client.transportContinuityID(), - endpointAfter == endpointBefore, - connectionAfter == connectionBefore, - credentialExpiryAfter > credentialExpiryBefore else { - throw MobileIrohReleaseGateProbeFailure.relayRolloverFailed - } - - let postMarker = "\(marker)_POST_ROLLOVER" - let postMarkerProbe = MobileIrohReleaseGateRenderGridProbe( - surfaceID: surfaceID, - marker: postMarker - ) - var postMarkerIterator = await client.subscribe( - to: ["terminal.render_grid"] - ).makeAsyncIterator() - let postTerminalProbe = MobileIrohReleaseGateTerminalProbe( - marker: postMarker - ) - await submitTerminalRawInput( - postTerminalProbe.command, - surfaceID: surfaceID - ) - var sawPostMarker = false - while let event = await postMarkerIterator.next() { - if postMarkerProbe.consume(event) { - sawPostMarker = true - break - } - } - guard sawPostMarker else { - throw MobileIrohReleaseGateProbeFailure.controlStreamContinuityFailed - } - - let reassertData = try await client.sendRequest(subscribe) - guard MobileIrohReleaseGateResponseValidator.independentEventSubscription( - reassertData, - expectedStreamID: streamID, - expectedAlreadySubscribed: true - ) else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - try await verifyFreshWorkspaceEvent( - client: client, - workspace: workspace, - temporaryName: eventMarker - ) - try await restoreWorkspace(workspace) - - var receivedArtifactByteCount = 1 - var receivedArtifactTail = Data() - do { - while let chunk = try await artifactConnection.receive( - maximumByteCount: 64 * 1_024 - ) { - receivedArtifactByteCount += chunk.count - guard receivedArtifactByteCount <= artifactTotalByteCount else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneOverrun - } - receivedArtifactTail.append(chunk) - if receivedArtifactTail.count > artifactSuffix.count { - receivedArtifactTail.removeFirst( - receivedArtifactTail.count - artifactSuffix.count - ) - } - } - } catch let failure as MobileIrohReleaseGateProbeFailure { - throw failure - } catch { - throw MobileIrohReleaseGateProbeFailure.artifactLaneReadFailed - } - guard receivedArtifactByteCount == artifactTotalByteCount else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneTruncated - } - guard receivedArtifactTail == artifactSuffix else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneTailMismatch - } - - await artifactConnection.close() - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - return RelayRolloverContinuity( - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true - ) - } catch { - await artifactConnection.close() - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await restoreWorkspaceBestEffort(workspace) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - if let failure = error as? MobileIrohReleaseGateProbeFailure { - throw failure - } - throw MobileIrohReleaseGateProbeFailure.relayRolloverFailed - } - } - - private func verifyUnrefreshedRelayExpiry( - client: MobileCoreRPCClient, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? - ) async throws -> Bool { - guard let endpointBefore = await endpointIdentity(), - let credentialExpiryBefore = await relayCredentialExpiry(), - await client.transportContinuityID() != nil, - let closureObservation = await client.transportClosureObservation() else { - throw MobileIrohReleaseGateProbeFailure.continuityEvidenceUnavailable - } - let deadline = credentialExpiryBefore.addingTimeInterval(20) - while Date() < deadline { - try await Task.sleep(for: .seconds(5)) - do { - let heartbeat = try MobileCoreRPCClient.requestData( - method: "workspace.list", - params: [:] - ) - _ = try await client.sendRequest(heartbeat) - } catch let error as MobileShellConnectionError { - guard Date() >= credentialExpiryBefore.addingTimeInterval(-2), - case .connectionClosed = error, - await endpointIdentity() == endpointBefore, - await relayCredentialExpiry() == credentialExpiryBefore, - await transportDidClose( - observation: closureObservation, - client: client - ) else { - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - return true - } catch { - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - } - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - - private func verifyFreshWorkspaceEvent( - client: MobileCoreRPCClient, - workspace: MobileWorkspacePreview, - temporaryName: String - ) async throws { - let eventStream = await client.subscribe(to: ["workspace.updated"]) - try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - for await event in eventStream { - try Task.checkCancellation() - if Self.isFreshWorkspaceEvent(event) { - return true - } - } - return false - } - // Give the structured listener a scheduling opportunity before the - // mutation. A new local stream cannot contain pre-rollover events. - await Task.yield() - try await renameWorkspaceForEvent( - workspace: workspace, - temporaryName: temporaryName - ) - group.addTask { - try await Task.sleep(for: .seconds(10)) - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - defer { group.cancelAll() } - guard try await group.next() == true else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - } - } - - nonisolated private static func isFreshWorkspaceEvent( - _ event: MobileEventEnvelope - ) -> Bool { - // Host events are encoded once per connection and intentionally omit a - // subscription stream ID. The exact server registration is verified by - // the idempotent subscribe acknowledgement immediately before the - // controlled workspace mutation. - event.topic == "workspace.updated" - } - - private func transportDidClose( - observation: CmxTransportClosureObservation, - client: MobileCoreRPCClient - ) async -> Bool { - await observation.waitUntilClosed() - return await client.transportContinuityID() == nil - } - - private enum ArtifactReadiness { - case ready - case scanPathMissing - case statSizeMismatch - } - - private func waitForArtifact( - client: MobileCoreRPCClient, - workspaceID: String, - surfaceID: String, - path: String, - expectedSize: Int64 - ) async throws -> ArtifactReadiness { - let scanRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.scan", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - ] - ) - let statRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.stat", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - ] - ) - var sawExpectedPath = false - var consecutiveStableObservations = 0 - for _ in 0 ..< 100 { - let scanResponse = try await client.sendRequest(scanRequest) - if MobileIrohReleaseGateResponseValidator.artifactPath( - scanResponse, - expectedPath: path - ) { - sawExpectedPath = true - let statResponse = try await client.sendRequest(statRequest) - if MobileIrohReleaseGateResponseValidator.artifactStat( - statResponse, - expectedSize: expectedSize - ) { - consecutiveStableObservations += 1 - if consecutiveStableObservations - >= MobileIrohReleaseGateArtifactPreparation.requiredStableStatObservations { - return .ready - } - } else { - consecutiveStableObservations = 0 - } - } else { - consecutiveStableObservations = 0 - } - try await Task.sleep(for: .milliseconds(100)) - } - return sawExpectedPath ? .statSizeMismatch : .scanPathMissing - } - - private func cleanUpRelayRolloverPreparation( - client: MobileCoreRPCClient, - streamID: String, - artifactPath: String, - surfaceID: String - ) async { - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - } - - private func renameWorkspaceForEvent( - workspace: MobileWorkspacePreview, - temporaryName: String - ) async throws { - guard let currentWorkspace = irohReleaseGateCurrentWorkspace( - matching: workspace - ) else { - throw MobileIrohReleaseGateProbeFailure.workspaceMutationFailed - } - let result = await renameWorkspace( - id: currentWorkspace.id, - title: temporaryName - ) - guard case .success = result, - irohReleaseGateCurrentWorkspace(matching: workspace)?.name - == temporaryName else { - throw MobileIrohReleaseGateProbeFailure.workspaceMutationFailed - } - } - - private func restoreWorkspace(_ workspace: MobileWorkspacePreview) async throws { - guard let currentWorkspace = irohReleaseGateCurrentWorkspace( - matching: workspace - ) else { - throw MobileIrohReleaseGateProbeFailure.workspaceRestorationFailed - } - let result = await renameWorkspace( - id: currentWorkspace.id, - title: workspace.name - ) - guard case .success = result, - irohReleaseGateCurrentWorkspace(matching: workspace)?.name - == workspace.name else { - throw MobileIrohReleaseGateProbeFailure.workspaceRestorationFailed - } - } - - private func restoreWorkspaceBestEffort(_ workspace: MobileWorkspacePreview) async { - _ = try? await restoreWorkspace(workspace) - } - private func verifyIndependentEvents( client: MobileCoreRPCClient, marker: String diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift deleted file mode 100644 index 61b3355747a4..000000000000 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -#if DEBUG -import Testing -@testable import CmuxMobileShellReleaseGateSupport - -struct MobileIrohReleaseGateArtifactPreparationTests { - @Test - func completionMarkerCannotAppearInTheEchoedCommand() { - let preparation = MobileIrohReleaseGateArtifactPreparation.make( - path: "/tmp/cmux-iroh-gate-test.bin", - suffixText: "CMUX_IROH_ARTIFACT_TEST", - marker: "CMUX_IROH_GATE_TEST" - ) - - #expect(preparation.completionMarker.hasPrefix("CMUX_IROH_ARTIFACT_READY_")) - #expect(!preparation.command.contains(preparation.completionMarker)) - } - - @Test - func readinessRequiresTwoStableStatObservations() { - #expect(MobileIrohReleaseGateArtifactPreparation.requiredStableStatObservations == 2) - } - - @Test - func artifactPathIsPublishedOnItsOwnLineBeforeCompletion() { - let path = "/tmp/cmux-iroh-gate-test.bin" - let preparation = MobileIrohReleaseGateArtifactPreparation.make( - path: path, - suffixText: "CMUX_IROH_ARTIFACT_TEST", - marker: "CMUX_IROH_GATE_TEST" - ) - - let pathPublication = "printf '\\n%s\\n' '\(path)'" - let completionPublication = "printf '\\n%s%s\\n'" - let pathRange = preparation.command.range(of: pathPublication) - let completionRange = preparation.command.range(of: completionPublication) - - #expect(pathRange != nil) - #expect(completionRange != nil) - if let pathRange, let completionRange { - #expect(pathRange.upperBound < completionRange.lowerBound) - } - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift index 4af0e89f37e4..b0d665d878ea 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift @@ -112,80 +112,6 @@ struct MobileIrohReleaseGateResponseValidatorTests { )) } - @Test - func artifactContinuityRequiresTheExactAuthorizedPathAndLaneDescriptor() throws { - let path = "/tmp/cmux-iroh-gate.txt" - let scan = try ChatWireCoding().encode(TerminalArtifactScanResponse(artifacts: [ - TerminalArtifactReference( - path: path, - kind: .text, - displayName: "cmux-iroh-gate.txt", - size: 12 - ), - ])) - let descriptor = ChatArtifactLaneDescriptor( - resourceID: "opaque-resource", - totalSize: 12, - expiresAt: Date(timeIntervalSince1970: 2_000_000_000) - ) - let encodedDescriptor = try ChatWireCoding().encode(descriptor) - - #expect(MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: path - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/other.txt" - )) - #expect( - MobileIrohReleaseGateResponseValidator.artifactLaneDescriptor(encodedDescriptor) - == descriptor - ) - - let stat = ChatArtifactStat( - exists: true, - isDirectory: false, - size: 12, - modifiedAt: Date(timeIntervalSince1970: 2_000_000_000), - kind: .text - ) - let encodedStat = try ChatWireCoding().encode(stat) - #expect(MobileIrohReleaseGateResponseValidator.artifactStat( - encodedStat, - expectedSize: 12 - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactStat( - encodedStat, - expectedSize: 13 - )) - } - - @Test - func artifactContinuityAcceptsTheCanonicalMacOSTemporaryDirectoryAlias() throws { - let scan = try ChatWireCoding().encode(TerminalArtifactScanResponse(artifacts: [ - TerminalArtifactReference( - path: "/private/tmp/cmux-iroh-gate-test.bin", - kind: .binary, - displayName: "cmux-iroh-gate-test.bin", - size: 12 - ), - ])) - - #expect(MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/cmux-iroh-gate-test.bin" - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/cmux-iroh-gate-other.bin" - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/var/tmp/cmux-iroh-gate-test.bin" - )) - } - @Test func notificationReconcileRejectsNegativeUnreadCount() throws { let valid = try JSONSerialization.data(withJSONObject: [ diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift index d8e1d46089a4..b14622aa595c 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift @@ -49,29 +49,10 @@ extension MobileHostIrohRuntime { && derivedEndpointID == $0.endpointID && $0.identityGeneration == identity.generation } ?? false - let cachedManagedRelayURLs: Set - if let relayPolicyTrustRoot, - let cachedPolicy = try? await relayPolicyCache.load( - trustRoot: relayPolicyTrustRoot, - now: Date() - ) { - cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) - } else { - cachedManagedRelayURLs = [] - } - let cachedRelay: CmxIrohRelayTokenResponse? if let cachedBinding, bindingMatches { lastKnownBindingID = cachedBinding.bindingID lastKnownAccountID = accountID lastKnownTag = tag - cachedRelay = try await brokerCredentials.loadRelayCredential( - accountID: accountID, - binding: cachedBinding, - expectedRelayFleet: cachedManagedRelayURLs, - now: Date() - ) - } else { - cachedRelay = nil } let policyExpectation = try CmxIrohHostPolicyExpectation( accountID: accountID, @@ -176,7 +157,6 @@ extension MobileHostIrohRuntime { let managedRelayURLs: Set let resolvedPolicyService: CmxIrohRelayPolicyService? let resolvedEffectivePolicy: CmxIrohEffectiveRelayPolicy? - var freshRelayCredential: CmxIrohRelayTokenResponse? var relayPolicyNeedsImmediateRefresh = false if let relayPolicyTrustRoot { let service = CmxIrohRelayPolicyService( @@ -193,24 +173,19 @@ extension MobileHostIrohRuntime { effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: Date() ) relayPolicyNeedsImmediateRefresh = true } else { // Relay-only verification cannot become active without the - // current signed fleet and credential, so keep its explicit - // readiness barrier. + // current signed fleet, so keep its explicit readiness barrier. diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { - let outcome = try await service.refreshWithCredential( - endpointID: derivedEndpointID, + effective = try await service.refresh( accountID: accountID, trustRoot: relayPolicyTrustRoot, now: Date() ) - effective = outcome.effective - freshRelayCredential = outcome.relayCredential diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) } catch { diagnosticLog.record(DiagnosticEvent( @@ -220,7 +195,6 @@ extension MobileHostIrohRuntime { effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: Date() ) relayPolicyNeedsImmediateRefresh = true @@ -246,12 +220,6 @@ extension MobileHostIrohRuntime { resolvedPolicyService = nil resolvedEffectivePolicy = nil } - let compatibleCachedRelay = cachedRelay.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } - let freshCompatibleRelay = freshRelayCredential.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } let configuration = CmxIrohHostRuntimeConfiguration( accountID: accountID, deviceID: deviceID, @@ -273,13 +241,11 @@ extension MobileHostIrohRuntime { ), managedRelayURLs: managedRelayURLs, endpointRelayProfile: endpointRelayProfile, - cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, cachedHostPolicy: cachedHostPolicy ) let credentialRepository = brokerCredentials let hostPolicyCache = hostPolicies let lanPublisher = lanPublisher - let activeRelayPolicyService = resolvedPolicyService let hostRuntime = CmxIrohHostRuntime( factory: CmxIrohLibEndpointFactory( transportVerificationMode: transportVerificationMode @@ -458,21 +424,6 @@ extension MobileHostIrohRuntime { } ) }, - handleRelayCredential: { [weak self] response, binding in - guard await self?.allowsPersistence( - accountID: accountID, - revision: revision - ) == true else { return } - let expectedRelayFleet = await activeRelayPolicyService?.managedPolicy() - .map { Set($0.relays.map(\.url)) } ?? managedRelayURLs - try? await credentialRepository.saveRelayCredential( - response, - accountID: accountID, - binding: binding, - expectedRelayFleet: expectedRelayFleet, - now: Date() - ) - }, handleLANRefresh: { guard MobileHostService.isListeningEnabled else { await lanPublisher.stop() diff --git a/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift b/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift index 532cbff1f8c3..9a4a4ff20e0e 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift @@ -198,7 +198,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: Date() @@ -348,7 +347,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { self.diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await service.refresh( - endpointID: endpointID, accountID: accountID, trustRoot: trustRoot, now: Date() @@ -460,7 +458,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { ) async { do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: Date() diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift index 59f0be30af25..e1c6d67f333b 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift @@ -17,16 +17,12 @@ struct MobileIrohReleaseGateHostView: View { configuration: MobileIrohReleaseGateRunner.Configuration, onboardingStore: MobileOnboardingStore, signOutHook: MobileSignOutHook, - settingsController: any CmxIrohSettingsControlling, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? + settingsController: any CmxIrohSettingsControlling ) { _store = State(initialValue: store) _runner = State(initialValue: MobileIrohReleaseGateRunner( configuration: configuration, - settingsController: settingsController, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry + settingsController: settingsController )) self.onboardingStore = onboardingStore self.signOutHook = signOutHook diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift index 134368206d08..6f6554025018 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift @@ -15,20 +15,16 @@ private let mobileIrohReleaseGateLog = Logger( @MainActor final class MobileIrohReleaseGateRunner { - private static let relayRolloverSoakDurationSeconds = 330 private static let requiredReadyObservations = 2 private static let readinessSettlingDuration: Duration = .milliseconds(500) private static let standardTimeout: Duration = .seconds(90) - private static let extendedTimeout: Duration = .seconds(420) struct Configuration: Equatable, Sendable { static let modeEnvironmentKey = "CMUX_IROH_RELEASE_GATE_MODE" - static let scenarioEnvironmentKey = "CMUX_IROH_RELEASE_GATE_SCENARIO" static let reportFilename = "cmux-iroh-release-gate.json" static let reportReadyNotification = "dev.cmux.ios.iroh-release-gate.report-ready" let mode: CmxIrohTransportVerificationMode - let scenario: MobileIrohReleaseGateScenario let reportURL: URL init?( @@ -40,18 +36,7 @@ final class MobileIrohReleaseGateRunner { let cachesDirectory else { return nil } - let scenario: MobileIrohReleaseGateScenario - if let rawScenario = environment[Self.scenarioEnvironmentKey] { - guard let parsed = MobileIrohReleaseGateScenario(rawValue: rawScenario) else { - return nil - } - scenario = parsed - } else { - scenario = .standard - } - guard scenario == .standard || mode == .relayOnly else { return nil } self.mode = mode - self.scenario = scenario self.reportURL = cachesDirectory.appendingPathComponent(Self.reportFilename) } @@ -72,7 +57,6 @@ final class MobileIrohReleaseGateRunner { struct Report: Codable, Equatable, Sendable { let schemaVersion: Int let mode: String - let scenario: String let passed: Bool let hostStatusVerified: Bool let rpcMethodInventoryVerified: Bool @@ -82,14 +66,6 @@ final class MobileIrohReleaseGateRunner { let notificationReconcileVerified: Bool let chatSessionsVerified: Bool let artifactScanCountVerified: Bool - let relayCredentialRolloverVerified: Bool - let endpointContinuityVerified: Bool - let connectionContinuityVerified: Bool - let controlStreamContinuityVerified: Bool - let independentEventsContinuityVerified: Bool - let artifactLaneVerified: Bool - let unrefreshedExpiryDisconnectVerified: Bool - let soakDurationSeconds: Int let routeKind: String? let selectedPath: String? let failure: String? @@ -178,8 +154,6 @@ final class MobileIrohReleaseGateRunner { init( configuration: Configuration, settingsController: any CmxIrohSettingsControlling, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity? = { nil }, - relayCredentialExpiry: @escaping @Sendable () async -> Date? = { nil }, fileManager: FileManager = .default ) { self.configuration = configuration @@ -187,15 +161,7 @@ final class MobileIrohReleaseGateRunner { self.dependencies = Dependencies( readinessUpdates: nil, runProbe: { store, marker in - try await store.runIrohReleaseGateProbe( - marker: marker, - scenario: configuration.scenario, - soakDurationSeconds: configuration.scenario == .relayRollover - ? Self.relayRolloverSoakDurationSeconds - : 0, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) + try await store.runIrohReleaseGateProbe(marker: marker) }, settingsUpdates: { settingsController.irohSettingsUpdates() @@ -209,9 +175,7 @@ final class MobileIrohReleaseGateRunner { postReportReady: { Self.postReportReadyNotification() }, - timeout: configuration.scenario == .standard - ? Self.standardTimeout - : Self.extendedTimeout + timeout: Self.standardTimeout ) } @@ -279,14 +243,12 @@ final class MobileIrohReleaseGateRunner { private func boundedReport(store: CMUXMobileShellStore) async -> Report { let mode = configuration.mode - let scenario = configuration.scenario let timeout = dependencies.timeout let reports = AsyncStream(bufferingPolicy: .bufferingOldest(1)) { continuation in let operationTask = Task { @MainActor [weak self] in guard let self else { continuation.yield(Self.failureReport( mode: mode, - scenario: scenario, failure: .unknownProbeFailure )) continuation.finish() @@ -306,7 +268,6 @@ final class MobileIrohReleaseGateRunner { let deadline = await self.deadlineFailure() continuation.yield(Self.failureReport( mode: mode, - scenario: scenario, failure: deadline.failure, completedProbe: self.completedProbe, lastDiagnosticEvent: deadline.lastDiagnosticEvent @@ -324,7 +285,6 @@ final class MobileIrohReleaseGateRunner { } return Self.failureReport( mode: mode, - scenario: scenario, failure: .unknownProbeFailure ) } @@ -343,7 +303,6 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } @@ -355,14 +314,12 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } guard observedReady else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .readinessUnavailable ) } @@ -374,7 +331,6 @@ final class MobileIrohReleaseGateRunner { } catch { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } @@ -384,44 +340,9 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } - var pathBeforeProbe: String? - if configuration.scenario != .standard { - for await snapshot in dependencies.settingsUpdates() { - guard !Task.isCancelled else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .timeout - ) - } - if let accepted = Self.acceptedPath( - snapshot.selectedTransportPath, - mode: configuration.mode - ) { - pathBeforeProbe = accepted - break - } - } - guard !Task.isCancelled else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .timeout - ) - } - guard pathBeforeProbe != nil else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .pathPolicyMismatch - ) - } - } - let marker = "CMUX_IROH_GATE_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" let probe: MobileIrohReleaseGateProbeResult do { @@ -429,34 +350,22 @@ final class MobileIrohReleaseGateRunner { } catch let failure as MobileIrohReleaseGateProbeFailure { return Self.probeFailureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: failure, - selectedPath: pathBeforeProbe + selectedPath: nil ) } catch { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .unknownProbeFailure ) } completedProbe = probe - if let pathBeforeProbe { - return Self.completedReport( - mode: configuration.mode, - scenario: configuration.scenario, - probe: probe, - selectedPath: pathBeforeProbe - ) - } - let snapshots = dependencies.settingsUpdates() for await snapshot in snapshots { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout, completedProbe: probe ) @@ -472,7 +381,6 @@ final class MobileIrohReleaseGateRunner { observationID = nil return Self.completedReport( mode: configuration.mode, - scenario: configuration.scenario, probe: probe, selectedPath: selectedPath ) @@ -480,7 +388,6 @@ final class MobileIrohReleaseGateRunner { } return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .pathPolicyMismatch, completedProbe: probe ) @@ -606,36 +513,14 @@ final class MobileIrohReleaseGateRunner { } } - private static func scenarioPassed( - _ scenario: MobileIrohReleaseGateScenario, - probe: MobileIrohReleaseGateProbeResult - ) -> Bool { - switch scenario { - case .standard: - return true - case .relayRollover: - return probe.relayCredentialRolloverVerified - && probe.endpointContinuityVerified - && probe.connectionContinuityVerified - && probe.controlStreamContinuityVerified - && probe.independentEventsContinuityVerified - && probe.artifactLaneVerified - && probe.soakDurationSeconds >= relayRolloverSoakDurationSeconds - case .relayExpiry: - return probe.unrefreshedExpiryDisconnectVerified - } - } - private static func completedReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, probe: MobileIrohReleaseGateProbeResult, selectedPath: String ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: probe.hostStatusVerified && probe.rpcMethodInventoryVerified && probe.terminalRoundTripVerified @@ -643,8 +528,7 @@ final class MobileIrohReleaseGateRunner { && probe.independentEventsVerified && probe.notificationReconcileVerified && probe.chatSessionsVerified - && probe.artifactScanCountVerified - && scenarioPassed(scenario, probe: probe), + && probe.artifactScanCountVerified, hostStatusVerified: probe.hostStatusVerified, rpcMethodInventoryVerified: probe.rpcMethodInventoryVerified, terminalRoundTripVerified: probe.terminalRoundTripVerified, @@ -653,14 +537,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: probe.notificationReconcileVerified, chatSessionsVerified: probe.chatSessionsVerified, artifactScanCountVerified: probe.artifactScanCountVerified, - relayCredentialRolloverVerified: probe.relayCredentialRolloverVerified, - endpointContinuityVerified: probe.endpointContinuityVerified, - connectionContinuityVerified: probe.connectionContinuityVerified, - controlStreamContinuityVerified: probe.controlStreamContinuityVerified, - independentEventsContinuityVerified: probe.independentEventsContinuityVerified, - artifactLaneVerified: probe.artifactLaneVerified, - unrefreshedExpiryDisconnectVerified: probe.unrefreshedExpiryDisconnectVerified, - soakDurationSeconds: probe.soakDurationSeconds, routeKind: CmxAttachTransportKind.iroh.rawValue, selectedPath: selectedPath, failure: nil, @@ -690,14 +566,12 @@ final class MobileIrohReleaseGateRunner { private static func probeFailureReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, failure: MobileIrohReleaseGateProbeFailure, selectedPath: String? ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: false, hostStatusVerified: false, rpcMethodInventoryVerified: false, @@ -707,14 +581,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: false, chatSessionsVerified: false, artifactScanCountVerified: false, - relayCredentialRolloverVerified: false, - endpointContinuityVerified: false, - connectionContinuityVerified: false, - controlStreamContinuityVerified: false, - independentEventsContinuityVerified: false, - artifactLaneVerified: false, - unrefreshedExpiryDisconnectVerified: false, - soakDurationSeconds: 0, routeKind: CmxAttachTransportKind.iroh.rawValue, selectedPath: selectedPath, failure: failure.rawValue, @@ -725,15 +591,13 @@ final class MobileIrohReleaseGateRunner { private nonisolated static func failureReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, failure: Failure, completedProbe: MobileIrohReleaseGateProbeResult? = nil, lastDiagnosticEvent: DiagnosticEvent? = nil ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: false, hostStatusVerified: completedProbe?.hostStatusVerified ?? false, rpcMethodInventoryVerified: completedProbe?.rpcMethodInventoryVerified ?? false, @@ -743,14 +607,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: completedProbe?.notificationReconcileVerified ?? false, chatSessionsVerified: completedProbe?.chatSessionsVerified ?? false, artifactScanCountVerified: completedProbe?.artifactScanCountVerified ?? false, - relayCredentialRolloverVerified: completedProbe?.relayCredentialRolloverVerified ?? false, - endpointContinuityVerified: completedProbe?.endpointContinuityVerified ?? false, - connectionContinuityVerified: completedProbe?.connectionContinuityVerified ?? false, - controlStreamContinuityVerified: completedProbe?.controlStreamContinuityVerified ?? false, - independentEventsContinuityVerified: completedProbe?.independentEventsContinuityVerified ?? false, - artifactLaneVerified: completedProbe?.artifactLaneVerified ?? false, - unrefreshedExpiryDisconnectVerified: completedProbe?.unrefreshedExpiryDisconnectVerified ?? false, - soakDurationSeconds: completedProbe?.soakDurationSeconds ?? 0, routeKind: completedProbe == nil ? nil : CmxAttachTransportKind.iroh.rawValue, selectedPath: nil, failure: failure.rawValue, diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift index 54efb4c30d37..447362d7d43c 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift @@ -28,11 +28,7 @@ public struct MobileIrohReleaseGateScene: View { configuration: configuration, onboardingStore: root.onboardingStore, signOutHook: root.signOutHook, - settingsController: iroh, - endpointIdentity: { await iroh.releaseGateEndpointIdentity() }, - relayCredentialExpiry: { - await iroh.releaseGateRelayCredentialExpiry() - } + settingsController: iroh ) ) } else { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift index b2e1293c6bf6..b3efc8b8bb8d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift @@ -8,10 +8,5 @@ public extension MobileIrohRuntimeComposition { func releaseGateEndpointIdentity() async -> CmxIrohPeerIdentity? { await runtime?.snapshot().endpointID } - - /// Supplies the non-secret installed relay expiry to the release gate. - func releaseGateRelayCredentialExpiry() async -> Date? { - await runtime?.relayCredentialExpiresAt() - } } #endif diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index 723a3a35e4fe..7d74893dc2f9 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -1767,35 +1767,15 @@ public final class MobileIrohRuntimeComposition: && $0.endpointID == endpointID && $0.identityGeneration == identity.generation } ?? false - let cachedManagedRelayURLs: Set - if let relayPolicyTrustRoot, - let cachedPolicy = try? await relayPolicyCache.load( - trustRoot: relayPolicyTrustRoot, - now: now() - ) { - cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) - } else { - cachedManagedRelayURLs = [] - } - let cachedRelay: CmxIrohRelayTokenResponse? if let cachedBinding, bindingMatches { lastKnownBindingID = cachedBinding.bindingID lastKnownBindingAccountID = accountID lastKnownBindingTag = tag - cachedRelay = try await brokerCredentials.loadRelayCredential( + } else if cachedBinding != nil { + try? await brokerCredentials.deleteBinding( accountID: accountID, - binding: cachedBinding, - expectedRelayFleet: cachedManagedRelayURLs, - now: now() + appInstanceID: appInstanceID ) - } else { - if cachedBinding != nil { - try? await brokerCredentials.deleteBinding( - accountID: accountID, - appInstanceID: appInstanceID - ) - } - cachedRelay = nil } // Pin the activation's broker to the session identity that owns @@ -1839,7 +1819,6 @@ public final class MobileIrohRuntimeComposition: let managedRelayURLs: Set let resolvedPolicyService: CmxIrohRelayPolicyService? let resolvedEffectivePolicy: CmxIrohEffectiveRelayPolicy? - var freshRelayCredential: CmxIrohRelayTokenResponse? var relayPolicyNeedsImmediateRefresh = false if let relayPolicyTrustRoot { let service = CmxIrohRelayPolicyService( @@ -1857,21 +1836,17 @@ public final class MobileIrohRuntimeComposition: effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: now() ) relayPolicyNeedsImmediateRefresh = true } else { diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { - let outcome = try await service.refreshWithCredential( - endpointID: endpointID, + effective = try await service.refresh( accountID: accountID, trustRoot: relayPolicyTrustRoot, now: now() ) - effective = outcome.effective - freshRelayCredential = outcome.relayCredential diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) } catch { diagnosticLog?.record(DiagnosticEvent( @@ -1881,7 +1856,6 @@ public final class MobileIrohRuntimeComposition: effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: now() ) relayPolicyNeedsImmediateRefresh = true @@ -1907,12 +1881,6 @@ public final class MobileIrohRuntimeComposition: resolvedPolicyService = nil resolvedEffectivePolicy = nil } - let compatibleCachedRelay = cachedRelay.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } - let freshCompatibleRelay = freshRelayCredential.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } let configuration = CmxIrohClientRuntimeConfiguration( accountID: accountID, deviceID: deviceID, @@ -1924,14 +1892,12 @@ public final class MobileIrohRuntimeComposition: capabilities: Self.capabilities, managedRelayURLs: managedRelayURLs, endpointRelayProfile: endpointRelayProfile, - cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, cachedBinding: bindingMatches ? cachedBinding : nil ) let credentialRepository = brokerCredentials let routeCatalog = routeCatalog let lanPeerDiscovery = lanPeerDiscovery let clock = now - let activeRelayPolicyService = resolvedPolicyService let transportVerificationMode = transportVerificationMode let customPrivatePaths = customPrivatePaths let networkPathSnapshotComposer = networkPathSnapshotComposer @@ -2006,7 +1972,6 @@ public final class MobileIrohRuntimeComposition: accountID: accountID ) }, - automaticRelayCredentialRefreshEnabled: automaticRelayCredentialRefreshEnabled, handleBinding: { [weak self] binding, discovery in guard await self?.allowsPersistence( accountID: accountID, @@ -2042,21 +2007,6 @@ public final class MobileIrohRuntimeComposition: ) == true else { return } await routeCatalog.replaceCachedBindings(bindings, scope: revision) }, - handleRelayCredential: { [weak self] response, binding in - guard await self?.allowsPersistence( - accountID: accountID, - revision: revision - ) == true else { return } - let expectedRelayFleet = await activeRelayPolicyService?.managedPolicy() - .map { Set($0.relays.map(\.url)) } ?? managedRelayURLs - try? await credentialRepository.saveRelayCredential( - response, - accountID: accountID, - binding: CmxIrohBrokerBindingMetadata(binding: binding), - expectedRelayFleet: expectedRelayFleet, - now: clock() - ) - }, handleLocalDeactivation: { [appInstances, identities, brokerCredentials] in await routeCatalog.deactivate(scope: revision) await lanPeerDiscovery?.stop() @@ -2755,7 +2705,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: now() @@ -2920,7 +2869,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { self.diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await service.refresh( - endpointID: endpointID, accountID: accountID, trustRoot: trustRoot, now: self.now() @@ -3038,7 +2986,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { ) async { do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: now() diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift index a296aa657f02..2bc359322b5b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift @@ -207,18 +207,15 @@ struct MobileIrohReleaseGateRunnerTests { } @Test - func probeFailurePreservesTheVerifiedIrohRouteAndPath() async throws { - let configuration = try temporaryConfiguration( - mode: .relayOnly, - scenario: .relayRollover - ) + func probeFailureReportsTheBoundedFailureCase() async throws { + let configuration = try temporaryConfiguration(mode: .relayOnly) var capturedReport: MobileIrohReleaseGateRunner.Report? let runner = MobileIrohReleaseGateRunner( configuration: configuration, dependencies: .init( readinessUpdates: { _ in Self.readyReadinessUpdates() }, runProbe: { _, _ in - throw MobileIrohReleaseGateProbeFailure.artifactCommandNotCompleted + throw MobileIrohReleaseGateProbeFailure.terminalRoundTripFailed }, settingsUpdates: { Self.managedRelaySettingsUpdates() }, writeReport: { report, url in @@ -235,8 +232,8 @@ struct MobileIrohReleaseGateRunnerTests { let report = try #require(capturedReport) #expect(report.passed == false) #expect(report.routeKind == CmxAttachTransportKind.iroh.rawValue) - #expect(report.selectedPath == "managed_relay") - #expect(report.failure == MobileIrohReleaseGateProbeFailure.artifactCommandNotCompleted.rawValue) + #expect(report.selectedPath == nil) + #expect(report.failure == MobileIrohReleaseGateProbeFailure.terminalRoundTripFailed.rawValue) } @Test @@ -265,35 +262,6 @@ struct MobileIrohReleaseGateRunnerTests { #expect(report.failure == "timeout") } - @Test - func rolloverScenarioRequiresEveryContinuityProof() async throws { - let incomplete = try await runScenario( - .relayRollover, - probe: Self.successfulProbe - ) - #expect(incomplete.passed == false) - - let complete = try await runScenario( - .relayRollover, - probe: Self.successfulRolloverProbe - ) - #expect(complete.passed) - #expect(complete.scenario == "relay_rollover") - #expect(complete.soakDurationSeconds == 330) - } - - @Test - func expiryScenarioAcceptsOnlyTheExpectedDisconnectProof() async throws { - let report = try await runScenario( - .relayExpiry, - probe: Self.successfulExpiryProbe - ) - - #expect(report.passed) - #expect(report.scenario == "relay_expiry") - #expect(report.unrefreshedExpiryDisconnectVerified) - } - @Test func configurationRequiresAnExplicitSupportedMode() throws { let cache = URL(fileURLWithPath: "/tmp/iroh-gate-tests", isDirectory: true) @@ -312,24 +280,7 @@ struct MobileIrohReleaseGateRunnerTests { cachesDirectory: cache )) #expect(configuration.mode == .relayOnly) - #expect(configuration.scenario == .standard) #expect(configuration.reportURL.lastPathComponent == "cmux-iroh-release-gate.json") - - let rollover = try #require(MobileIrohReleaseGateRunner.Configuration( - environment: [ - "CMUX_IROH_RELEASE_GATE_MODE": "relayOnly", - "CMUX_IROH_RELEASE_GATE_SCENARIO": "relay_rollover", - ], - cachesDirectory: cache - )) - #expect(rollover.scenario == .relayRollover) - #expect(MobileIrohReleaseGateRunner.Configuration( - environment: [ - "CMUX_IROH_RELEASE_GATE_MODE": "automatic", - "CMUX_IROH_RELEASE_GATE_SCENARIO": "relay_expiry", - ], - cachesDirectory: cache - ) == nil) } @Test(arguments: [ @@ -371,9 +322,8 @@ struct MobileIrohReleaseGateRunnerTests { @Test func encodedReportContainsNoTopologyOrIdentityFields() throws { let report = MobileIrohReleaseGateRunner.Report( - schemaVersion: 4, + schemaVersion: 5, mode: "relayOnly", - scenario: "relay_rollover", passed: true, hostStatusVerified: true, rpcMethodInventoryVerified: true, @@ -383,14 +333,6 @@ struct MobileIrohReleaseGateRunnerTests { notificationReconcileVerified: true, chatSessionsVerified: true, artifactScanCountVerified: true, - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true, - unrefreshedExpiryDisconnectVerified: false, - soakDurationSeconds: 330, routeKind: "iroh", selectedPath: "managed_relay", failure: nil, @@ -403,7 +345,6 @@ struct MobileIrohReleaseGateRunnerTests { #expect(Set(object.keys) == [ "schemaVersion", "mode", - "scenario", "passed", "hostStatusVerified", "rpcMethodInventoryVerified", @@ -413,14 +354,6 @@ struct MobileIrohReleaseGateRunnerTests { "notificationReconcileVerified", "chatSessionsVerified", "artifactScanCountVerified", - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - "unrefreshedExpiryDisconnectVerified", - "soakDurationSeconds", "routeKind", "selectedPath", "lastDiagnosticEventCode", @@ -491,39 +424,8 @@ struct MobileIrohReleaseGateRunnerTests { artifactScanCountVerified: true ) - private static let successfulRolloverProbe = MobileIrohReleaseGateProbeResult( - hostStatusVerified: true, - rpcMethodInventoryVerified: true, - terminalRoundTripVerified: true, - workspaceMutationVerified: true, - independentEventsVerified: true, - notificationReconcileVerified: true, - chatSessionsVerified: true, - artifactScanCountVerified: true, - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true, - soakDurationSeconds: 330 - ) - - private static let successfulExpiryProbe = MobileIrohReleaseGateProbeResult( - hostStatusVerified: true, - rpcMethodInventoryVerified: true, - terminalRoundTripVerified: true, - workspaceMutationVerified: true, - independentEventsVerified: true, - notificationReconcileVerified: true, - chatSessionsVerified: true, - artifactScanCountVerified: true, - unrefreshedExpiryDisconnectVerified: true - ) - private func temporaryConfiguration( - mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario = .standard + mode: CmxIrohTransportVerificationMode ) throws -> MobileIrohReleaseGateRunner.Configuration { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -534,40 +436,11 @@ struct MobileIrohReleaseGateRunnerTests { return try #require(MobileIrohReleaseGateRunner.Configuration( environment: [ MobileIrohReleaseGateRunner.Configuration.modeEnvironmentKey: mode.rawValue, - MobileIrohReleaseGateRunner.Configuration.scenarioEnvironmentKey: scenario.rawValue, ], cachesDirectory: directory )) } - private func runScenario( - _ scenario: MobileIrohReleaseGateScenario, - probe: MobileIrohReleaseGateProbeResult - ) async throws -> MobileIrohReleaseGateRunner.Report { - let configuration = try temporaryConfiguration( - mode: .relayOnly, - scenario: scenario - ) - var capturedReport: MobileIrohReleaseGateRunner.Report? - let runner = MobileIrohReleaseGateRunner( - configuration: configuration, - dependencies: .init( - readinessUpdates: { _ in Self.readyReadinessUpdates() }, - runProbe: { _, _ in probe }, - settingsUpdates: { Self.managedRelaySettingsUpdates() }, - writeReport: { report, url in - capturedReport = report - try Self.write(report: report, to: url) - }, - postReportReady: {}, - timeout: .seconds(1) - ) - ) - - await runner.run(store: CMUXMobileShellStore.preview()) - return try #require(capturedReport) - } - private func runLatePathFailure( settingsUpdates: AsyncStream, timeout: Duration diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift index 3918442c0bb7..483993d8c6ea 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift @@ -274,15 +274,16 @@ struct MobileIrohRuntimeCompositionCooldownTests { #expect(await fixture.broker.totalRequestCount() == settled + 1) } + /// Activation resolves the signed policy once and never mints a relay + /// credential: the connect path is tokenless (relay allow hook). @Test - func freshRelayBootstrapCredentialAvoidsSecondMint() async throws { + func relayPolicyBootstrapMintsNothing() async throws { let fixture = try await MobileIrohCooldownFixture.makeSuccessfulBootstrap() await fixture.broker.waitForBootstrapRequest() #expect(fixture.composition.runtime != nil) #expect(await fixture.broker.bootstrapRequestCount() >= 1) - #expect(await fixture.broker.relayTokenRequestCount() == 0) } @Test @@ -754,25 +755,11 @@ private struct MobileIrohCooldownRelayPolicyFixture { ]) } - func bootstrap() throws -> CmxIrohRelayBootstrapResponse { - CmxIrohRelayBootstrapResponse( - relayToken: relayCredential(), - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: signedPolicy(), - preference: .automatic, - preferenceRevision: 1 - ) - ) - } - - func relayCredential() -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return CmxIrohRelayTokenResponse( - token: "aaaa", - expiresAt: formatter.string(from: now.addingTimeInterval(3_600)), - refreshAfter: formatter.string(from: now.addingTimeInterval(1_800)), - relayFleet: relayURLs + func bootstrap() throws -> CmxIrohRelayPolicyResponse { + try CmxIrohRelayPolicyResponse( + policy: signedPolicy(), + preference: .automatic, + preferenceRevision: 1 ) } @@ -829,12 +816,11 @@ private actor MobileIrohCooldownBroker: private let discoveryError: (any Error)? private let registration: CmxIrohRegistrationResponse private let discoveryResponse: CmxIrohDiscoveryResponse - private let bootstrap: CmxIrohRelayBootstrapResponse? + private let bootstrap: CmxIrohRelayPolicyResponse? private var relayBootstrapRetryAfterSeconds: Int? private var totalRequests = 0 private var discoveryRequests = 0 private var bootstrapRequests = 0 - private var relayTokenRequests = 0 private var suspendRelayBootstrap: Bool private var relayBootstrapContinuation: CheckedContinuation? private var bootstrapRequestWaiters: [CheckedContinuation] = [] @@ -844,7 +830,7 @@ private actor MobileIrohCooldownBroker: discoveryError: (any Error)?, registration: CmxIrohRegistrationResponse, discovery: CmxIrohDiscoveryResponse, - bootstrap: CmxIrohRelayBootstrapResponse?, + bootstrap: CmxIrohRelayPolicyResponse?, suspendRelayBootstrap: Bool ) { self.registrationError = registrationError @@ -883,25 +869,11 @@ private actor MobileIrohCooldownBroker: throw MobileIrohCooldownTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - totalRequests += 1 - relayTokenRequests += 1 - guard let credential = bootstrap?.relayToken else { - throw MobileIrohCooldownTestError.unavailable - } - return credential - } - func revoke(bindingID _: String) { totalRequests += 1 } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { totalRequests += 1 bootstrapRequests += 1 let waiters = bootstrapRequestWaiters @@ -943,7 +915,6 @@ private actor MobileIrohCooldownBroker: func totalRequestCount() -> Int { totalRequests } func discoveryRequestCount() -> Int { discoveryRequests } func bootstrapRequestCount() -> Int { bootstrapRequests } - func relayTokenRequestCount() -> Int { relayTokenRequests } func waitForBootstrapRequest() async { guard bootstrapRequests == 0 else { return } @@ -988,7 +959,6 @@ private actor MobileIrohCooldownEndpoint: CmxIrohEndpoint { } func accept() -> (any CmxIrohConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index 21faf770b166..164af9185a9b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -1800,13 +1800,6 @@ private actor MobileIrohRevocationBroker: CmxIrohClientBrokerServing { throw MobileIrohSignOutTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - throw MobileIrohSignOutTestError.unavailable - } - func revoke(bindingID: String) { bindingIDs.append(bindingID) } @@ -1866,13 +1859,6 @@ private actor MobileIrohCredentialFetchingBroker: CmxIrohClientBrokerServing { throw MobileIrohSignOutTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - throw MobileIrohSignOutTestError.unavailable - } - func revoke(bindingID: String) async throws { try await fetchCredentialPair() revoked.append(bindingID) diff --git a/scripts/mobile-dev-launch.sh b/scripts/mobile-dev-launch.sh index 5b8105d8ab5c..5dcfd0fbec31 100755 --- a/scripts/mobile-dev-launch.sh +++ b/scripts/mobile-dev-launch.sh @@ -392,7 +392,6 @@ if [[ "$TARGET" == "simulator" ]]; then SIMCTL_CHILD_CMUX_DOGFOOD_ATTACH_URL="$ATTACH_URL" \ SIMCTL_CHILD_CMUX_DOGFOOD_CLIENT_ID="$DOGFOOD_CLIENT_ID" \ SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_MODE="$IROH_RELEASE_GATE_MODE" \ - SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_SCENARIO="${CMUX_IROH_RELEASE_GATE_SCENARIO:-standard}" \ SIMCTL_CHILD_CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH="${CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH:-0}" \ xcrun simctl "${launch_args[@]}" "$SIM_UDID" "$BUNDLE_ID" else diff --git a/scripts/run-iroh-release-gate.sh b/scripts/run-iroh-release-gate.sh index e9c8e4c0f55a..f1cc3fdba0ee 100755 --- a/scripts/run-iroh-release-gate.sh +++ b/scripts/run-iroh-release-gate.sh @@ -3,13 +3,13 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: scripts/run-iroh-release-gate.sh --mode --tag +Usage: scripts/run-iroh-release-gate.sh --mode --tag [--staging-base-url ] [--presence-base-url ] [--skip-build] [--keep-simulator] [--report-output ] [--print-plan] [--production [--stack-env-file ]] -Automatic, relay-only, and relay-expiry build a tagged Mac app plus an isolated iOS Simulator +Automatic and relay-only build a tagged Mac app plus an isolated iOS Simulator app, sign both into the same staging account, pair only over Iroh, and verify the app RPC surface. Direct-only runs a deterministic two-Iroh-endpoint proof inside an isolated iOS Simulator with relays disabled. Private-path runs a @@ -78,11 +78,10 @@ if [[ "$PRODUCTION" -eq 1 ]]; then fi case "$MODE" in - automatic) RAW_MODE="automatic"; GATE_SCENARIO="standard"; GATE_PLAN="app-rpc" ;; - relay-only) RAW_MODE="relayOnly"; GATE_SCENARIO="relay_rollover"; GATE_PLAN="app-rpc" ;; - relay-expiry) RAW_MODE="relayOnly"; GATE_SCENARIO="relay_expiry"; GATE_PLAN="app-rpc" ;; - direct-only) RAW_MODE="directOnly"; GATE_SCENARIO="standard"; GATE_PLAN="simulator-direct-transport" ;; - private-path) RAW_MODE=""; GATE_SCENARIO="standard"; GATE_PLAN="host-private-path-transport" ;; + automatic) RAW_MODE="automatic"; GATE_PLAN="app-rpc" ;; + relay-only) RAW_MODE="relayOnly"; GATE_PLAN="app-rpc" ;; + direct-only) RAW_MODE="directOnly"; GATE_PLAN="simulator-direct-transport" ;; + private-path) RAW_MODE=""; GATE_PLAN="host-private-path-transport" ;; *) echo "error: invalid mode '$MODE'" >&2; exit 2 ;; esac @@ -663,8 +662,6 @@ if [[ "$PRODUCTION" -eq 1 ]]; then fi CMUX_ATTACH_MINT_MAX_ATTEMPTS=600 \ CMUX_ATTACH_READY_TIMEOUT_SECONDS="${CMUX_IROH_RELEASE_GATE_ATTACH_READY_TIMEOUT_SECONDS:-90}" \ -CMUX_IROH_RELEASE_GATE_SCENARIO="$GATE_SCENARIO" \ -CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH="$([[ "$GATE_SCENARIO" == "relay_expiry" ]] && printf 1 || printf 0)" \ ./scripts/mobile-dev-launch.sh "${MOBILE_LAUNCH_ARGS[@]}" \ 2>&1 | sed -E \ -e 's/^(==> dev sign-in account:).*/\1 [redacted]/' \ @@ -699,7 +696,7 @@ if [[ -n "$REPORT_OUTPUT" ]]; then fi fi -REPORT_PATH="$REPORT_PATH" EXPECTED_MODE="$RAW_MODE" EXPECTED_SCENARIO="$GATE_SCENARIO" /usr/bin/python3 <<'PY' +REPORT_PATH="$REPORT_PATH" EXPECTED_MODE="$RAW_MODE" /usr/bin/python3 <<'PY' import json import os @@ -707,11 +704,9 @@ with open(os.environ["REPORT_PATH"], encoding="utf-8") as handle: report = json.load(handle) expected_mode = os.environ["EXPECTED_MODE"] -expected_scenario = os.environ["EXPECTED_SCENARIO"] allowed_keys = { "schemaVersion", "mode", - "scenario", "passed", "hostStatusVerified", "rpcMethodInventoryVerified", @@ -721,14 +716,6 @@ allowed_keys = { "notificationReconcileVerified", "chatSessionsVerified", "artifactScanCountVerified", - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - "unrefreshedExpiryDisconnectVerified", - "soakDurationSeconds", "routeKind", "selectedPath", "failure", @@ -755,12 +742,10 @@ problems = [] unexpected_keys = set(report) - allowed_keys if unexpected_keys: problems.append("report contained unexpected fields") -if report.get("schemaVersion") != 4: +if report.get("schemaVersion") != 5: problems.append("unexpected schemaVersion") if report.get("mode") != expected_mode: problems.append("mode mismatch") -if report.get("scenario") != expected_scenario: - problems.append("scenario mismatch") if report.get("routeKind") != "iroh": problems.append("route was not Iroh") if report.get("selectedPath") not in allowed_paths[expected_mode]: @@ -768,22 +753,6 @@ if report.get("selectedPath") not in allowed_paths[expected_mode]: for key in required_true: if report.get(key) is not True: problems.append(f"{key} was not true") -if expected_scenario == "relay_rollover": - for key in ( - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - ): - if report.get(key) is not True: - problems.append(f"{key} was not true") - if report.get("soakDurationSeconds", 0) < 330: - problems.append("rollover soak was shorter than 330 seconds") -elif expected_scenario == "relay_expiry": - if report.get("unrefreshedExpiryDisconnectVerified") is not True: - problems.append("unrefreshedExpiryDisconnectVerified was not true") redacted_report = {key: report.get(key) for key in sorted(allowed_keys) if key in report} print(json.dumps(redacted_report, sort_keys=True)) diff --git a/web/.env.example b/web/.env.example index 7c05e7f8ba4e..8480b50e457b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -6,10 +6,10 @@ CMUX_CLIENT_CONFIG_RATE_LIMIT_ID= # Deployed requests fail closed when this and CMUX_FEEDBACK_RATE_LIMIT_ID are empty. CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID= -# Iroh relay access token and signed relay policy. The catalog is the complete -# managed fleet and must use an increasing sequence whenever its contents change. +# Signed Iroh relay policy. The catalog is the complete managed fleet and must +# use an increasing sequence whenever its contents change. Relay admission is +# the relay's allow hook (/api/relay/allow); no client credentials are minted. # Custom relay credentials remain device-local and are never configured here. -CMUX_RELAY_JWT_PRIVATE_KEY_PEM= CMUX_RELAY_POLICY_KEY_ID= CMUX_RELAY_POLICY_PRIVATE_KEY_PEM= CMUX_RELAY_TOKEN_RATE_LIMIT_ID= diff --git a/web/app/api/relay/policy/route.ts b/web/app/api/relay/policy/route.ts new file mode 100644 index 000000000000..fc5f9bd45d5d --- /dev/null +++ b/web/app/api/relay/policy/route.ts @@ -0,0 +1,96 @@ +// Serve the signed, server-driven Iroh relay policy for the caller's account. +// Relay admission is decided by the relay's allow hook (/api/relay/allow), so +// this route carries no credentials: clients prove identity in the iroh +// handshake and only need the signed catalog plus their account preference. +// Auth is native-only because the policy names account-scoped infrastructure. + +import { checkRateLimit } from "@vercel/firewall"; + +import { + enforceRelayRateLimit, + jsonResponse, + relayErrorResponse, + runRelayEffect, + type RelayRateLimitCheck, +} from "../../../../services/relay/http"; +import { + productionRelayWorkflowConfig, + signedRelayPolicy, + type SignedRelayPolicyResult, +} from "../../../../services/relay/workflows"; +import { runRelayRepositoryEffect } from "../../../../services/relay/repository"; +import { relayAuthenticationError } from "../../../../services/relay/errors"; +import { + unauthorized, + verifyRequest, + type AuthedUser, +} from "../../../../services/vms/auth"; + +const RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS = 60; + +export interface RelayPolicyDeps { + readonly verifyRequest: (request: Request) => Promise; + readonly nowSeconds: () => number; + readonly signedPolicy: ( + accountId: string, + nowSeconds: number, + ) => Promise; + readonly checkRateLimit: RelayRateLimitCheck; + readonly rateLimitRuleId: () => string | undefined; + readonly isVercel: () => boolean; +} + +const productionDeps: RelayPolicyDeps = { + verifyRequest: (request) => verifyRequest(request, { allowCookie: false }), + nowSeconds: () => Math.floor(Date.now() / 1_000), + signedPolicy: async (accountId, nowSeconds) => { + const config = productionRelayWorkflowConfig(); + return await runRelayRepositoryEffect(signedRelayPolicy(accountId, { + ...config, + nowSeconds, + })); + }, + checkRateLimit, + // Reuses the account-scoped rule that previously gated token minting. + rateLimitRuleId: () => process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID, + isVercel: () => process.env.VERCEL === "1", +}; + +export async function handleRelayPolicyRequest( + request: Request, + deps: RelayPolicyDeps, +): Promise { + let user: AuthedUser | null; + try { + user = await deps.verifyRequest(request); + } catch (error) { + return relayErrorResponse(relayAuthenticationError(error)); + } + if (!user) return unauthorized(); + + try { + const nowSeconds = deps.nowSeconds(); + const retryAfterSeconds = RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS - + (nowSeconds % RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS); + await runRelayEffect(enforceRelayRateLimit({ + request, + accountId: user.id, + ruleId: deps.rateLimitRuleId(), + check: deps.checkRateLimit, + isVercel: deps.isVercel(), + retryAfterSeconds, + })); + const policy = await deps.signedPolicy(user.id, nowSeconds); + return jsonResponse({ + policy: policy.policy, + preference: policy.preference, + preferenceRevision: policy.preferenceRevision, + }); + } catch (error) { + return relayErrorResponse(error); + } +} + +export function GET(request: Request): Promise { + return handleRelayPolicyRequest(request, productionDeps); +} diff --git a/web/app/api/relay/token/route.ts b/web/app/api/relay/token/route.ts deleted file mode 100644 index 296e4a2fd324..000000000000 --- a/web/app/api/relay/token/route.ts +++ /dev/null @@ -1,335 +0,0 @@ -// Mint endpoint-bound access credentials and a signed, server-driven Iroh relay policy. -// Auth is native-only because both credentials leave the browser boundary. - -import type { KeyObject } from "node:crypto"; - -import { checkRateLimit } from "@vercel/firewall"; -import * as Effect from "effect/Effect"; - -import { readBoundedJsonObject } from "../../../../services/apns/routePolicy"; -import { - enforceRelayRateLimit, - jsonResponse, - relayErrorResponse, - runRelayEffect, - type RelayRateLimitCheck, -} from "../../../../services/relay/http"; -import { - isValidEndpointId, - mintManagedRelayCredentials, - relaySigningKey, - type ManagedRelayCredentialGrant, -} from "../../../../services/relay/token"; -import { - RelayConfigurationError, - RelayDatabaseError, - relayAuthenticationError, -} from "../../../../services/relay/errors"; -import { - productionRelayWorkflowConfig, - signedRelayPolicy, - type SignedRelayPolicyResult, -} from "../../../../services/relay/workflows"; -import { runRelayRepositoryEffect } from "../../../../services/relay/repository"; -import { - IrohRepository, - IrohRepositoryLive, -} from "../../../../services/iroh/repository"; -import { - verifyBindingRequestSignature, - type IrohBindingRequestProof, -} from "../../../../services/iroh/crypto"; -import { - parseBindingRequestProof, -} from "../../../../services/iroh/routeHandler"; -import { - unauthorized, - verifyRequest, - type AuthedUser, -} from "../../../../services/vms/auth"; - - -const MAX_BODY_BYTES = 4 * 1_024; -const RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS = 60; - -export interface RelayTokenDeps { - readonly verifyRequest: (request: Request) => Promise; - readonly signingKey: () => KeyObject | null; - readonly nowSeconds: () => number; - readonly signedPolicy: ( - accountId: string, - nowSeconds: number, - ) => Promise; - readonly issueCredentials: (input: { - readonly accountId: string; - readonly endpointId: string; - readonly relayUrls: readonly string[]; - readonly key: KeyObject; - readonly nowSeconds: number; - }) => readonly ManagedRelayCredentialGrant[]; - readonly isEndpointAuthorized: (input: { - readonly accountId: string; - readonly endpointId: string; - readonly clientNamespace: string; - readonly nowSeconds: number; - readonly bindingProof: IrohBindingRequestProof | undefined; - }) => Promise; - readonly checkRateLimit: RelayRateLimitCheck; - readonly rateLimitRuleId: () => string | undefined; - readonly isVercel: () => boolean; - readonly credentialSigningRequired: () => boolean; -} - -const productionDeps: RelayTokenDeps = { - verifyRequest: (request) => verifyRequest(request, { allowCookie: false }), - signingKey: relaySigningKey, - nowSeconds: () => Math.floor(Date.now() / 1_000), - signedPolicy: async (accountId, nowSeconds) => { - const config = productionRelayWorkflowConfig(); - return await runRelayRepositoryEffect(signedRelayPolicy(accountId, { - ...config, - nowSeconds, - })); - }, - issueCredentials: (input) => mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }), - isEndpointAuthorized: async (input) => await runRelayEffect( - Effect.gen(function* () { - const repository = yield* IrohRepository; - const binding = yield* repository.findActiveBindingByEndpoint( - input.accountId, - input.endpointId, - ); - if (!binding || binding.clientNamespace !== input.clientNamespace) { - return false; - } - if (!input.bindingProof) return input.clientNamespace === "legacy"; - if (input.bindingProof.bindingId !== binding.id) return false; - try { - verifyBindingRequestSignature({ - ...input.bindingProof, - endpointId: binding.endpointId, - nowSeconds: input.nowSeconds, - }); - return true; - } catch { - return false; - } - }).pipe( - Effect.provide(IrohRepositoryLive), - Effect.mapError((cause) => new RelayDatabaseError({ - operation: "irohBinding.findByEndpoint", - cause, - })), - ), - ), - checkRateLimit, - rateLimitRuleId: () => process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID, - isVercel: () => process.env.VERCEL === "1", - credentialSigningRequired: () => - process.env.VERCEL === "1" && process.env.VERCEL_ENV !== "preview", -}; - -export async function handleRelayTokenRequest( - request: Request, - deps: RelayTokenDeps, -): Promise { - // Apply the cheap IP-scoped gate before calling Stack Auth. A storming - // client must not spend one upstream users/me request per retry. The clone - // preserves the existing auth-first semantics for malformed requests, which - // must not consume a valid relay-token budget. - if (await hasValidRelayEndpoint(request)) { - try { - await runRelayEffect(enforceRelayRateLimit({ - request, - accountId: "pre-auth", - rateLimitKey: null, - ruleId: deps.rateLimitRuleId(), - check: deps.checkRateLimit, - isVercel: deps.isVercel(), - retryAfterSeconds: RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS, - })); - } catch (error) { - return relayErrorResponse(error); - } - } - - let user: AuthedUser | null; - try { - user = await deps.verifyRequest(request); - } catch (error) { - return relayErrorResponse(relayAuthenticationError(error)); - } - if (!user) return unauthorized(); - const clientNamespace = request.headers.get("x-cmux-app-namespace") ?? "legacy"; - if (!/^[A-Za-z0-9._:-]{1,255}$/.test(clientNamespace)) { - return jsonResponse({ error: "invalid_client_namespace" }, 400); - } - const proofRequest = request.clone(); - - try { - const key = deps.signingKey(); - const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); - if (!body.ok) { - return jsonResponse( - { error: body.error }, - body.error === "request_too_large" ? 413 : 400, - ); - } - const rawEndpointId = body.value.endpointId; - if (typeof rawEndpointId !== "string" || !isValidEndpointId(rawEndpointId)) { - return jsonResponse({ error: "invalid_endpoint_id" }, 400); - } - const bindingProof = parseBindingRequestProof( - proofRequest, - new Uint8Array(await proofRequest.arrayBuffer()), - ); - if (bindingProof instanceof Response) return bindingProof; - if (clientNamespace !== "legacy" && !bindingProof) { - return jsonResponse({ error: "binding_request_proof_required" }, 403); - } - - const nowSeconds = deps.nowSeconds(); - const endpointId = rawEndpointId.toLowerCase(); - const isEndpointAuthorized = await deps.isEndpointAuthorized({ - accountId: user.id, - endpointId, - clientNamespace, - nowSeconds, - bindingProof, - }); - if (clientNamespace !== "legacy" && !isEndpointAuthorized) { - return jsonResponse({ error: "invalid_binding_request_proof" }, 403); - } - - const policy = await deps.signedPolicy(user.id, nowSeconds); - if (!key && deps.credentialSigningRequired()) { - return jsonResponse({ error: "relay_token_not_configured" }, 503); - } - const relayUrls = policy.payload.relays.map((relay) => relay.url); - // A fresh endpoint must fetch policy before registration, then fetch its - // bound credential immediately after registration. Renewals happen every - // four minutes because both artifacts expire after five. Give bootstrap - // and credential issuance separate one-minute partitions so the external - // rule cannot make the valid two-leg bootstrap or renewal cadence - // impossible. Duplicate work inside one phase and minute is still bounded. - const rateLimitBucket = Math.floor( - nowSeconds / RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS, - ); - const rateLimitPhase = isEndpointAuthorized ? "credential" : "bootstrap"; - const retryAfterSeconds = RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS - - (nowSeconds % RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS); - await runRelayEffect(enforceRelayRateLimit({ - request, - accountId: user.id, - devicePartition: - `${endpointId}:${rateLimitPhase}:${rateLimitBucket}`, - ruleId: deps.rateLimitRuleId(), - check: deps.checkRateLimit, - isVercel: deps.isVercel(), - retryAfterSeconds, - })); - - // Local and preview runtimes intentionally operate without the private - // relay JWT signer. They still return the signed fleet policy so clients - // install one coherent account preference and continue with direct/LAN - // paths. Deployed non-preview runtimes fail closed above. - const relayCredentials = isEndpointAuthorized && key - ? deps.issueCredentials({ - accountId: user.id, - endpointId, - relayUrls, - key, - nowSeconds, - }) - : undefined; - if ( - relayCredentials !== undefined && - !hasExactCredentialSet(relayCredentials, relayUrls, nowSeconds) - ) { - throw new RelayConfigurationError({ code: "credential_set_invalid" }); - } - const legacy = relayCredentials - ? homogeneousLegacyCredential(relayCredentials) - : null; - return jsonResponse({ - endpointId, - ...(relayCredentials ? { relayCredentials } : {}), - // Homogeneous fleets retain the old fields during client migration. - ...(legacy - ? { - token: legacy.token, - expiresAt: legacy.expiresAt, - ttlSeconds: legacy.ttlSeconds, - relays: relayUrls, - } - : {}), - policy: policy.policy, - preference: policy.preference, - preferenceRevision: policy.preferenceRevision, - }); - } catch (error) { - return relayErrorResponse(error); - } -} - -function hasExactCredentialSet( - credentials: readonly ManagedRelayCredentialGrant[], - relayUrls: readonly string[], - nowSeconds: number, -): boolean { - if (credentials.length !== relayUrls.length || credentials.length === 0) { - return false; - } - const expected = new Set(relayUrls); - const observed = new Set(); - for (const credential of credentials) { - if ( - !expected.has(credential.relayUrl) || - observed.has(credential.relayUrl) || - credential.token.length === 0 || - credential.token.length > 8 * 1_024 || - credential.ttlSeconds < 30 || - credential.ttlSeconds > 24 * 60 * 60 || - credential.expiresAt <= credential.refreshAfter || - credential.refreshAfter <= nowSeconds || - credential.refreshAfter < credential.expiresAt - credential.ttlSeconds - ) { - return false; - } - observed.add(credential.relayUrl); - } - return observed.size === expected.size; -} - -function homogeneousLegacyCredential( - credentials: readonly ManagedRelayCredentialGrant[], -): ManagedRelayCredentialGrant | null { - const first = credentials[0]; - if (!first) return null; - return credentials.every((credential) => - credential.token === first.token && - credential.expiresAt === first.expiresAt && - credential.refreshAfter === first.refreshAfter && - credential.ttlSeconds === first.ttlSeconds - ) ? first : null; -} - -export function POST(request: Request): Promise { - return handleRelayTokenRequest(request, productionDeps); -} - -async function hasValidRelayEndpoint(request: Request): Promise { - try { - const body = await readBoundedJsonObject(request.clone(), MAX_BODY_BYTES); - const endpointId = body.ok ? body.value.endpointId : undefined; - return typeof endpointId === "string" && isValidEndpointId(endpointId); - } catch { - return false; - } -} diff --git a/web/app/env.ts b/web/app/env.ts index 501e897ab0cf..8a550b3a88c6 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -74,7 +74,6 @@ const retiredEnvValue = ( } }); const privateRelayEnvNames = new Set([ - "CMUX_RELAY_JWT_PRIVATE_KEY_PEM", "CMUX_RELAY_POLICY_KEY_ID", "CMUX_RELAY_POLICY_PRIVATE_KEY_PEM", ]); @@ -259,12 +258,9 @@ export const env = createEnv({ CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT: irohBindingLimit.optional(), CMUX_IROH_DEV_BINDING_DEVICE_LIMIT: irohBindingLimit.optional(), - // Self-hosted relay fleet. Preview and local builds remain credential-free, - // while every deployed non-preview runtime must be able to mint endpoint- - // bound credentials, sign the fleet policy, and enforce its account limit. - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: requireVercelRelayValue( - z.string().min(64).max(16_384), - ), + // Self-hosted relay fleet. Relay admission is the allow hook; the web API + // only signs the fleet policy. Every deployed non-preview runtime must be + // able to sign that policy and enforce its account limit. CMUX_RELAY_POLICY_KEY_ID: requireVercelRelayValue( z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/), ), @@ -369,7 +365,6 @@ export const env = createEnv({ CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT: trimEnv(process.env.CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT), CMUX_IROH_DEV_BINDING_DEVICE_LIMIT: trimEnv(process.env.CMUX_IROH_DEV_BINDING_DEVICE_LIMIT), - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: trimEnv(process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM), CMUX_RELAY_POLICY_KEY_ID: trimEnv(process.env.CMUX_RELAY_POLICY_KEY_ID), CMUX_RELAY_POLICY_PRIVATE_KEY_PEM: trimEnv(process.env.CMUX_RELAY_POLICY_PRIVATE_KEY_PEM), CMUX_RELAY_TOKEN_RATE_LIMIT_ID: trimEnv(process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID), diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index e99968c44fc1..efd0af8fd952 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -52,10 +52,11 @@ authenticated user-id and deployment-environment allowlist match. Registration never mints a relay credential. A newly created binding receives `relay.status = "unavailable"` and signed refreshes of the same binding return -`relay.status = "not_requested"`; clients obtain endpoint-bound credentials for -the self-hosted fleet from `/api/relay/token`, which admits callers by their -active binding. Platform is part of the immutable binding identity and requires -explicit revocation before it can change. +`relay.status = "not_requested"`; the fields exist only for wire compatibility. +Relay admission is decided server-side by the relay's allow hook +(`/api/relay/allow`) against the proven endpoint key, and clients fetch the +signed fleet policy from `/api/relay/policy`. Platform is part of the immutable +binding identity and requires explicit revocation before it can change. Every user-scoped mutation acquires the account-deletion advisory fence before any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 5d1f038be7d6..494ef421e4ef 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -413,11 +413,11 @@ export function makeIrohTrustBroker( now, }); - // Registration never mints a relay credential: clients obtain - // endpoint-bound credentials for the self-hosted fleet from - // /api/relay/token after registering. "unavailable" preserves the - // response shape the pre-registry bootstrap produced when the removed - // n0-hosted minter was unconfigured, which production always was. + // Registration never mints a relay credential: relay admission is the + // relay's allow hook against the proven endpoint key, so no client + // credential exists at all. "unavailable" preserves the response shape + // the pre-registry bootstrap produced when the removed n0-hosted minter + // was unconfigured, which production always was. const relay = registration.created ? { status: "unavailable" as const } : { status: "not_requested" as const }; diff --git a/web/services/relay/token.ts b/web/services/relay/token.ts deleted file mode 100644 index 2ca1c6cb196d..000000000000 --- a/web/services/relay/token.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Pure token-minting logic for the private cmux iroh relay fleet, kept separate -// from the HTTP route so it is testable without the auth/DB/telemetry graph. -// -// The web API is the token issuer: it holds the Ed25519 PRIVATE signing key -// (`CMUX_RELAY_JWT_PRIVATE_KEY_PEM`); every relay VM holds only the matching -// PUBLIC key and verifies tokens offline. A minted token is a short-TTL EdDSA -// JWT with `iss=cmux`, `aud=cmux-relay`, `sub=`, and a required -// `endpoint_id` binding (so a leaked token cannot be replayed from another key). - -import { createPrivateKey, sign as edSign, type KeyObject } from "node:crypto"; -import { configuredRelayCatalog } from "./catalog"; - -export const RELAY_TOKEN_ISS = "cmux"; -export const RELAY_TOKEN_AUD = "cmux-relay"; -export const RELAY_TOKEN_TTL_SECONDS = 300; // short-lived; the client refreshes -export const RELAY_TOKEN_REFRESH_LEAD_SECONDS = 60; - -export type ManagedRelayCredentialGrant = { - readonly relayUrl: string; - readonly token: string; - readonly expiresAt: number; - readonly refreshAfter: number; - readonly ttlSeconds: number; -}; - -// iroh EndpointId is a 32-byte Ed25519 public key. The cmux relay parses the -// JWT claim with `EndpointId::from_str`, which (in iroh-base 1.0.0-rc.1) accepts -// EXACTLY 64-char lowercase hex OR 52-char RFC 4648 base32 (A-Z2-7, -// case-insensitive; `to_string()` emits hex). z-base-32 is a SEPARATE from_z32 -// API the relay does not use, so it must NOT be accepted here. Anything the -// parser rejects would be a signed-but-useless 200, so fail fast with 400. -// (We lowercase before matching; hex is minted lowercase to satisfy HEXLOWER, -// and the relay uppercases base32 internally.) -const HEX_ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; -// A 52-char RFC 4648 base32 encoding of exactly 32 bytes has 4 trailing zero -// bits, so the final symbol carries 1 data bit + 4 zero bits and can only be -// `a` (0) or `q` (16). Other final symbols have non-zero trailing bits, which -// iroh's BASE32_NOPAD decoder rejects — so require the canonical final symbol. -const BASE32_ENDPOINT_ID_RE = /^[a-z2-7]{51}[aq]$/; - -/** Exact canonical fleet retained for compatibility with older route callers. */ -export function relayUrls(): string[] { - return configuredRelayCatalog().relays.map((relay) => relay.url); -} - -// Note: this checks the exact encoding shape, not that the 32 bytes decode to a -// valid Ed25519 curve point (e.g. 64 `f`s pass here but are not on the curve). -// Full on-curve validation is intentionally left to the relay, which is the -// authoritative validator at connect time: the endpoint_id is the CALLER'S OWN -// iroh public key, so a legitimate client always sends a valid point, and a -// crafted-but-invalid id only yields a token bound to a key nobody holds (the -// relay requires the token's endpoint_id to equal the handshake-authenticated -// key), i.e. self-harm with no replay or availability impact. Adding a curve -// library here to reject a self-defeating input is not worth the dependency. -export function isValidEndpointId(value: string): boolean { - const v = value.toLowerCase(); - return HEX_ENDPOINT_ID_RE.test(v) || BASE32_ENDPOINT_ID_RE.test(v); -} - -// Parse the signing key once and cache it keyed on the PEM value, so -// `createPrivateKey` (not free) runs only when the configured key changes. -let cached: { pem: string; key: KeyObject } | null = null; - -export function relaySigningKey(): KeyObject | null { - const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; - if (!pem || !pem.includes("BEGIN")) return null; - if (cached && cached.pem === pem) return cached.key; - try { - const key = createPrivateKey(pem); - // The fleet's baked public key is Ed25519; a misconfigured RSA/EC/Ed448 key - // would sign a token no relay can verify. Treat it as unconfigured (-> 503) - // rather than minting an unusable token or throwing at sign time. - if (key.asymmetricKeyType !== "ed25519") return null; - cached = { pem, key }; - return key; - } catch { - return null; - } -} - -function b64url(input: Buffer | string): string { - return Buffer.from(input).toString("base64url"); -} - -/** - * Mint a compact EdDSA (Ed25519) JWT. Ed25519 signs the raw message (no prehash), - * so the digest passed to `sign` is `null`. The output is byte-for-byte what - * `jsonwebtoken`/`jose` produce and what the relay's verifier accepts. - * - * `endpointId` is REQUIRED: every issued token is bound to the caller's iroh - * endpoint key so a leaked token cannot be replayed from a different key. - */ -export function mintRelayToken(params: { - sub: string; - endpointId: string; - key: KeyObject; - nowSeconds: number; -}): { token: string; expiresAt: number } { - const { sub, endpointId, key, nowSeconds } = params; - const expiresAt = nowSeconds + RELAY_TOKEN_TTL_SECONDS; - const header = { alg: "EdDSA", typ: "JWT" }; - const payload: Record = { - iss: RELAY_TOKEN_ISS, - aud: RELAY_TOKEN_AUD, - sub, - iat: nowSeconds, - exp: expiresAt, - endpoint_id: endpointId.toLowerCase(), - }; - const signingInput = `${b64url(JSON.stringify(header))}.${b64url( - JSON.stringify(payload), - )}`; - const signature = edSign(null, Buffer.from(signingInput), key); - return { token: `${signingInput}.${b64url(signature)}`, expiresAt }; -} - -/** Mint URL-keyed grants without coupling clients to a relay provider. */ -export function mintManagedRelayCredentials(params: { - readonly sub: string; - readonly endpointId: string; - readonly relayUrls: readonly string[]; - readonly key: KeyObject; - readonly nowSeconds: number; -}): ManagedRelayCredentialGrant[] { - const minted = mintRelayToken(params); - return params.relayUrls.map((relayUrl) => ({ - relayUrl, - token: minted.token, - expiresAt: minted.expiresAt, - refreshAfter: minted.expiresAt - RELAY_TOKEN_REFRESH_LEAD_SECONDS, - ttlSeconds: RELAY_TOKEN_TTL_SECONDS, - })); -} diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index dac37eb7fb50..341779e81fe9 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -24,8 +24,6 @@ const requiredIrohProductionEnv = { }; const requiredRelayProductionEnv = { - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: - `-----BEGIN PRIVATE KEY-----\n${"B".repeat(64)}\n-----END PRIVATE KEY-----`, CMUX_RELAY_POLICY_KEY_ID: "relay-policy-current", CMUX_RELAY_POLICY_PRIVATE_KEY_PEM: `-----BEGIN PRIVATE KEY-----\n${"C".repeat(64)}\n-----END PRIVATE KEY-----`, diff --git a/web/tests/relay-token-route.test.ts b/web/tests/relay-token-route.test.ts deleted file mode 100644 index fc74d9a24bfe..000000000000 --- a/web/tests/relay-token-route.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - generateKeyPairSync, - verify as edVerify, -} from "node:crypto"; - -import { - handleRelayTokenRequest, - type RelayTokenDeps, -} from "../app/api/relay/token/route"; -import type { RelayPolicyPayload } from "../services/relay/model"; -import { mintManagedRelayCredentials } from "../services/relay/token"; -import type { AuthedUser } from "../services/vms/auth"; - -const { privateKey, publicKey } = generateKeyPairSync("ed25519"); -const ENDPOINT_ID = "0123456789abcdef".repeat(4); -const PAYLOAD: RelayPolicyPayload = { - version: 1, - jti: "01890f47-9ff8-7cc2-98b3-2fefdbb4312c", - sequence: 4, - iat: 1_700_000_000, - nbf: 1_700_000_000, - exp: 1_700_000_300, - aud: "cmux-iroh-relay-policy", - relay_protocol: "iroh-relay-v1", - relays: [{ - id: "managed-one", - provider: "cmux", - region: "us-west", - url: "https://relay-one.cmux.dev/", - }], -}; - -function deps(overrides: Partial = {}): RelayTokenDeps { - return { - verifyRequest: async () => ({ id: "account-a" }) as AuthedUser, - signingKey: () => privateKey, - nowSeconds: () => 1_700_000_000, - signedPolicy: async (accountId) => { - expect(accountId).toBe("account-a"); - return { - policy: "signed.policy.value", - payload: PAYLOAD, - preference: { - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }, - preferenceRevision: 3, - }; - }, - issueCredentials: (input) => mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }), - isEndpointAuthorized: async () => true, - checkRateLimit: async () => ({ rateLimited: false }), - rateLimitRuleId: () => undefined, - isVercel: () => false, - credentialSigningRequired: () => false, - ...overrides, - }; -} - -function request( - body: unknown, - clientNamespace?: string, - includesBindingProof = false, -): Request { - return new Request("https://cmux.dev/api/relay/token", { - method: "POST", - headers: { - "content-type": "application/json", - ...(clientNamespace - ? { "x-cmux-app-namespace": clientNamespace } - : {}), - ...(includesBindingProof - ? { - "x-cmux-iroh-binding-id": "123e4567-e89b-42d3-a456-426614174090", - "x-cmux-iroh-request-time": "1700000000", - "x-cmux-iroh-request-signature": "a".repeat(86), - } - : {}), - }, - body: JSON.stringify(body), - }); -} - -describe("POST /api/relay/token", () => { - test("keeps legacy token fields and adds policy plus separate preference metadata", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps(), - ); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - const body = await response.json() as Record; - expect(body.relays).toEqual(["https://relay-one.cmux.dev/"]); - expect(body.endpointId).toBe(ENDPOINT_ID); - expect(body.relayCredentials).toEqual([{ - relayUrl: "https://relay-one.cmux.dev/", - token: body.token, - expiresAt: 1_700_000_300, - refreshAfter: 1_700_000_240, - ttlSeconds: 300, - }]); - expect(body.policy).toBe("signed.policy.value"); - expect(body.preference).toEqual({ - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }); - expect(body.preferenceRevision).toBe(3); - expect(body.ttlSeconds).toBe(300); - expect(body.expiresAt).toBe(1_700_000_300); - - const [header, payload, signature] = (body.token as string).split("."); - expect(edVerify( - null, - Buffer.from(`${header}.${payload}`), - publicKey, - Buffer.from(signature, "base64url"), - )).toBe(true); - expect(JSON.parse(Buffer.from(payload, "base64url").toString())).toEqual({ - iss: "cmux", - aud: "cmux-relay", - sub: "account-a", - iat: 1_700_000_000, - exp: 1_700_000_300, - endpoint_id: ENDPOINT_ID, - }); - }); - - test("withholds relay credentials until the endpoint has an active broker binding", async () => { - let mintedCredentials = false; - const unboundDeps = { - ...deps({ - issueCredentials: (input) => { - mintedCredentials = true; - return mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }); - }, - }), - isEndpointAuthorized: async (input: { - accountId: string; - endpointId: string; - clientNamespace: string; - nowSeconds: number; - bindingProof: unknown; - }) => { - expect(input).toEqual({ - accountId: "account-a", - endpointId: ENDPOINT_ID, - clientNamespace: "legacy", - nowSeconds: 1_700_000_000, - bindingProof: undefined, - }); - return false; - }, - }; - - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - unboundDeps, - ); - - expect(response.status).toBe(200); - expect(mintedCredentials).toBe(false); - const body = await response.json() as Record; - expect(body.endpointId).toBe(ENDPOINT_ID); - expect(body.policy).toBe("signed.policy.value"); - expect(body.preferenceRevision).toBe(3); - expect(body.relayCredentials).toBeUndefined(); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - expect(body.expiresAt).toBeUndefined(); - expect(body.ttlSeconds).toBeUndefined(); - }); - - test("passes the exact app namespace into endpoint ownership checks", async () => { - let checkedNamespace = ""; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }, "dev.cmux.app.beta", true), - deps({ - isEndpointAuthorized: async (input) => { - checkedNamespace = input.clientNamespace; - return false; - }, - }), - ); - - expect(response.status).toBe(403); - expect(checkedNamespace).toBe("dev.cmux.app.beta"); - const body = await response.json() as Record; - expect(body.error).toBe("invalid_binding_request_proof"); - }); - - test("requires binding proof before accepting a namespaced endpoint claim", async () => { - let rateLimitChecks = 0; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }, "dev.cmux.app.beta"), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => { - rateLimitChecks += 1; - return { rateLimited: false }; - }, - }), - ); - - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ - error: "binding_request_proof_required", - }); - expect(rateLimitChecks).toBe(1); - }); - - test("returns signed policy without private relay credentials in local development", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ signingKey: () => null }), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - endpointId: ENDPOINT_ID, - policy: "signed.policy.value", - preference: { - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }, - preferenceRevision: 3, - }); - }); - - test("fails closed without the private relay signer in deployed runtimes", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signingKey: () => null, - credentialSigningRequired: () => true, - }), - ); - - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ - error: "relay_token_not_configured", - }); - }); - - test("preserves distinct URL-token associations without ambiguous legacy fields", async () => { - const secondRelay = { - id: "managed-two", - provider: "other", - region: "eu-west", - url: "https://relay-two.example/", - } as const; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signedPolicy: async () => ({ - policy: "signed.policy.value", - payload: { ...PAYLOAD, relays: [...PAYLOAD.relays, secondRelay] }, - preference: { - mode: "automatic", - selectedManagedRelayIds: [], - customRelays: [], - }, - preferenceRevision: 4, - }), - issueCredentials: ({ relayUrls, nowSeconds }) => relayUrls.map( - (relayUrl, index) => ({ - relayUrl, - token: index === 0 ? "abc234" : "def567", - expiresAt: nowSeconds + 300 + index, - refreshAfter: nowSeconds + 240 + index, - ttlSeconds: 300, - }), - ), - }), - ); - - expect(response.status).toBe(200); - const body = await response.json() as Record; - expect(body.relayCredentials).toEqual([ - { - relayUrl: PAYLOAD.relays[0]?.url, - token: "abc234", - expiresAt: 1_700_000_300, - refreshAfter: 1_700_000_240, - ttlSeconds: 300, - }, - { - relayUrl: secondRelay.url, - token: "def567", - expiresAt: 1_700_000_301, - refreshAfter: 1_700_000_241, - ttlSeconds: 300, - }, - ]); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - }); - - test("omits legacy fields when otherwise shared credentials refresh differently", async () => { - const secondRelay = { - id: "managed-two", - provider: "other", - region: "eu-west", - url: "https://relay-two.example/", - } as const; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signedPolicy: async () => ({ - policy: "signed.policy.value", - payload: { ...PAYLOAD, relays: [...PAYLOAD.relays, secondRelay] }, - preference: { - mode: "automatic", - selectedManagedRelayIds: [], - customRelays: [], - }, - preferenceRevision: 4, - }), - issueCredentials: ({ relayUrls, nowSeconds }) => relayUrls.map( - (relayUrl, index) => ({ - relayUrl, - token: "shared-token", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240 + index, - ttlSeconds: 300, - }), - ), - }), - ); - - expect(response.status).toBe(200); - const body = await response.json() as Record; - expect(body.relayCredentials).toHaveLength(2); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - }); - - test("rejects missing, duplicate, and substituted credential URLs", async () => { - for (const issueCredentials of [ - () => [], - ({ relayUrls, nowSeconds }: Parameters[0]) => [ - { - relayUrl: relayUrls[0]!, - token: "abc234", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }, - { - relayUrl: relayUrls[0]!, - token: "def567", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }, - ], - ({ nowSeconds }: Parameters[0]) => [{ - relayUrl: "https://attacker.example/", - token: "abc234", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }], - ]) { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ issueCredentials }), - ); - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ error: "relay_policy_unavailable" }); - } - }); - - test("requires native same-account authentication and a valid endpoint id", async () => { - const unauthorized = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ verifyRequest: async () => null }), - ); - expect(unauthorized.status).toBe(401); - - const invalid = await handleRelayTokenRequest( - request({ endpointId: "z-base-32-is-not-valid" }), - deps(), - ); - expect(invalid.status).toBe(400); - }); - - test("turns a transient Stack Auth throttle into a retryable response", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw new AggregateError( - [new Error("Rate limited, no retry-after header received")], - "Stack Auth unavailable", - ); - }, - }), - ); - - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("60"); - expect(await response.json()).toEqual({ error: "rate_limited" }); - - const statusLimited = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw { status: 429, message: "Too many requests" }; - }, - }), - ); - expect(statusLimited.status).toBe(429); - - const unavailable = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw new Error("Stack Auth connection failed"); - }, - }), - ); - expect(unavailable.status).toBe(503); - expect(unavailable.headers.get("retry-after")).toBeNull(); - expect(await unavailable.json()).toEqual({ - error: "authentication_unavailable", - }); - }); - - test("blocks a valid relay request before calling Stack Auth when ingress is limited", async () => { - let authCalls = 0; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - verifyRequest: async () => { - authCalls += 1; - return { id: "account-a" } as AuthedUser; - }, - checkRateLimit: async (_id, options) => { - expect(options.rateLimitKey).toBeUndefined(); - return { rateLimited: true }; - }, - }), - ); - - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("60"); - expect(authCalls).toBe(0); - }); - - test("rate limits per account and endpoint and fails closed", async () => { - let key: string | undefined; - let checks = 0; - const limited = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - checks += 1; - key = options.rateLimitKey; - if (options.rateLimitKey === undefined) return { rateLimited: false }; - return { rateLimited: true }; - }, - }), - ); - expect(limited.status).toBe(429); - // Partitioned per device, protocol phase, and minute: a storming endpoint - // starves only its duplicate work, never bootstrap, renewal, or another - // phone, simulator, or tagged build. - expect(key).toBe( - `account-a:${ENDPOINT_ID.toLowerCase()}:credential:28333333`, - ); - expect(limited.headers.get("retry-after")).toBe("40"); - - // Malformed requests are rejected before the limiter and never consume - // the per-device budget. - const invalid = await handleRelayTokenRequest( - request({ endpointId: "not-an-endpoint" }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => { - checks += 1; - return { rateLimited: true }; - }, - }), - ); - expect(invalid.status).toBe(400); - expect(checks).toBe(2); - - const blocked = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => options.rateLimitKey === undefined - ? { rateLimited: false } - : { rateLimited: false, error: "blocked" }, - }), - ); - expect(blocked.status).toBe(429); - - const unavailable = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - if (options.rateLimitKey === undefined) return { rateLimited: false }; - throw new Error("firewall unreachable"); - }, - }), - ); - expect(unavailable.status).toBe(503); - }); - - test("gives fresh endpoint bootstrap and bound credential renewal separate minute budgets", async () => { - let nowSeconds = 1_700_000_000; - let endpointBound = false; - const consumedPartitions = new Set(); - const observedPartitions: string[] = []; - const protocolDeps = deps({ - nowSeconds: () => nowSeconds, - isEndpointAuthorized: async () => endpointBound, - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - const partition = options.rateLimitKey ?? ""; - if (!partition) return { rateLimited: false }; - observedPartitions.push(partition); - const rateLimited = consumedPartitions.has(partition); - consumedPartitions.add(partition); - return { rateLimited }; - }, - }); - - const bootstrap = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(bootstrap.status).toBe(200); - expect((await bootstrap.json() as Record).relayCredentials) - .toBeUndefined(); - - endpointBound = true; - const credential = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(credential.status).toBe(200); - expect((await credential.json() as Record).relayCredentials) - .toHaveLength(1); - - const duplicate = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(duplicate.status).toBe(429); - expect(duplicate.headers.get("retry-after")).toBe("40"); - - nowSeconds += 60; - const renewal = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(renewal.status).toBe(200); - expect(new Set(observedPartitions).size).toBe(3); - }); - - test("skips rate limiting when no rule is configured", async () => { - // An unset rule id env var means the operator wants no rate limiting. - // This must mint credentials, not 503 every device off the relay network. - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ isVercel: () => true, rateLimitRuleId: () => undefined }), - ); - expect(response.status).toBe(200); - }); - - test("fails open when the configured rate-limit rule no longer exists", async () => { - // Vercel reports a deleted firewall rule as not-found. That is an operator - // action, not an outage, so the mint must proceed as if unlimited. - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => ({ rateLimited: false, error: "not-found" }), - }), - ); - expect(response.status).toBe(200); - }); -}); diff --git a/web/tests/relay-token.test.ts b/web/tests/relay-token.test.ts deleted file mode 100644 index 218870414388..000000000000 --- a/web/tests/relay-token.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { generateKeyPairSync, verify as edVerify } from "node:crypto"; - -import { - RELAY_TOKEN_TTL_SECONDS, - isValidEndpointId, - mintRelayToken, - relaySigningKey, - relayUrls, -} from "../services/relay/token"; - -// Pure unit tests: no route/auth mocking, so nothing leaks into the shared -// bun-test module registry. A throwaway keypair stands in for the fleet — the -// public key verifies the minted token exactly as a relay would. -const { publicKey, privateKey } = generateKeyPairSync("ed25519"); -const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; - -// A valid 64-hex iroh EndpointId and a valid 52-char RFC 4648 base32 one -// (A-Z2-7; "a" == 0 decodes to a 32-byte value). -const HEX_ID = "0123456789abcdef".repeat(4); -const BASE32_ID = "a".repeat(52); - -function verifyJwt(token: string): { - header: Record; - payload: Record; - valid: boolean; -} { - const [h, p, s] = token.split("."); - const valid = edVerify( - null, - Buffer.from(`${h}.${p}`), - publicKey, - Buffer.from(s, "base64url"), - ); - return { - header: JSON.parse(Buffer.from(h, "base64url").toString()), - payload: JSON.parse(Buffer.from(p, "base64url").toString()), - valid, - }; -} - -beforeEach(() => { - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem; - delete process.env.CMUX_RELAY_URLS; -}); - -describe("mintRelayToken", () => { - test("mints an EdDSA JWT that verifies against the matching public key", () => { - const key = relaySigningKey(); - expect(key).not.toBeNull(); - const now = 1_700_000_000; - const { token, expiresAt } = mintRelayToken({ - sub: "user_abc", - endpointId: HEX_ID, - key: key!, - nowSeconds: now, - }); - const { header, payload, valid } = verifyJwt(token); - // Verifies against the PUBLIC key -> the relay would accept it. - expect(valid).toBe(true); - expect(header.alg).toBe("EdDSA"); - expect(header.typ).toBe("JWT"); - expect(payload.iss).toBe("cmux"); - expect(payload.aud).toBe("cmux-relay"); - expect(payload.sub).toBe("user_abc"); - expect(payload.iat).toBe(now); - expect(payload.exp).toBe(now + RELAY_TOKEN_TTL_SECONDS); - expect(expiresAt).toBe(now + RELAY_TOKEN_TTL_SECONDS); - // endpoint_id is always bound. - expect(payload.endpoint_id).toBe(HEX_ID); - }); - - test("lowercases the bound endpoint_id", () => { - const key = relaySigningKey()!; - const { token } = mintRelayToken({ - sub: "user_1", - endpointId: HEX_ID.toUpperCase(), - key, - nowSeconds: 1_700_000_000, - }); - const { payload } = verifyJwt(token); - expect(payload.endpoint_id).toBe(HEX_ID); - }); - - test("a token signed by a different key does NOT verify", () => { - const key = relaySigningKey()!; - const { token } = mintRelayToken({ - sub: "user_1", - endpointId: BASE32_ID, - key, - nowSeconds: 1_700_000_000, - }); - const other = generateKeyPairSync("ed25519").publicKey; - const [h, p, s] = token.split("."); - const valid = edVerify( - null, - Buffer.from(`${h}.${p}`), - other, - Buffer.from(s, "base64url"), - ); - expect(valid).toBe(false); - }); -}); - -describe("relaySigningKey", () => { - test("returns null when the PEM is unset or malformed", () => { - delete process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; - expect(relaySigningKey()).toBeNull(); - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = "not a pem"; - expect(relaySigningKey()).toBeNull(); - }); - - test("returns null for a non-Ed25519 key (RSA)", () => { - const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = rsa.privateKey.export({ - type: "pkcs8", - format: "pem", - }) as string; - expect(relaySigningKey()).toBeNull(); - }); -}); - -describe("isValidEndpointId", () => { - test("accepts exact 64-hex and 52-char RFC 4648 base32 (any case)", () => { - expect(isValidEndpointId(HEX_ID)).toBe(true); - expect(isValidEndpointId(HEX_ID.toUpperCase())).toBe(true); - expect(isValidEndpointId(BASE32_ID)).toBe(true); - expect(isValidEndpointId(BASE32_ID.toUpperCase())).toBe(true); - }); - test("rejects wrong-length or out-of-alphabet ids", () => { - expect(isValidEndpointId("a".repeat(48))).toBe(false); // wrong length - expect(isValidEndpointId("a".repeat(63))).toBe(false); // 63 != 64 - expect(isValidEndpointId(`${HEX_ID}00`)).toBe(false); // 66 hex - expect(isValidEndpointId("g".repeat(64))).toBe(false); // 'g' not hex - // '1'/'8' are z-base-32 but NOT RFC 4648 base32, so must be rejected. - expect(isValidEndpointId("1".repeat(52))).toBe(false); - expect(isValidEndpointId("8".repeat(52))).toBe(false); - // Non-canonical final symbol (non-zero trailing bits) — iroh's decoder - // rejects it, so we must too (final char must be 'a' or 'q'). - expect(isValidEndpointId("a".repeat(51) + "b")).toBe(false); - expect(isValidEndpointId("a".repeat(51) + "q")).toBe(true); - expect(isValidEndpointId("has spaces!!")).toBe(false); - }); -}); - -describe("relayUrls", () => { - test("returns the canonical 7-region fleet", () => { - const urls = relayUrls(); - expect(urls).toContain("https://usw1.relay.cmux.dev/"); - expect(urls).toContain("https://use4.relay.cmux.dev/"); - expect(urls.length).toBe(7); - }); - test("does not allow a legacy environment override to substitute the fleet", () => { - process.env.CMUX_RELAY_URLS = "https://a.example.com, https://b.example.com"; - expect(relayUrls()).not.toContain("https://a.example.com"); - expect(relayUrls().length).toBe(7); - }); -}); From 0d3a58b38d6a0ff6ace6a51d83b424c4d074a331 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:41:31 -0700 Subject: [PATCH 27/71] review: clear Aziz policy findings with injectable diagnostics and actor mirror CmxIrohDebugRelayOverride keeps no new static members; the diag URL is read through an injectable CmxIrohDebugRelayOverrideDiagnostics struct in its own package file. The relay diag mirror moves to MobileHostIrohRuntime+RelayDiag.swift and becomes a revision-ordered actor instead of an OSAllocatedUnfairLock, read from the iroh_diag Task off-main so the wedged-main-thread guarantee is unchanged. AppTerminationRequest moves to its own Sources file. --- .../CmxIrohDebugRelayOverride.swift | 8 -- ...CmxIrohDebugRelayOverrideDiagnostics.swift | 19 ++++ Sources/AppDelegate.swift | 33 ------- Sources/AppTerminationRequest.swift | 32 +++++++ .../MobileHostIrohRuntime+RelayDiag.swift | 87 +++++++++++++++++++ Sources/Mobile/MobileHostIrohRuntime.swift | 77 +--------------- .../Mobile/MobileHostRelayDiagMirror.swift | 23 +++++ Sources/TerminalController.swift | 15 ++-- cmux.xcodeproj/project.pbxproj | 12 +++ 9 files changed, 183 insertions(+), 123 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift create mode 100644 Sources/AppTerminationRequest.swift create mode 100644 Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift create mode 100644 Sources/Mobile/MobileHostRelayDiagMirror.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift index 8f2b24d5f272..121ddbdd2879 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift @@ -23,14 +23,6 @@ public enum CmxIrohDebugRelayOverride { #endif } - /// The active override's single relay URL, exposed for local debug - /// diagnostics (the `iroh_diag` socket verb). Nil when the override is - /// inactive, and always nil in release builds, where the override - /// compiles away. - public static var diagnosticsActiveRelayURL: String? { - activeProfile()?.activeRelays.first?.url - } - #if DEBUG /// Reads the raw override value, preferring the process environment. static func rawValue( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift new file mode 100644 index 000000000000..15263a7b7a1e --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift @@ -0,0 +1,19 @@ +/// Read-only diagnostics view of ``CmxIrohDebugRelayOverride`` for local +/// debug surfaces (the `iroh_diag` socket verb). +public struct CmxIrohDebugRelayOverrideDiagnostics: Sendable { + /// Creates a diagnostics view over the process-wide override state. + public init() {} + + /// The environment/defaults key that activates the override, echoed in + /// diagnostics output so operators know which knob produced the value. + public var overrideKey: String { + CmxIrohDebugRelayOverride.key + } + + /// The active override's single relay URL. Nil when the override is + /// inactive, and always nil in release builds, where the override + /// compiles away. + public var activeRelayURL: String? { + CmxIrohDebugRelayOverride.activeProfile()?.activeRelays.first?.url + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9a3accf9e0f5..14e85aa71134 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -19561,36 +19561,3 @@ extension AppDelegate { // MARK: - CmuxAppKitSupportUI seam conformance extension AppDelegate: WindowDecorating {} - -// MARK: - App termination requests - -/// The shared terminate request used by the quit shortcut path (keyboard -/// Cmd+Q routing, socket-driven `simulate_shortcut`, and the quit -/// confirmation alert reply). -/// -/// `NSApp.terminate` must not run while the main dispatch queue is inside a -/// caller's block. When `applicationShouldTerminate` returns -/// `.terminateLater`, AppKit spins the run loop waiting for -/// `replyToApplicationShouldTerminate`, and the deferred `@MainActor` -/// cleanup task can only start once the main queue is free again. A debug -/// socket command executes inside `v2MainSync` (`DispatchQueue.main.sync`), -/// so terminating synchronously from it deadlocked the app -/// (https://github.com/manaflow-ai/cmux/issues/10788). Scheduling the -/// terminate as a main-run-loop callout lets the handler finish its reply -/// and release the main queue first, matching how a real keyboard Cmd+Q -/// arrives (a run-loop event callout with an idle main queue). -@MainActor -enum AppTerminationRequest { - /// Requests app termination from a later main-run-loop callout. - /// `terminate` is injectable for tests; production callers use the - /// default `NSApp.terminate`. - static func schedule( - _ terminate: @escaping @MainActor () -> Void = { NSApp.terminate(nil) } - ) { - RunLoop.main.perform(inModes: [.common]) { - MainActor.assumeIsolated { - terminate() - } - } - } -} diff --git a/Sources/AppTerminationRequest.swift b/Sources/AppTerminationRequest.swift new file mode 100644 index 000000000000..5dc248819688 --- /dev/null +++ b/Sources/AppTerminationRequest.swift @@ -0,0 +1,32 @@ +import AppKit + +/// The shared terminate request used by the quit shortcut path (keyboard +/// Cmd+Q routing, socket-driven `simulate_shortcut`, and the quit +/// confirmation alert reply). +/// +/// `NSApp.terminate` must not run while the main dispatch queue is inside a +/// caller's block. When `applicationShouldTerminate` returns +/// `.terminateLater`, AppKit spins the run loop waiting for +/// `replyToApplicationShouldTerminate`, and the deferred `@MainActor` +/// cleanup task can only start once the main queue is free again. A debug +/// socket command executes inside `v2MainSync` (`DispatchQueue.main.sync`), +/// so terminating synchronously from it deadlocked the app +/// (https://github.com/manaflow-ai/cmux/issues/10788). Scheduling the +/// terminate as a main-run-loop callout lets the handler finish its reply +/// and release the main queue first, matching how a real keyboard Cmd+Q +/// arrives (a run-loop event callout with an idle main queue). +@MainActor +enum AppTerminationRequest { + /// Requests app termination from a later main-run-loop callout. + /// `terminate` is injectable for tests; production callers use the + /// default `NSApp.terminate`. + static func schedule( + _ terminate: @escaping @MainActor () -> Void = { NSApp.terminate(nil) } + ) { + RunLoop.main.perform(inModes: [.common]) { + MainActor.assumeIsolated { + terminate() + } + } + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift new file mode 100644 index 000000000000..f75a1903ae5b --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift @@ -0,0 +1,87 @@ +import CmuxIrohTransport +import Foundation + +/// Relay lines for the `iroh_diag` socket verb. +/// +/// Relay URLs are deliberately kept out of `DiagnosticLog` and its report, +/// which stay privacy-safe for Settings exports; only the local debug socket +/// prints them, from the mirror below. +extension MobileHostIrohRuntime { + /// The relay policy fields the `iroh_diag` socket verb reports. + struct RelayDiagState: Equatable, Sendable { + let source: CmxIrohRelayPolicySource + let usedCachedPolicy: Bool + let relayURLs: [String] + } + + nonisolated static let relayDiagMirror = MobileHostRelayDiagMirror() + + /// Monotonic write order for ``relayDiagMirror``, owned by the main + /// actor because every ``relayPolicyEffective`` write happens there. + private static var relayDiagRevision: UInt64 = 0 + + /// The single write funnel, called from `relayPolicyEffective`'s + /// `didSet` so every installation and clearing site is mirrored. + static func publishRelayDiagMirror(from policy: CmxIrohEffectiveRelayPolicy?) { + relayDiagRevision &+= 1 + let revision = relayDiagRevision + let state = policy.map { + RelayDiagState( + source: $0.source, + usedCachedPolicy: $0.usedCachedPolicy, + relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted() + ) + } + Task { + await relayDiagMirror.apply(revision: revision, state: state) + } + } + + /// The relay section appended to `iroh_diag` output: the profile the + /// endpoint is actually using, and whether it came from the managed + /// catalog, a custom profile, or the debug override. The override is + /// consulted first because every profile installation funnel replaces + /// the installed profile with it while it is active. + nonisolated static func relayDiagReportText() async -> String { + relayDiagReport( + policy: await relayDiagMirror.current(), + debugOverrideRelayURL: CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL + ) + } + + nonisolated static func relayDiagReport( + policy: RelayDiagState?, + debugOverrideRelayURL: String? + ) -> String { + var lines = ["Active relay profile"] + if let debugOverrideRelayURL { + let key = CmxIrohDebugRelayOverrideDiagnostics().overrideKey + lines.append("Source: debug override (\(key))") + lines.append("Relays: \(debugOverrideRelayURL)") + return lines.joined(separator: "\n") + } + guard let policy else { + lines.append("Source: none installed (no relay policy this launch)") + return lines.joined(separator: "\n") + } + let source = switch policy.source { + case .inactive: + "inactive (no account policy restored)" + case .managed: + policy.usedCachedPolicy ? "managed catalog (cached)" : "managed catalog" + case .custom: + "custom" + case .managedUnavailable: + "managed selection unavailable (relays disabled)" + case .customUnavailable: + "custom selection unavailable (relays disabled)" + } + lines.append("Source: \(source)") + if policy.relayURLs.isEmpty { + lines.append("Relays: (none)") + } else { + lines.append("Relays: \(policy.relayURLs.joined(separator: ", "))") + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index f9c2a9d415ca..fd4ae3ff76d1 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -4,7 +4,6 @@ import CmuxIrohTransport import CryptoKit import Foundation import Observation -import os import OSLog let mobileHostIrohLog = Logger( @@ -111,15 +110,7 @@ final class MobileHostIrohRuntime { var relayPolicyService: CmxIrohRelayPolicyService? var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? { didSet { - Self.relayDiagMirror.withLock { [relayPolicyEffective] state in - state = relayPolicyEffective.map { - RelayDiagState( - source: $0.source, - usedCachedPolicy: $0.usedCachedPolicy, - relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted() - ) - } - } + Self.publishRelayDiagMirror(from: relayPolicyEffective) } } var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? @@ -247,72 +238,6 @@ final class MobileHostIrohRuntime { role: .macHost ) - /// The relay policy fields the `iroh_diag` socket verb reports. - /// - /// Relay URLs are deliberately kept out of ``DiagnosticLog`` and its - /// report, which stay privacy-safe for Settings exports; the local debug - /// socket appends these lines itself. - struct RelayDiagState: Equatable, Sendable { - let source: CmxIrohRelayPolicySource - let usedCachedPolicy: Bool - let relayURLs: [String] - } - - /// Nonisolated mirror of the relay policy most recently installed by the - /// account pipeline, written from the main-actor `relayPolicyEffective` - /// funnel and readable (like ``hostDiagnosticLog``) without a main-actor - /// hop so `iroh_diag` keeps working when the main thread is wedged. - nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( - initialState: nil - ) - - /// The relay section appended to `iroh_diag` output: the profile the - /// endpoint is actually using, and whether it came from the managed - /// catalog, a custom profile, or the debug override. The override is - /// consulted first because every profile installation funnel replaces - /// the installed profile with it while it is active. - nonisolated static func relayDiagReportText() -> String { - relayDiagReport( - policy: relayDiagMirror.withLock { $0 }, - debugOverrideRelayURL: CmxIrohDebugRelayOverride.diagnosticsActiveRelayURL - ) - } - - nonisolated static func relayDiagReport( - policy: RelayDiagState?, - debugOverrideRelayURL: String? - ) -> String { - var lines = ["Active relay profile"] - if let debugOverrideRelayURL { - lines.append("Source: debug override (\(CmxIrohDebugRelayOverride.key))") - lines.append("Relays: \(debugOverrideRelayURL)") - return lines.joined(separator: "\n") - } - guard let policy else { - lines.append("Source: none installed (no relay policy this launch)") - return lines.joined(separator: "\n") - } - let source = switch policy.source { - case .inactive: - "inactive (no account policy restored)" - case .managed: - policy.usedCachedPolicy ? "managed catalog (cached)" : "managed catalog" - case .custom: - "custom" - case .managedUnavailable: - "managed selection unavailable (relays disabled)" - case .customUnavailable: - "custom selection unavailable (relays disabled)" - } - lines.append("Source: \(source)") - if policy.relayURLs.isEmpty { - lines.append("Relays: (none)") - } else { - lines.append("Relays: \(policy.relayURLs.joined(separator: ", "))") - } - return lines.joined(separator: "\n") - } - private nonisolated static var diagnosticBuildStamp: String { DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary) } diff --git a/Sources/Mobile/MobileHostRelayDiagMirror.swift b/Sources/Mobile/MobileHostRelayDiagMirror.swift new file mode 100644 index 000000000000..0e33cbfa2f9b --- /dev/null +++ b/Sources/Mobile/MobileHostRelayDiagMirror.swift @@ -0,0 +1,23 @@ +/// Mirror of the relay policy most recently installed by the account +/// pipeline, for the `iroh_diag` socket verb. Like +/// `MobileHostIrohRuntime.hostDiagnosticLog`, it is readable without a +/// main-actor hop so the verb keeps working when the main thread is wedged. +/// Writes are ordered by a main-actor revision because separate `Task` hops +/// from the writer funnel are not guaranteed to arrive in submission order. +actor MobileHostRelayDiagMirror { + private var revision: UInt64 = 0 + private var state: MobileHostIrohRuntime.RelayDiagState? + + func apply( + revision newRevision: UInt64, + state newState: MobileHostIrohRuntime.RelayDiagState? + ) { + guard newRevision > revision else { return } + revision = newRevision + state = newState + } + + func current() -> MobileHostIrohRuntime.RelayDiagState? { + state + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9ce723ecb973..7c06aed7477d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11706,16 +11706,19 @@ class TerminalController { // Reads the nonisolated static ring directly: no main-actor hop, so // the verb keeps working when the main thread is wedged (the case // connection diagnostics exist for). The wait blocks only on the - // log's own drain actor, and the execution policy keeps this - // command off the main thread, so the wait cannot self-deadlock. + // log's own drain actor and the relay mirror actor, and the + // execution policy keeps this command off the main thread, so the + // wait cannot self-deadlock. let report = await MobileHostIrohRuntime.hostDiagnosticLog.snapshot() - export = String(decoding: report.humanReadableExport(), as: UTF8.self) + let reportText = String(decoding: report.humanReadableExport(), as: UTF8.self) + // Appended outside the report so relay URLs never enter the + // privacy-safe DiagnosticLog pipeline. + let relayText = await MobileHostIrohRuntime.relayDiagReportText() + export = reportText + "\n" + relayText + "\n" semaphore.signal() } semaphore.wait() - // Appended outside the report so URLs never enter the DiagnosticLog - // pipeline; the mirror read is lock-guarded and main-actor-free. - return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n" + return export } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f955d975a3fb..8e0f43d4c85c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A7206E010000000000000001 /* AppIconAppearanceObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206E020000000000000001 /* AppIconAppearanceObserverTests.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; + 1B0B09980000000000000002 /* AppTerminationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09980000000000000001 /* AppTerminationRequest.swift */; }; A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */; }; A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; @@ -1447,6 +1448,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */; }; 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */; }; C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */; }; + 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */; }; 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */; }; 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */; }; 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */; }; @@ -1455,6 +1457,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources DE71CE000000000000000006 /* MobileHostNetworkPathRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */; }; A1B2C3D4E5F60718293A4C03 /* MobileHostOrderedInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4C04 /* MobileHostOrderedInputTests.swift */; }; A1B2C3D4E5F60718293A4C01 /* MobileHostOrderedRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4C02 /* MobileHostOrderedRequestQueue.swift */; }; + 1B0B09970000000000000002 /* MobileHostRelayDiagMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */; }; 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */; }; C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */; }; C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */; }; @@ -3164,6 +3167,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7206E020000000000000001 /* AppIconAppearanceObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconAppearanceObserverTests.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; + 1B0B09980000000000000001 /* AppTerminationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppTerminationRequest.swift; sourceTree = ""; }; A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowBackdropControllerDependencies.swift; sourceTree = ""; }; A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowChromeComposition.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -4273,6 +4277,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohAuthObserver.swift; sourceTree = ""; }; 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Activation.swift"; sourceTree = ""; }; C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Lifecycle.swift"; sourceTree = ""; }; + 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+RelayDiag.swift"; sourceTree = ""; }; 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+SettingsControl.swift"; sourceTree = ""; }; 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+SettingsSnapshot.swift"; sourceTree = ""; }; 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohRuntime.swift; sourceTree = ""; }; @@ -4281,6 +4286,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostNetworkPathRefreshTests.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4C04 /* MobileHostOrderedInputTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostOrderedInputTests.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4C02 /* MobileHostOrderedRequestQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostOrderedRequestQueue.swift; sourceTree = ""; }; + 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRelayDiagMirror.swift; sourceTree = ""; }; 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRPC.swift; sourceTree = ""; }; C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+Capabilities.swift"; sourceTree = ""; }; C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+TicketAuthorization.swift"; sourceTree = ""; }; @@ -5968,6 +5974,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A2DBE587F52C8A3B2A2CFCB8 /* MobileStateSync.swift */, 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */, 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */, + 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */, + 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */, 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */, 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */, C1A070000000000000000011 /* MobileHostAuthorizationSupport.swift */, @@ -7000,6 +7008,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + 1B0B09980000000000000001 /* AppTerminationRequest.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -9291,6 +9300,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, C97160000000000000000001 /* AppHostProcessReceipt.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, + 1B0B09980000000000000002 /* AppTerminationRequest.swift in Sources */, A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */, A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */, 961300000000000000000003 /* AttributedString+SidebarRowLinks.swift in Sources */, @@ -9943,12 +9953,14 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */, 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */, C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */, + 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */, 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */, 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */, 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */, C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */, DE71CE000000000000000008 /* MobileHostNetworkPathMonitor.swift in Sources */, A1B2C3D4E5F60718293A4C01 /* MobileHostOrderedRequestQueue.swift in Sources */, + 1B0B09970000000000000002 /* MobileHostRelayDiagMirror.swift in Sources */, 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */, C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */, C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */, From e3c16d6c1760fd7da27057471e22172a9025d61c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:48:03 -0700 Subject: [PATCH 28/71] docs: relay admission is the allow hook, not client-held tokens --- docs/iroh-app-transport-architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/iroh-app-transport-architecture.md b/docs/iroh-app-transport-architecture.md index 539cf0a1cee6..9da8afc2c418 100644 --- a/docs/iroh-app-transport-architecture.md +++ b/docs/iroh-app-transport-architecture.md @@ -115,7 +115,7 @@ Every catalog has a strictly increasing sequence and at most sixteen unique cred The server may add, remove, or replace relays without a client update. A remote `EndpointAddr` contains only the remote endpoint's advertised home relay or relays, validated against the signed fleet. Fleet configuration and remote reachability remain separate wire fields. -A signed-in native client calls `POST /api/relay/token` with its canonical EndpointID. The web API returns a five-minute endpoint-bound relay JWT, the signed policy, and the account preference. Each cmux relay verifies its JWT offline. The app refreshes before expiry and replaces the verified relay policy on the live endpoint without changing EndpointID or application streams. +A signed-in native client calls `GET /api/relay/policy`. The web API returns the signed policy and the account preference; clients hold no relay credentials. Relay admission is server-side: the relay's allow hook (`POST /api/relay/allow`) checks the endpoint key proven in the iroh handshake and caches the answer. The app refreshes the signed policy before its expiry and replaces the verified relay policy on the live endpoint without changing EndpointID or application streams. Relay preferences are personal-account scoped: @@ -172,7 +172,7 @@ Before defaulting to Iroh, verification must cover: - public direct, managed-relay, post-admission NAT-traversed LAN/Tailscale/custom-VPN candidates, authenticated Bonjour LAN bootstrap, and hardened numeric Tailscale TCP compatibility paths; - TCP-only firewalls, blocked UDP, captive portals, constrained paths, and expensive cellular paths; - explicit HTTP-proxy-only networks, with a clear legacy/private-network fallback until Iroh relay WebSockets support proxy-controlled connection establishment; -- relay token denial, expiry, refresh, and long-lived stream preservation; +- relay allow-hook denial, signed-policy expiry and refresh, and long-lived stream preservation; - background and foreground endpoint recreation with stable EndpointID; - a deterministic failed-rebind/network-resume test that proves the health watchdog detects terminal driver failure and recreates the endpoint from the same key and identity generation with a new runtime generation; - a malicious pre-admission QNT peer, proving zero candidate disclosure, `REACH_OUT` probes, timers, or migration before activation and same-connection migration after both admitted sides activate; From c1f25f6a31ccdaa43da84a7076835cf562b872ae Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:48:34 -0700 Subject: [PATCH 29/71] ios: refresh stale comment on the policy refresh gate --- .../Sources/cmuxFeature/MobileIrohRuntimeComposition.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index 7d74893dc2f9..a2aeb60cb139 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -2917,9 +2917,8 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { } } - /// The signed policy bootstrap includes a fresh relay credential. Tests - /// that suspend automatic credential renewal must therefore suspend this - /// lane as well as the credential coordinator's timer. + /// Tests that suspend automatic relay refresh suspend this signed-policy + /// refresh lane (the env knob keeps its historical name). nonisolated static func shouldScheduleRelayPolicyRefresh( automaticRelayCredentialRefreshEnabled: Bool, serviceAvailable: Bool, From ed80b8da1ad5ca15b2d77cf31db7fbd26b9a87ce Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:49:15 -0700 Subject: [PATCH 30/71] review: give the relay diag mirror synchronous read-after-write visibility The actor mirror published through a detached Task, so an iroh_diag read racing a policy installation could report the previous or missing profile (second review round's finding). Restore the OSAllocatedUnfairLock mirror, now in MobileHostIrohRuntime+RelayDiag.swift: the didSet writer publishes synchronously before returning, and the reader stays off the main actor so the verb still works while the main thread is wedged. This matches the in-tree AgentChatThemeSync nonisolated-static-lock precedent; the Aziz lock-vs-actor lint intentionally stays flagged because an actor cannot give a synchronous writer read-after-write visibility here. --- .../MobileHostIrohRuntime+RelayDiag.swift | 35 ++++++++++++------- .../Mobile/MobileHostRelayDiagMirror.swift | 23 ------------ Sources/TerminalController.swift | 16 ++++----- cmux.xcodeproj/project.pbxproj | 4 --- 4 files changed, 29 insertions(+), 49 deletions(-) delete mode 100644 Sources/Mobile/MobileHostRelayDiagMirror.swift diff --git a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift index f75a1903ae5b..b959bbd73b61 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift @@ -1,5 +1,6 @@ import CmuxIrohTransport import Foundation +import os /// Relay lines for the `iroh_diag` socket verb. /// @@ -14,17 +15,23 @@ extension MobileHostIrohRuntime { let relayURLs: [String] } - nonisolated static let relayDiagMirror = MobileHostRelayDiagMirror() - - /// Monotonic write order for ``relayDiagMirror``, owned by the main - /// actor because every ``relayPolicyEffective`` write happens there. - private static var relayDiagRevision: UInt64 = 0 + /// Mirror of the relay policy most recently installed by the account + /// pipeline. A lock rather than an actor, deliberately (matching the + /// `AgentChatThemeSync` precedent and the `hostDiagnosticLog` design): + /// the write must be visible synchronously when the + /// `relayPolicyEffective` didSet returns (an actor write would be a + /// detached hop, letting a concurrent `iroh_diag` read report the + /// previous policy after installation), and the read must stay off the + /// main actor so the verb keeps working when the main thread is wedged. + /// Both critical sections are tiny value copies with no reentrancy. + private nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( + initialState: nil + ) /// The single write funnel, called from `relayPolicyEffective`'s - /// `didSet` so every installation and clearing site is mirrored. + /// `didSet` so every installation and clearing site is mirrored before + /// the property write returns. static func publishRelayDiagMirror(from policy: CmxIrohEffectiveRelayPolicy?) { - relayDiagRevision &+= 1 - let revision = relayDiagRevision let state = policy.map { RelayDiagState( source: $0.source, @@ -32,9 +39,11 @@ extension MobileHostIrohRuntime { relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted() ) } - Task { - await relayDiagMirror.apply(revision: revision, state: state) - } + relayDiagMirror.withLock { $0 = state } + } + + nonisolated static func currentRelayDiagState() -> RelayDiagState? { + relayDiagMirror.withLock { $0 } } /// The relay section appended to `iroh_diag` output: the profile the @@ -42,9 +51,9 @@ extension MobileHostIrohRuntime { /// catalog, a custom profile, or the debug override. The override is /// consulted first because every profile installation funnel replaces /// the installed profile with it while it is active. - nonisolated static func relayDiagReportText() async -> String { + nonisolated static func relayDiagReportText() -> String { relayDiagReport( - policy: await relayDiagMirror.current(), + policy: currentRelayDiagState(), debugOverrideRelayURL: CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL ) } diff --git a/Sources/Mobile/MobileHostRelayDiagMirror.swift b/Sources/Mobile/MobileHostRelayDiagMirror.swift deleted file mode 100644 index 0e33cbfa2f9b..000000000000 --- a/Sources/Mobile/MobileHostRelayDiagMirror.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Mirror of the relay policy most recently installed by the account -/// pipeline, for the `iroh_diag` socket verb. Like -/// `MobileHostIrohRuntime.hostDiagnosticLog`, it is readable without a -/// main-actor hop so the verb keeps working when the main thread is wedged. -/// Writes are ordered by a main-actor revision because separate `Task` hops -/// from the writer funnel are not guaranteed to arrive in submission order. -actor MobileHostRelayDiagMirror { - private var revision: UInt64 = 0 - private var state: MobileHostIrohRuntime.RelayDiagState? - - func apply( - revision newRevision: UInt64, - state newState: MobileHostIrohRuntime.RelayDiagState? - ) { - guard newRevision > revision else { return } - revision = newRevision - state = newState - } - - func current() -> MobileHostIrohRuntime.RelayDiagState? { - state - } -} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7c06aed7477d..01f88e048798 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11706,19 +11706,17 @@ class TerminalController { // Reads the nonisolated static ring directly: no main-actor hop, so // the verb keeps working when the main thread is wedged (the case // connection diagnostics exist for). The wait blocks only on the - // log's own drain actor and the relay mirror actor, and the - // execution policy keeps this command off the main thread, so the - // wait cannot self-deadlock. + // log's own drain actor, and the execution policy keeps this + // command off the main thread, so the wait cannot self-deadlock. let report = await MobileHostIrohRuntime.hostDiagnosticLog.snapshot() - let reportText = String(decoding: report.humanReadableExport(), as: UTF8.self) - // Appended outside the report so relay URLs never enter the - // privacy-safe DiagnosticLog pipeline. - let relayText = await MobileHostIrohRuntime.relayDiagReportText() - export = reportText + "\n" + relayText + "\n" + export = String(decoding: report.humanReadableExport(), as: UTF8.self) semaphore.signal() } semaphore.wait() - return export + // Appended outside the report so relay URLs never enter the + // privacy-safe DiagnosticLog pipeline; the mirror read is serialized + // and main-actor-free. + return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n" } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8e0f43d4c85c..c005a781633b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1457,7 +1457,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources DE71CE000000000000000006 /* MobileHostNetworkPathRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */; }; A1B2C3D4E5F60718293A4C03 /* MobileHostOrderedInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4C04 /* MobileHostOrderedInputTests.swift */; }; A1B2C3D4E5F60718293A4C01 /* MobileHostOrderedRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4C02 /* MobileHostOrderedRequestQueue.swift */; }; - 1B0B09970000000000000002 /* MobileHostRelayDiagMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */; }; 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */; }; C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */; }; C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */; }; @@ -4286,7 +4285,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostNetworkPathRefreshTests.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4C04 /* MobileHostOrderedInputTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostOrderedInputTests.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4C02 /* MobileHostOrderedRequestQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostOrderedRequestQueue.swift; sourceTree = ""; }; - 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRelayDiagMirror.swift; sourceTree = ""; }; 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRPC.swift; sourceTree = ""; }; C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+Capabilities.swift"; sourceTree = ""; }; C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+TicketAuthorization.swift"; sourceTree = ""; }; @@ -5975,7 +5973,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */, 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */, 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */, - 1B0B09970000000000000001 /* MobileHostRelayDiagMirror.swift */, 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */, 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */, C1A070000000000000000011 /* MobileHostAuthorizationSupport.swift */, @@ -9960,7 +9957,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */, DE71CE000000000000000008 /* MobileHostNetworkPathMonitor.swift in Sources */, A1B2C3D4E5F60718293A4C01 /* MobileHostOrderedRequestQueue.swift in Sources */, - 1B0B09970000000000000002 /* MobileHostRelayDiagMirror.swift in Sources */, 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */, C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */, C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */, From 9489483573bce6d36630a82c5d44b8c706f9c1d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:54:17 -0700 Subject: [PATCH 31/71] review: delete legacy token-era Keychain record on binding replacement; doc the gated policy fetch The token-era build stored the relay credential under the scope key in the Keychain. saveBinding lost its invalidation delete in the token removal, so a replaced binding could strand that obsolete secret until sign-out. Restore the delete-on-change as legacy hygiene, with a regression test seeding a record at the exact scope key. Also adds the missing DocC line on CmxIrohBackpressuredRelayPolicyBroker.fetchRelayPolicy. --- .../CmxIrohBackpressuredBroker.swift | 1 + .../CmxIrohBrokerCredentialRepository.swift | 11 ++++- ...xIrohBrokerCredentialRepositoryTests.swift | 41 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift index d563710b06cc..5ed19e74e752 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift @@ -185,6 +185,7 @@ public struct CmxIrohBackpressuredRelayPolicyBroker: CmxIrohRelayPolicyServing, self.accountID = accountID } + /// Fetches the signed relay policy through the shared account gate. public func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { try await gate.perform(accountID: accountID, operation: .relayCredential) { try await broker.fetchRelayPolicy() diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift index decc8d7417be..b42293bd15bc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift @@ -55,7 +55,8 @@ public actor CmxIrohBrokerCredentialRepository { ) } - /// Saves an exact broker binding, invalidating relay credentials if it changed. + /// Saves an exact broker binding, deleting any legacy token-era secure + /// record if the binding changed. /// /// - Parameters: /// - binding: The binding tuple returned by registration or discovery. @@ -71,6 +72,14 @@ public actor CmxIrohBrokerCredentialRepository { 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) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift index 4cd36fa51d30..88091e24974f 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CryptoKit import Foundation import Testing @testable import CmuxIrohTransport @@ -67,6 +68,37 @@ struct CmxIrohBrokerCredentialRepositoryTests { ) } + @Test("binding replacement deletes any legacy token-era secure record") + func bindingRotationDeletesLegacySecureRecord() async throws { + let (defaults, suiteName) = try isolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let secureStore = TestSecureCredentialStore() + let repository = makeRepository(defaults: defaults, secureStore: secureStore) + let binding = try metadata() + try await repository.saveBinding(binding, accountID: "account-a") + // A token-era build stored the relay credential under the scope key. + try await secureStore.write( + Data("legacy-token".utf8), + account: scope(accountID: "account-a", appInstanceID: binding.appInstanceID), + accessibility: .afterFirstUnlockThisDeviceOnly + ) + + let replacement = try metadata( + bindingID: "123e4567-e89b-42d3-a456-426614174020", + endpointByte: "cd", + generation: 2 + ) + try await repository.saveBinding(replacement, accountID: "account-a") + + #expect(await secureStore.recordCount() == 0) + #expect( + try await repository.loadBinding( + accountID: "account-a", + appInstanceID: replacement.appInstanceID + ) == replacement + ) + } + @Test("explicit deletion and deactivation clear binding metadata") func explicitDeletion() async throws { let (defaults, suiteName) = try isolatedDefaults() @@ -107,6 +139,15 @@ struct CmxIrohBrokerCredentialRepositoryTests { ) } + /// Mirrors the repository's deterministic scope derivation so a test can + /// seed a record where a token-era build would have stored it. + private 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 func isolatedDefaults() throws -> (UserDefaults, String) { let suiteName = "CmxIrohBrokerCredentialRepositoryTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) From 7e783c995391234c896de4ac7c322a478770cbd0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:06:54 -0700 Subject: [PATCH 32/71] review: default CmxIrohEndpoint.replaceRelayProfile rejects every profile The default implementation silently succeeded for managed profiles after the token-era replaceRelays hook was removed, letting a supervisor commit a profile an alternate endpoint never applied. Reject unconditionally; concrete endpoints that support replacement override it. --- .../Sources/CmuxIrohTransport/CmxIrohEndpoint.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift index 102473ef4a42..d5dddfea83eb 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift @@ -59,10 +59,10 @@ public extension CmxIrohEndpoint { /// Test and alternate endpoints opt out of local advertisement by default. func localDirectAddresses() async -> [String] { [] } - /// Test and alternate endpoints reject relay profile replacement by default. - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { - guard profile.source == .managed else { - throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement - } + /// Test and alternate endpoints reject relay profile replacement by + /// default, so a supervisor can never commit a profile the endpoint did + /// not actually apply. + func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) async throws { + throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement } } From 84050588d0de662a767f629aca80076e965f63dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:24:37 -0700 Subject: [PATCH 33/71] merge fix: drop token-era minter wiring from relay-report DB behavior test feat-iroh-attach-reporting predates feat-iroh-delete-tokens; the merged tree has no IrohRelayMintError, no minter argument on makeIrohTrustBroker, and a five-field IrohTrustBrokerConfigShape. Resolve preferring the deletion side. --- web/tests/relay-report-db-behavior.test.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/web/tests/relay-report-db-behavior.test.ts b/web/tests/relay-report-db-behavior.test.ts index 0499933a8e2b..8bb67505b356 100644 --- a/web/tests/relay-report-db-behavior.test.ts +++ b/web/tests/relay-report-db-behavior.test.ts @@ -11,7 +11,6 @@ import postgres, { type Sql } from "postgres"; import { closeCloudDbForTests } from "../db/client"; import type { IrohTrustBrokerConfigShape } from "../services/iroh/config"; -import { IrohRelayMintError } from "../services/iroh/errors"; import type { IrohPathHint } from "../services/iroh/model"; import { IrohRepository, @@ -279,7 +278,7 @@ describe("phone discovery serves the attach-derived relay route", () => { await insertBinding(); await applyRelayAttachReport(report()); - const broker = makeIrohTrustBroker(repository, failingMinter(), brokerConfig()); + const broker = makeIrohTrustBroker(repository, brokerConfig()); const discovery = await Effect.runPromise( broker.discover(USER_ID, new Date(T0 + 10_000)), ) as { @@ -312,13 +311,6 @@ describe("phone discovery serves the attach-derived relay route", () => { }); }); -/** Discovery never mints; fail loudly if it tries. */ -function failingMinter() { - return { - mint: () => Effect.fail(new IrohRelayMintError({ code: "test_failure" })), - }; -} - function brokerConfig(): IrohTrustBrokerConfigShape { const grantKeys = generateKeyPairSync("ed25519"); return { @@ -339,8 +331,5 @@ function brokerConfig(): IrohTrustBrokerConfigShape { .toString("base64"), }], }), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, }; } From f5f40f5aca87be6ce9e2ee385c98cd4d1a98e29f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:42:15 -0700 Subject: [PATCH 34/71] test: a fresh endpoint must not dial managed relays before registration is acknowledged Cold-launch admission race (3/5 cold launches in the 20260826 sim timing batch): the endpoint binds with its managed relay map installed, native iroh dials the home relay immediately, and the relay's allow hook asks the broker about an endpoint whose registration is still in flight. The deny is negatively cached, the 15s relay gate times out, and the first activation lands 40-60s after launch. Red tests: a runtime with no cached binding must bind relay-less and install managed relays only after broker.register returns; a cached binding keeps relays installed at bind. The existing startInstallsExactIOSBindingAndManagedRelays invariant is updated to the new contract (one relay install, still tokenless). --- .../CmxIrohClientRuntimeTests.swift | 113 +++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index e37ab3828f31..cb38905c0cec 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -109,6 +109,106 @@ struct CmxIrohClientRuntimeTests { await runtime.stop() } + /// A fresh endpoint (no cached binding) must not dial a managed, + /// admission-gated relay before its broker registration is acknowledged: + /// the relay's allow hook denies an unregistered endpoint and negatively + /// caches the deny, costing the whole first activation. The endpoint + /// binds relay-less and the managed relays are installed only after + /// registration returns. + @Test + func freshEndpointWithholdsManagedRelaysUntilRegistrationIsAcknowledged() async throws { + let fixture = try ClientRuntimeTestFixture() + let discovery = try ClientRuntimeTestFixture.discovery( + binding: fixture.binding, + revision: 2 + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestRevisionedClientBroker( + binding: fixture.binding, + discoveries: [discovery], + blockedRegistrationCount: 1, + embedInitialDiscovery: true, + registrationRevision: 1 + ) + let runtime = try CmxIrohClientRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { fixture.now } + ) + + let start = Task { try await runtime.start() } + await broker.waitUntilRegistrationCount(1) + // The endpoint is bound and its registration is held in flight: no + // managed relay may be installed yet. + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + await broker.releaseBlockedRegistration() + try await start.value + + let updates = await endpoint.observedRelayProfileUpdates() + #expect(updates.count == 1) + #expect( + updates.first?.allowedRelayURLs + == Set(ClientRuntimeTestFixture.relayURLs) + ) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A cached binding proves the broker already acknowledged this endpoint, + /// so the managed relays stay installed at bind and no post-registration + /// relay swap happens. + @Test + func cachedBindingKeepsManagedRelaysInstalledAtBind() async throws { + let fixture = try ClientRuntimeTestFixture() + let discovery = try ClientRuntimeTestFixture.discovery( + binding: fixture.binding, + revision: 1 + ) + let configuration = CmxIrohClientRuntimeConfiguration( + accountID: fixture.configuration.accountID, + deviceID: fixture.configuration.deviceID, + appInstanceID: fixture.configuration.appInstanceID, + clientNamespace: fixture.configuration.clientNamespace, + tag: fixture.configuration.tag, + displayName: fixture.configuration.displayName, + identity: fixture.configuration.identity, + capabilities: fixture.configuration.capabilities, + managedRelayURLs: fixture.configuration.managedRelayURLs, + cachedBinding: CmxIrohBrokerBindingMetadata(binding: fixture.binding) + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let runtime = try CmxIrohClientRuntime( + factory: factory, + broker: TestRevisionedClientBroker( + binding: fixture.binding, + discoveries: [discovery] + ), + configuration: configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { fixture.now } + ) + + try await runtime.start() + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect( + boundConfigurations.first?.relayProfile.allowedRelayURLs + == Set(ClientRuntimeTestFixture.relayURLs) + ) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + @Test func authoritativeRejectionCannotFallBackToStaleOfflineAuthority() async throws { let fixture = try RegistryFixture() @@ -485,9 +585,16 @@ struct CmxIrohClientRuntimeTests { #expect(prepared.challengeRequest.tag == fixture.binding.tag) #expect(prepared.challengeRequest.endpointId == fixture.endpointID.endpointID) #expect(prepared.challengeRequest.identityGeneration == fixture.identity.generation) - // Tokenless transport: relays are configured at bind time and the - // connect path installs no credential and mutates no relay. - #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + // Tokenless transport: a fresh endpoint binds relay-less, start() + // installs the managed relays exactly once after registration is + // acknowledged, and the connect path installs no credential and + // mutates no relay further. + let relayUpdates = await endpoint.observedRelayProfileUpdates() + #expect(relayUpdates.count == 1) + #expect( + relayUpdates.first?.activeRelays + .allSatisfy { $0.authenticationToken == nil } == true + ) #expect(await recorder.observedBindingCount() == 1) #expect(runtime.transportFactory.supportedKinds == [.iroh]) await runtime.stop() From 484a6c4ba60d20f008fe48b4212c355bd74362b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:42:15 -0700 Subject: [PATCH 35/71] Withhold managed relays from a fresh endpoint until registration is acknowledged A cached binding proves the broker has already seen this endpoint, so relay dials pass the relay's allow hook immediately and the profile stays installed at bind. Without one, CmxIrohClientRuntime now binds the endpoint with CmxIrohEndpointRelayProfile.unavailableManagedSelection and start() installs the real managed profile via the existing replaceRelayProfile machinery right after install(policy:), i.e. only once broker.register has been acknowledged (cooldown and offline fallbacks cannot reach that point on a fresh endpoint). The relayOnly usable-home-relay gate then waits on a dial that is guaranteed to be admissible, instead of one the relay may have negatively cached as denied. Custom relays are user-operated and not admission-gated by the cmux broker; they stay installed at bind so a broker outage cannot disable them. --- .../CmxIrohClientRuntime.swift | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift index c817bb66491c..79641a1f3349 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift @@ -68,6 +68,14 @@ public actor CmxIrohClientRuntime { let broker: any CmxIrohClientBrokerServing let configuration: CmxIrohClientRuntimeConfiguration var endpointRelayProfile: CmxIrohEndpointRelayProfile + /// Whether this runtime binds its endpoint with the managed relays + /// withheld and installs them only after ``start()`` has an acknowledged + /// broker registration. True exactly when no cached binding proves the + /// broker has already seen this endpoint: a fresh endpoint that dials an + /// admission-gated relay before its registration lands is denied by the + /// relay's allow hook, and that deny is negatively cached, so one lost + /// race costs the whole activation. + let withholdsManagedRelaysUntilRegistered: Bool var managedRelayURLs: Set let pendingRevocations: CmxIrohPendingRevocationOutbox let protocolConfiguration: CmxIrohProtocolConfiguration @@ -144,10 +152,22 @@ public actor CmxIrohClientRuntime { handlePolicyInvalidation: @escaping PolicyInvalidationHandler = {} ) throws { let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile() + // A cached binding proves the broker has acknowledged this endpoint, + // so its relay dials pass the allow hook immediately. Without one the + // endpoint binds relay-less and `start()` installs the managed relays + // only after registration is acknowledged, ordering the first relay + // dial after broker admission. Custom relays are user-operated and + // not admission-gated by the cmux broker, so they stay installed at + // bind (a broker outage must not disable them). + let withholdsManagedRelaysUntilRegistered = configuration.cachedBinding == nil + && endpointRelayProfile.source == .managed + && !endpointRelayProfile.activeRelays.isEmpty let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, alpns: [protocolConfiguration.alpn], - relayProfile: endpointRelayProfile + relayProfile: withholdsManagedRelaysUntilRegistered + ? .unavailableManagedSelection + : endpointRelayProfile ) let supervisor = CmxIrohEndpointSupervisor( factory: factory, @@ -167,6 +187,7 @@ public actor CmxIrohClientRuntime { self.broker = broker self.configuration = configuration self.endpointRelayProfile = endpointRelayProfile + self.withholdsManagedRelaysUntilRegistered = withholdsManagedRelaysUntilRegistered managedRelayURLs = configuration.managedRelayURLs self.pendingRevocations = pendingRevocations self.protocolConfiguration = protocolConfiguration @@ -489,6 +510,19 @@ public actor CmxIrohClientRuntime { ) try requireCurrent(revision) try await install(policy: policy, revision: revision) + if withholdsManagedRelaysUntilRegistered { + // The broker has now acknowledged this endpoint's binding + // (a fresh endpoint cannot reach here otherwise: cooldown + // and offline fallbacks both require prior broker proof). + // Installing the managed relays only now guarantees the + // first relay dial cannot race the relay's allow hook into + // a negatively cached deny. + try await connectivityEngine.replaceRelayProfile( + endpointRelayProfile, + expectedIdentity: endpointID + ) + try requireCurrent(revision) + } if !protocolConfiguration.allowsNATTraversalAfterAdmission { guard await connectivityEngine.hasConfiguredRelay() else { throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut From 498a9be088d9dec64c3584bb77ca0fbb0d0bbe9a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:48:34 -0700 Subject: [PATCH 36/71] test: a peer stalled mid-handshake must not block other admissions Reproduces the Mac-host relay wedge from the 2026-08-26 itest sim timing batch: after an abnormal client death, the host stayed broker-registered and relay-attached, yet every new dial timed out until app restart. The accept pipeline performs the whole server-side handshake inline in the single serial accept loop, so one peer that stops making handshake progress blocks every later admission. Red on this commit; the fix lands in the next commit. --- ...hEndpointServerStalledHandshakeTests.swift | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift new file mode 100644 index 000000000000..902ae800117f --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift @@ -0,0 +1,159 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Regression coverage for the Mac-host relay wedge: a peer that initiates a +/// connection and then stops making handshake progress (killed client, dead +/// relay path) must never gate other peers' admissions. +/// +/// The 2026-08-26 itest timing batch showed the live failure shape: after one +/// abnormal client death, the host stayed broker-registered and relay-attached +/// (its relay TCP connection was unchanged on the relay's side), yet every new +/// dial timed out until the host app was restarted. The host's accept pipeline +/// performed the entire server-side handshake inline in the one serial accept +/// loop, so a single stalled handshake blocked every subsequent admission. +@Suite +struct CmxIrohEndpointServerStalledHandshakeTests { + @Test + func aPeerStalledMidHandshakeDoesNotBlockOtherAdmissions() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "c", count: 64) + ) + let healthyIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "d", count: 64) + ) + let endpoint = StalledHandshakeIrohEndpoint(identity: localIdentity) + let supervisor = CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + _ = try await supervisor.activate() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer(supervisor: supervisor) { connection, generation, _ in + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await connection.close(errorCode: 0, reason: "test_complete") + } + + await server.start() + // One connection attempt whose server-side handshake never completes, + // followed by a healthy peer waiting behind it. + await endpoint.enqueueStalledHandshake() + await endpoint.enqueue( + TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + ) + + var admittedCount = 0 + for _ in 0 ..< 200 { + admittedCount = await recorder.recordedCount() + if admittedCount > 0 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(admittedCount == 1) + if admittedCount == 1 { + let admitted = await recorder.next() + #expect(admitted.identity == healthyIdentity) + } + + await endpoint.releaseStalledHandshakes() + await server.stop() + await supervisor.deactivate() + } +} + +/// An endpoint whose accept queue can contain a connection attempt that stops +/// making handshake progress, exactly like the production iroh accept path +/// when the dialing peer dies after its Initial packet. +private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { + private enum AcceptEvent: Sendable { + case stalledHandshake + case connection(any CmxIrohConnection) + } + + private let peerIdentity: CmxIrohPeerIdentity + private var acceptEvents: [AcceptEvent] = [] + private var acceptWaiters: [UUID: CheckedContinuation] = [:] + private var stallWaiters: [CheckedContinuation] = [] + private let health: AsyncStream + private let healthContinuation: AsyncStream.Continuation + + init(identity: CmxIrohPeerIdentity) { + peerIdentity = identity + let stream = AsyncStream.makeStream() + health = stream.stream + healthContinuation = stream.continuation + } + + func identity() -> CmxIrohPeerIdentity { peerIdentity } + + func address() -> CmxIrohEndpointAddress { + CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) + } + + func connect( + to _: CmxIrohEndpointAddress, + alpn _: Data + ) async throws -> any CmxIrohConnection { + throw TestIrohTransportError.unsupported + } + + func accept() async throws -> (any CmxIrohConnection)? { + let event: AcceptEvent + if !acceptEvents.isEmpty { + event = acceptEvents.removeFirst() + } else { + let id = UUID() + event = await withCheckedContinuation { acceptWaiters[id] = $0 } + } + switch event { + case .stalledHandshake: + // The dialing peer sent its Initial packet and then died. The + // server-side handshake never completes until release. + await withCheckedContinuation { stallWaiters.append($0) } + return nil + case let .connection(connection): + return connection + } + } + + func healthEvents() -> AsyncStream { health } + func isHealthy() -> Bool { true } + + func close() { + releaseStalledHandshakes() + healthContinuation.finish() + } + + func enqueue(_ connection: any CmxIrohConnection) { + deliver(.connection(connection)) + } + + func enqueueStalledHandshake() { + deliver(.stalledHandshake) + } + + func releaseStalledHandshakes() { + let waiters = stallWaiters + stallWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + private func deliver(_ event: AcceptEvent) { + if let id = acceptWaiters.keys.first, + let continuation = acceptWaiters.removeValue(forKey: id) { + continuation.resume(returning: event) + } else { + acceptEvents.append(event) + } + } +} From 9fb464e655fc26283439047c1efb4a723a3816f5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:56:31 -0700 Subject: [PATCH 37/71] fix: own the server-side handshake per connection so one stalled peer cannot wedge the host Root cause of the Mac-host relay wedge from the 2026-08-26 itest timing batch: CmxIrohLibEndpoint.accept() completed the whole server-side QUIC handshake (Incoming.accept, ALPN read, handshake completion) inline in CmxIrohEndpointServer's single serial accept loop. A peer that initiated a connection and then stopped making handshake progress (killed sim app, dead relay path) suspended that loop for the driver's full handshake timeout while the retrying client refilled the accept queue with more already-abandoned attempts, so the host stayed broker-registered and relay-attached but never completed another admission until app restart. The endpoint's accept() now returns an un-handshaken CmxIrohIncomingConnection whose establish() runs inside the same per-connection admission task that owns the rest of that connection's lifetime, bounded by the existing admission deadline and the driver's own handshake timeout. Identity-scoped capacity checks move to post-handshake registration, where the identity is TLS-verified; the global pending-admission cap still bounds pre-handshake work. The accept loop's only job is draining the accept queue, and an accept queue that ends while its generation is current now routes through endpoint recovery instead of dying silently under a still-published binding. --- .../CmuxIrohTransport/CmxIrohEndpoint.swift | 13 +- .../CmxIrohEndpointServer.swift | 220 ++++++++++++------ .../CmxIrohIncomingConnection.swift | 45 ++++ .../CmxIrohLibEndpoint.swift | 12 +- .../CmxIrohLibIncomingConnection.swift | 30 +++ ...xIrohClientRuntimeLifecycleRaceTests.swift | 2 +- .../CmxIrohClientRuntimeTests.swift | 2 +- .../CmxIrohCustomRelayLiveTests.swift | 3 +- ...hEndpointServerStalledHandshakeTests.swift | 24 +- .../CmxIrohEndpointServerTests.swift | 7 +- .../CmxIrohHostRuntimeTests.swift | 8 +- ...CmxIrohPrivatePathTransportGateTests.swift | 3 +- .../TestBlockingRelayUpdateEndpoint.swift | 2 +- .../TestCancellableDialEndpoint.swift | 2 +- .../TestDialingIrohEndpoint.swift | 2 +- .../TestGatedDialEndpoint.swift | 2 +- .../TestHangingDialEndpoint.swift | 2 +- .../TestIrohEndpoint.swift | 2 +- ...eIrohRuntimeCompositionCooldownTests.swift | 2 +- 19 files changed, 284 insertions(+), 99 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift index d5dddfea83eb..775f54e1e84a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift @@ -28,11 +28,16 @@ public protocol CmxIrohEndpoint: Sendable { alpn: Data ) async throws -> any CmxIrohConnection - /// Accepts the next connection that negotiated a configured ALPN. + /// Accepts the next incoming connection attempt without completing its + /// server-side handshake. /// - /// - Returns: The accepted connection, or `nil` after endpoint close. - /// - Throws: A transport error for a failed handshake. - func accept() async throws -> (any CmxIrohConnection)? + /// The returned attempt performs the handshake in + /// ``CmxIrohIncomingConnection/establish()``, so a peer that stops making + /// handshake progress never blocks the accept queue behind it. + /// + /// - Returns: The incoming attempt, or `nil` after endpoint close. + /// - Throws: A transport error when the accept queue fails. + func accept() async throws -> (any CmxIrohIncomingConnection)? /// Replaces the complete managed or custom relay profile without changing EndpointID. /// diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index e7b70b90435e..806a769577c1 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -42,12 +42,21 @@ public actor CmxIrohEndpointServer { private struct PendingAdmission { let generation: UInt64 - let remoteIdentity: CmxIrohPeerIdentity - let connection: any CmxIrohConnection + let incoming: any CmxIrohIncomingConnection let handlerTask: Task let deadlineTask: Task + /// Set once the server-side handshake completed and capacity checks + /// passed. `nil` while the attempt is still establishing. + var remoteIdentity: CmxIrohPeerIdentity? + var connection: (any CmxIrohConnection)? } + /// The endpoint's accept queue ended while its generation is still + /// current: the driver is gone but lifecycle state says active. Thrown + /// into the recovery path so the supervisor re-verifies the endpoint + /// instead of the accept loop dying silently. + private struct AcceptQueueEndedError: Error {} + private struct ActiveConnection { let generation: UInt64 let remoteIdentity: CmxIrohPeerIdentity @@ -154,10 +163,11 @@ public actor CmxIrohEndpointServer { for admission in admissions { admission.handlerTask.cancel() admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "server_stopped" - ) + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: "server_stopped") + } else { + await admission.incoming.abandon() + } } for connection in connections { connection.handlerTask.cancel() @@ -205,13 +215,18 @@ public actor CmxIrohEndpointServer { var consecutiveFailures = 0 while !Task.isCancelled, currentGeneration == generation { do { - guard let connection = try await endpoint.accept() else { return } + guard let incoming = try await endpoint.accept() else { + // Never die silently: recovery below re-verifies the + // endpoint so a closed driver is replaced instead of + // leaving a published-but-undialable generation behind. + throw AcceptQueueEndedError() + } consecutiveFailures = 0 guard currentGeneration == generation else { - await connection.close(errorCode: 1, reason: "stale_generation") + await incoming.abandon() return } - await startAdmission(connection: connection, generation: generation) + startAdmission(incoming: incoming, generation: generation) } catch is CancellationError { return } catch { @@ -236,54 +251,33 @@ public actor CmxIrohEndpointServer { } private func startAdmission( - connection: any CmxIrohConnection, + incoming: any CmxIrohIncomingConnection, generation: UInt64 - ) async { - let remoteIdentity = await connection.remoteIdentity() - guard currentGeneration == generation, !Task.isCancelled else { - await connection.close(errorCode: 1, reason: "stale_generation") - return - } + ) { guard pendingAdmissions.count < maximumPendingAdmissions else { - await connection.close(errorCode: 1, reason: "admission_capacity") - return - } - let pendingForIdentity = pendingAdmissions.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - guard pendingForIdentity < maximumPendingAdmissionsPerIdentity else { - await connection.close( - errorCode: 1, - reason: "admission_identity_capacity" - ) - return - } - let activeForIdentity = activeConnections.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - let hasReplaceableConnection = activeConnections.values.contains { - $0.remoteIdentity == remoteIdentity && !$0.isUsable - } - let canReserveReplacement = pendingForIdentity == 0 - && maximumConnectionsPerIdentity > 1 - && activeForIdentity >= maximumConnectionsPerIdentity - && hasReplaceableConnection - guard pendingAdmissions.count + activeConnections.count < maximumConnections - || canReserveReplacement else { - await connection.close(errorCode: 1, reason: "connection_capacity") - return - } - guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity - || canReserveReplacement else { - await connection.close( - errorCode: 1, - reason: "connection_identity_capacity" - ) + Task { await incoming.abandon() } return } let id = UUID() let handler = handler + // The handshake runs inside this per-connection task, bounded by the + // admission deadline below and by the driver's own handshake timeout, + // so a peer that stops making progress costs only its own slot. let handlerTask = Task { [weak self] in + let connection: any CmxIrohConnection + do { + connection = try await incoming.establish() + } catch { + await self?.failEstablishment(id) + return + } + guard let self else { + await connection.close(errorCode: 1, reason: "server_deallocated") + return + } + guard await self.registerEstablished(id, connection: connection) else { + return + } do { try await handler( connection, @@ -297,9 +291,9 @@ public actor CmxIrohEndpointServer { } ) ) - await self?.finishHandler(id, error: nil) + await self.finishHandler(id, error: nil) } catch { - await self?.finishHandler(id, error: error) + await self.finishHandler(id, error: error) } } let clock = clock @@ -313,19 +307,102 @@ public actor CmxIrohEndpointServer { } pendingAdmissions[id] = PendingAdmission( generation: generation, - remoteIdentity: remoteIdentity, - connection: connection, + incoming: incoming, handlerTask: handlerTask, deadlineTask: deadlineTask ) } + /// Records a completed handshake against its pending admission and applies + /// the identity-scoped capacity policy that used to run before the (then + /// inline) handshake. Returns whether the connection may proceed to the + /// application handler; a rejected or expired connection is closed here. + private func registerEstablished( + _ id: UUID, + connection: any CmxIrohConnection + ) async -> Bool { + let remoteIdentity = await connection.remoteIdentity() + guard var admission = pendingAdmissions[id], + admission.generation == currentGeneration, + admission.connection == nil else { + // Timed out, superseded, or the server stopped while establishing. + await connection.close(errorCode: 1, reason: "admission_expired") + return false + } + let pendingForIdentity = pendingAdmissions.lazy.filter { + $0.key != id && $0.value.remoteIdentity == remoteIdentity + }.count + guard pendingForIdentity < maximumPendingAdmissionsPerIdentity else { + await rejectEstablished( + id, + connection: connection, + reason: "admission_identity_capacity" + ) + return false + } + let activeForIdentity = activeConnections.values.lazy.filter { + $0.remoteIdentity == remoteIdentity + }.count + let hasReplaceableConnection = activeConnections.values.contains { + $0.remoteIdentity == remoteIdentity && !$0.isUsable + } + let canReserveReplacement = pendingForIdentity == 0 + && maximumConnectionsPerIdentity > 1 + && activeForIdentity >= maximumConnectionsPerIdentity + && hasReplaceableConnection + let otherPendingCount = pendingAdmissions.count - 1 + guard otherPendingCount + activeConnections.count < maximumConnections + || canReserveReplacement else { + await rejectEstablished( + id, + connection: connection, + reason: "connection_capacity" + ) + return false + } + guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity + || canReserveReplacement else { + await rejectEstablished( + id, + connection: connection, + reason: "connection_identity_capacity" + ) + return false + } + admission.remoteIdentity = remoteIdentity + admission.connection = connection + pendingAdmissions[id] = admission + return true + } + + private func rejectEstablished( + _ id: UUID, + connection: any CmxIrohConnection, + reason: String + ) async { + if let admission = pendingAdmissions.removeValue(forKey: id) { + admission.deadlineTask.cancel() + } + await connection.close(errorCode: 1, reason: reason) + } + + private func failEstablishment(_ id: UUID) async { + guard let admission = pendingAdmissions.removeValue(forKey: id) else { + return + } + admission.deadlineTask.cancel() + await admission.incoming.abandon() + } + private func markAdmitted(_ id: UUID, generation: UInt64) async -> Bool { guard currentGeneration == generation, - let admission = pendingAdmissions.removeValue(forKey: id), - admission.generation == generation else { + let admission = pendingAdmissions[id], + admission.generation == generation, + let remoteIdentity = admission.remoteIdentity, + let connection = admission.connection else { return false } + pendingAdmissions[id] = nil admission.deadlineTask.cancel() // An authenticated replacement may use the one admission reservation @@ -334,7 +411,7 @@ public actor CmxIrohEndpointServer { // exclusively by markUsable below. let activeForIdentity = activeConnections.filter { _, connection in connection.generation == generation - && connection.remoteIdentity == admission.remoteIdentity + && connection.remoteIdentity == remoteIdentity } let requiresReplacement = activeConnections.count >= maximumConnections || activeForIdentity.count >= maximumConnectionsPerIdentity @@ -352,8 +429,8 @@ public actor CmxIrohEndpointServer { nextConnectionSequence &+= 1 activeConnections[id] = ActiveConnection( generation: generation, - remoteIdentity: admission.remoteIdentity, - connection: admission.connection, + remoteIdentity: remoteIdentity, + connection: connection, handlerTask: admission.handlerTask, sequence: nextConnectionSequence, isUsable: false @@ -399,10 +476,12 @@ public actor CmxIrohEndpointServer { private func finishHandler(_ id: UUID, error: (any Error)?) async { if let admission = pendingAdmissions.removeValue(forKey: id) { admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: error == nil ? "admission_incomplete" : "admission_failed" - ) + let reason = error == nil ? "admission_incomplete" : "admission_failed" + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: reason) + } else { + await admission.incoming.abandon() + } return } guard let active = activeConnections.removeValue(forKey: id) else { @@ -421,10 +500,11 @@ public actor CmxIrohEndpointServer { return } admission.handlerTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "admission_timeout" - ) + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: "admission_timeout") + } else { + await admission.incoming.abandon() + } } private func cancelConnections( @@ -438,7 +518,11 @@ public actor CmxIrohEndpointServer { for admission in stale.values { admission.handlerTask.cancel() admission.deadlineTask.cancel() - await admission.connection.close(errorCode: 1, reason: reason) + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: reason) + } else { + await admission.incoming.abandon() + } } let active = activeConnections.filter { _, connection in connection.generation != retainedGeneration diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift new file mode 100644 index 000000000000..efa03ecf3f78 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift @@ -0,0 +1,45 @@ +public import Foundation + +/// One incoming connection attempt whose server-side handshake has not +/// completed yet. +/// +/// ``CmxIrohEndpoint/accept()`` returns this stage instead of a finished +/// connection so the accept loop's only job is draining the endpoint's accept +/// queue. The handshake is per-connection work owned by that connection's +/// admission task: a peer that stops making handshake progress (killed app, +/// dead relay path) can therefore never gate other peers' admissions. +public protocol CmxIrohIncomingConnection: Sendable { + /// Completes the server-side handshake and returns the connection. + /// + /// - Returns: The TLS-authenticated connection. + /// - Throws: A transport error when the handshake fails or the peer + /// negotiated an unexpected ALPN. + func establish() async throws -> any CmxIrohConnection + + /// Abandons the attempt without completing the handshake. + /// + /// Safe to call after ``establish()`` started; the attempt's resources are + /// released and an unfinished handshake is aborted by the driver. + func abandon() async +} + +/// Wraps an already-established connection as an incoming attempt. +/// +/// Alternate transports and test endpoints that produce finished connections +/// use this to satisfy the accept contract; ``establish()`` returns +/// immediately. +public struct CmxIrohEstablishedIncomingConnection: CmxIrohIncomingConnection { + private let connection: any CmxIrohConnection + + public init(_ connection: any CmxIrohConnection) { + self.connection = connection + } + + public func establish() async throws -> any CmxIrohConnection { + connection + } + + public func abandon() async { + await connection.close(errorCode: 1, reason: "admission_abandoned") + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift index 7092f4d02fd4..e749ede626d4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift @@ -140,13 +140,13 @@ actor CmxIrohLibEndpoint: CmxIrohEndpoint { throw lastError ?? CmxIrohLibError.invalidEndpointIdentity } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { + // Only drain the accept queue here. The server-side handshake belongs + // to the returned attempt's establish(), owned by the per-connection + // admission task: one peer that dies after its Initial packet must not + // wedge the host's whole accept pipeline (the 2026-08-26 relay wedge). guard let incoming = await driver.acceptNext() else { return nil } - let accepting = try await incoming.accept() - guard alpns.contains(try await accepting.alpn()) else { - throw CmxIrohLibError.unexpectedALPN - } - return try CmxIrohLibConnection(driver: await accepting.connect()) + return CmxIrohLibIncomingConnection(incoming: incoming, alpns: alpns) } func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift new file mode 100644 index 000000000000..99f0fb608b8c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift @@ -0,0 +1,30 @@ +import Foundation +import IrohLib + +/// One un-handshaken incoming iroh connection attempt. +/// +/// ``establish()`` performs the server-side handshake (`Incoming.accept`, +/// ALPN validation, handshake completion) that used to run inline in the +/// endpoint's accept path. The underlying driver bounds a peer that stops +/// making progress with its own handshake/idle timeout, and dropping the +/// consumed attempt aborts it, so a stalled attempt can only ever cost its +/// own admission slot. +struct CmxIrohLibIncomingConnection: CmxIrohIncomingConnection { + let incoming: Incoming + let alpns: Set + + func establish() async throws -> any CmxIrohConnection { + let accepting = try await incoming.accept() + guard alpns.contains(try await accepting.alpn()) else { + throw CmxIrohLibError.unexpectedALPN + } + return try CmxIrohLibConnection(driver: await accepting.connect()) + } + + func abandon() async { + // Refuse an unconsumed attempt so the dialer fails fast. An attempt + // whose establish() already consumed the Incoming reports "already + // consumed"; dropping the in-flight Accepting aborts that handshake. + try? await incoming.refuse() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift index 5b8190eed083..5f144dacdf9b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift @@ -450,7 +450,7 @@ private actor ClientRuntimeBlockingCloseEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } func healthEvents() -> AsyncStream { healthStream } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index e37ab3828f31..661a11290201 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -1484,7 +1484,7 @@ private actor TestSubstitutedAddressEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift index b90a9f8649d9..bf44cdb4b0a6 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift @@ -420,7 +420,8 @@ struct CmxIrohCustomRelayLiveTests { to: secondAddress, alpn: alpn ) - let incomingConnection = try #require(await acceptedConnection) + let incomingAttempt = try #require(await acceptedConnection) + let incomingConnection = try await incomingAttempt.establish() return ConnectionPair( outgoing: outgoingConnection, incoming: incomingConnection diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift index 902ae800117f..c6d80ee58911 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift @@ -107,7 +107,7 @@ private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { let event: AcceptEvent if !acceptEvents.isEmpty { event = acceptEvents.removeFirst() @@ -119,13 +119,16 @@ private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { case .stalledHandshake: // The dialing peer sent its Initial packet and then died. The // server-side handshake never completes until release. - await withCheckedContinuation { stallWaiters.append($0) } - return nil + return StalledIncomingConnection(endpoint: self) case let .connection(connection): - return connection + return CmxIrohEstablishedIncomingConnection(connection) } } + func awaitStallRelease() async { + await withCheckedContinuation { stallWaiters.append($0) } + } + func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } @@ -157,3 +160,16 @@ private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { } } } + +/// An incoming attempt whose server-side handshake makes no progress until the +/// endpoint releases it, then fails like an aborted handshake. +private struct StalledIncomingConnection: CmxIrohIncomingConnection { + let endpoint: StalledHandshakeIrohEndpoint + + func establish() async throws -> any CmxIrohConnection { + await endpoint.awaitStallRelease() + throw TestIrohTransportError.unsupported + } + + func abandon() async {} +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift index dc7cf3d24a65..42a7e2e5f4e8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift @@ -496,7 +496,7 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { try Task.checkCancellation() if !acceptEvents.isEmpty { return try Self.resolve(acceptEvents.removeFirst()) @@ -545,9 +545,10 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { nonisolated private static func resolve( _ event: AcceptEvent - ) throws -> (any CmxIrohConnection)? { + ) throws -> (any CmxIrohIncomingConnection)? { switch event { - case let .connection(connection): connection + case let .connection(connection): + CmxIrohEstablishedIncomingConnection(connection) case .failure: throw TestIrohTransportError.unsupported case .closed: nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index b12f48b22501..5859942181d5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift @@ -798,9 +798,11 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { try Task.checkCancellation() - if !connections.isEmpty { return connections.removeFirst() } + if !connections.isEmpty { + return CmxIrohEstablishedIncomingConnection(connections.removeFirst()) + } guard !closed else { return nil } let id = UUID() let connection = await withTaskCancellationHandler { @@ -809,7 +811,7 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { Task { await self.cancelAccept(id) } } try Task.checkCancellation() - return connection + return connection.map { CmxIrohEstablishedIncomingConnection($0) } } func healthEvents() -> AsyncStream { health } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift index ca5d4bf88a02..09b015236f32 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift @@ -424,7 +424,8 @@ struct CmxIrohPrivatePathTransportGateTests { endpoint: any CmxIrohEndpoint, authorizer: CmxIrohAdmissionController ) async throws -> CmxIrohServerSession { - let connection = try #require(try await endpoint.accept()) + let incoming = try #require(try await endpoint.accept()) + let connection = try await incoming.establish() let session = try CmxIrohServerSession( connection: connection, authorizer: authorizer diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift index 36ac5f3fea60..bc89722ccd5b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift @@ -35,7 +35,7 @@ actor TestBlockingRelayUpdateEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift index d4140c77dcaa..1e19f62d3471 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift @@ -44,7 +44,7 @@ actor TestCancellableDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift index fada16bdbeb5..e4403a830dd8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift @@ -50,7 +50,7 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift index ae3c8d829ddb..5fd4c1e2b799 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift @@ -32,7 +32,7 @@ actor TestGatedDialEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift index 8560a76187fd..77cd76cb381b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift @@ -43,7 +43,7 @@ actor TestHangingDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift index 3a761b475581..407604155bd2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift @@ -89,7 +89,7 @@ actor TestIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift index 483993d8c6ea..9013f8e2719a 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift @@ -958,7 +958,7 @@ private actor MobileIrohCooldownEndpoint: CmxIrohEndpoint { throw MobileIrohCooldownTestError.unavailable } - func accept() -> (any CmxIrohConnection)? { nil } + func accept() -> (any CmxIrohIncomingConnection)? { nil } func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } From 0343583e414e094c260849e8a97e5bd6923ed84c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:34:23 -0700 Subject: [PATCH 38/71] polish: split CmxIrohEstablishedIncomingConnection into its own file with member docs Addresses local review file-organization and DocC policy findings on the new public type. --- ...CmxIrohEstablishedIncomingConnection.swift | 25 +++++++++++++++++++ .../CmxIrohIncomingConnection.swift | 23 ----------------- 2 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift new file mode 100644 index 000000000000..2c6414b3d260 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift @@ -0,0 +1,25 @@ +/// Wraps an already-established connection as an incoming attempt. +/// +/// Alternate transports and test endpoints that produce finished connections +/// use this to satisfy the accept contract; ``establish()`` returns +/// immediately. +public struct CmxIrohEstablishedIncomingConnection: CmxIrohIncomingConnection { + private let connection: any CmxIrohConnection + + /// Wraps `connection` as an attempt whose handshake already completed. + public init(_ connection: any CmxIrohConnection) { + self.connection = connection + } + + /// Returns the wrapped connection immediately; the handshake completed + /// before this attempt was created. + public func establish() async throws -> any CmxIrohConnection { + connection + } + + /// Closes the wrapped connection; with the handshake already complete, + /// closing is the only way to release the attempt. + public func abandon() async { + await connection.close(errorCode: 1, reason: "admission_abandoned") + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift index efa03ecf3f78..366d12429106 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift @@ -1,5 +1,3 @@ -public import Foundation - /// One incoming connection attempt whose server-side handshake has not /// completed yet. /// @@ -22,24 +20,3 @@ public protocol CmxIrohIncomingConnection: Sendable { /// released and an unfinished handshake is aborted by the driver. func abandon() async } - -/// Wraps an already-established connection as an incoming attempt. -/// -/// Alternate transports and test endpoints that produce finished connections -/// use this to satisfy the accept contract; ``establish()`` returns -/// immediately. -public struct CmxIrohEstablishedIncomingConnection: CmxIrohIncomingConnection { - private let connection: any CmxIrohConnection - - public init(_ connection: any CmxIrohConnection) { - self.connection = connection - } - - public func establish() async throws -> any CmxIrohConnection { - connection - } - - public func abandon() async { - await connection.close(errorCode: 1, reason: "admission_abandoned") - } -} From e1ed22e230dc5a8ae34ac84ce844c5f6c6df45aa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:40:22 -0700 Subject: [PATCH 39/71] fix: reject over-capacity incoming attempts on the accept loop, not in unowned tasks Local review P1: when admission was full, each rejected attempt minted a fire-and-forget task, so a remote flood could create unbounded tasks and retain every incoming attempt. Abandoning on the loop is deliberate backpressure: rejection work is bounded to one attempt at a time and the pending-admission cap keeps covering all in-flight work. --- .../CmxIrohEndpointServer.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index 806a769577c1..a20b7cb2f941 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -226,7 +226,14 @@ public actor CmxIrohEndpointServer { await incoming.abandon() return } - startAdmission(incoming: incoming, generation: generation) + guard startAdmission(incoming: incoming, generation: generation) else { + // Admission is full. Abandoning here, on the loop, is + // deliberate backpressure: rejection work stays bounded to + // one attempt at a time instead of a remote flood minting + // unowned tasks. + await incoming.abandon() + continue + } } catch is CancellationError { return } catch { @@ -250,13 +257,14 @@ public actor CmxIrohEndpointServer { } } + /// Starts one admission, or returns `false` when admission is at capacity + /// and the caller must abandon the attempt itself. private func startAdmission( incoming: any CmxIrohIncomingConnection, generation: UInt64 - ) { + ) -> Bool { guard pendingAdmissions.count < maximumPendingAdmissions else { - Task { await incoming.abandon() } - return + return false } let id = UUID() let handler = handler @@ -311,6 +319,7 @@ public actor CmxIrohEndpointServer { handlerTask: handlerTask, deadlineTask: deadlineTask ) + return true } /// Records a completed handshake against its pending admission and applies From cdfea9ea7bae4dc7ce6819be488e9fc10316a973 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:29:51 -0700 Subject: [PATCH 40/71] test: regression coverage for reviewed iroh P1s (red) Three failing tests reproducing the pinned-review P1 findings: - dialBoundHoldsWhenTheStalledPhaseIgnoresCancellation: a dial-phase stall inside a non-cooperative driver call (the FFI bindings suspend on polled Rust futures that ignore Task.cancel()) must still fail at the configured dial bound instead of wedging the connect owner. - nonTransientRefreshFailuresDoNotReuseLastGoodDiscovery: rollback detection and non-transient broker rejections must fail closed toward re-discovery instead of dialing with the last-good snapshot. Keeps a companion test proving the cmux#9724 connectivity reuse survives. - expiredPolicyReuseGraceIsClampedToTheCacheMaximum: an unbounded caller grace must not make an expired signed relay policy reusable indefinitely. --- .../CmxIrohClientSessionDialBoundTests.swift | 94 +++++++++++++++++++ ...egistryContextProviderStalenessTests.swift | 68 ++++++++++++++ .../CmxIrohRelayPolicyTests.swift | 36 +++++++ .../TestUncancellableIrohReceiveStream.swift | 32 +++++++ 4 files changed, 230 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift index 6718b1cd2c42..6e0f6a51d145 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift @@ -94,8 +94,102 @@ struct CmxIrohClientSessionDialBoundTests { await session.close() } + @Test("the dial bound holds when the stalled phase ignores cancellation") + func dialBoundHoldsWhenTheStalledPhaseIgnoresCancellation() async throws { + let receiveStream = TestUncancellableIrohReceiveStream() + let control = CmxIrohBidirectionalStream( + receiveStream: receiveStream, + sendStream: TestIrohSendStream() + ) + let connection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [control] + ) + // Model the transport contract of the FFI driver: closing the QUIC + // connection terminates the pending read; Swift task cancellation + // alone does nothing (the bindings poll a Rust future that never + // observes it). + let closeUnblocksReads = Task { + await connection.waitUntilClosed() + await receiveStream.failPendingReceives() + } + defer { closeUnblocksReads.cancel() } + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [.connection(connection)] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + // Observe without cancelling: the deadline must be enforced by the + // session itself, at the transport boundary, not by this test's + // cooperative cancellation. + let connectTask = Task { try await session.connect() } + let outcome = await observedOutcome(of: connectTask, within: .seconds(2)) + #expect(outcome == .failed(.dialTimedOut)) + #expect(await connection.observedCloseCallCount() >= 1) + + // Drain a still-wedged attempt (the red state) so no orphaned task + // outlives the test. + await connection.close(errorCode: 0, reason: "test_cleanup") + await session.close() + } + // MARK: - Support + private enum ObservedDialOutcome: Equatable { + case succeeded + case failed(CmxIrohClientSessionError) + case failedOther(String) + case stillRunningAtObservationDeadline + } + + private actor ObservedDialOutcomeBox { + private var outcome: ObservedDialOutcome? + + func record(_ value: ObservedDialOutcome) { + outcome = value + } + + func current() -> ObservedDialOutcome? { + outcome + } + } + + /// Waits for `connectTask` without ever cancelling it, so a deadline that + /// only works through cooperative cancellation cannot pass by accident. + /// The monitor is deliberately unstructured: in the red state the connect + /// attempt is wedged, and a structured wait on it would deadlock this + /// test; the caller's cleanup close drains it after observation. + private func observedOutcome( + of connectTask: Task, + within limit: Duration + ) async -> ObservedDialOutcome { + let box = ObservedDialOutcomeBox() + Task { + do { + try await connectTask.value + await box.record(.succeeded) + } catch let error as CmxIrohClientSessionError { + await box.record(.failed(error)) + } catch { + await box.record(.failedOther(String(describing: error))) + } + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: limit) + while clock.now < deadline { + if let outcome = await box.current() { return outcome } + try? await clock.sleep(for: .milliseconds(10)) + } + return await box.current() ?? .stillRunningAtObservationDeadline + } + /// Runs `connect()` under a test watchdog so the red state (an unbounded /// admission hang) fails this test quickly instead of hanging the suite. private func boundedConnectFailure( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift index 68e61dff9800..620b7594f3e0 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift @@ -444,6 +444,74 @@ struct CmxIrohRegistryContextProviderStalenessTests { #expect(context.dialPlan.publicPaths.isEmpty) } + @Test + func nonTransientRefreshFailuresDoNotReuseLastGoodDiscovery() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let grant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [grant, grant, grant] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + // A healthy dial consumes the one-shot verified snapshot, leaving the + // last-good authoritative snapshot armed for the next refresh. + _ = try await provider.context(for: fixture.request(hints: [])) + #expect(await broker.discoveryRequestCount() == 0) + + // Rollback/equivocation detection surfaces as `invalidResponse`; a + // revoked or replaced binding as a non-transient rejection. Neither + // may be masked by silently dialing with the last verified snapshot: + // both must fail closed toward re-discovery. + for error in [ + CmxIrohTrustBrokerClientError.invalidResponse, + CmxIrohTrustBrokerClientError.rejected( + statusCode: 403, + code: "binding_revoked" + ), + ] { + await broker.setDiscoverError(error) + await #expect(throws: error) { + try await provider.context(for: fixture.request(hints: [])) + } + } + } + + @Test + func connectivityRefreshFailuresStillReuseLastGoodDiscovery() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let grant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [grant, grant] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + _ = try await provider.context(for: fixture.request(hints: [])) + #expect(await broker.discoveryRequestCount() == 0) + + // The transient class keeps the cmux#9724 behavior: dialing with the + // last verified snapshot beats not dialing at all while the broker + // recovers. + await broker.setDiscoverError(CmxIrohTrustBrokerClientError.connectivity) + let context = try await provider.context(for: fixture.request(hints: [])) + #expect(context.dialPlan.publicPaths == [relay]) + } + // MARK: - Support private func makeProvider( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift index 186eac383faa..3c7fc55e0ba1 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift @@ -269,6 +269,42 @@ struct CmxIrohRelayPolicyTests { ) } + @Test + func expiredPolicyReuseGraceIsClampedToTheCacheMaximum() async throws { + let fixture = try Fixture() + let cache = CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()) + // The fixture token carries the default one-hour signed validity. + _ = try await cache.install( + signedPolicy: fixture.token(sequence: 1), + trustRoot: fixture.trustRoot, + now: fixture.now + ) + + // An unbounded caller grace must not make the expired signed policy + // reusable indefinitely: past the cache's own maximum the load fails + // closed as expired. + let expectedMaximumGrace: TimeInterval = 24 * 60 * 60 + let farPastExpiry = fixture.now.addingTimeInterval( + 3_600 + expectedMaximumGrace + 60 + ) + await #expect(throws: CmxIrohRelayPolicyError.expired) { + try await cache.load( + trustRoot: fixture.trustRoot, + now: farPastExpiry, + expiredPolicyReuseGrace: .infinity + ) + } + + // Inside the clamp the same unbounded request still grants the + // bounded fail-open window (cmux#10375). + let graced = try await cache.load( + trustRoot: fixture.trustRoot, + now: fixture.now.addingTimeInterval(3_600 + 60), + expiredPolicyReuseGrace: .infinity + ) + #expect(graced?.sequence == 1) + } + @Test func corruptPolicyCacheCannotEraseTheRollbackFloor() async throws { let fixture = try Fixture() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift new file mode 100644 index 000000000000..0bb96637f59a --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import CmuxIrohTransport + +/// A control stream whose pending read ignores Swift task cancellation +/// entirely, mirroring the FFI driver contract: the generated bindings +/// suspend the caller on a polled Rust future that `Task.cancel()` never +/// touches. Only a transport-boundary abort (`failPendingReceives`, wired +/// to connection close in tests) terminates the read. +actor TestUncancellableIrohReceiveStream: CmxIrohReceiveStream { + private var pendingReceives: [CheckedContinuation] = [] + private var failed = false + + func receive(maximumByteCount _: Int) async throws -> Data? { + guard !failed else { throw TestIrohTransportError.unsupported } + return try await withCheckedThrowingContinuation { continuation in + pendingReceives.append(continuation) + } + } + + func stop(errorCode _: UInt64) {} + + /// Models the QUIC semantics of closing the owning connection: every + /// pending and future stream read fails immediately. + func failPendingReceives() { + failed = true + let pending = pendingReceives + pendingReceives = [] + for continuation in pending { + continuation.resume(throwing: TestIrohTransportError.unsupported) + } + } +} From 767ea353b1a2961ee9fbb53f0dfd0b6d2b2f4016 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:35:42 -0700 Subject: [PATCH 41/71] fix: enforce reviewed iroh P1s: transport-abort dial deadline, gated last-good reuse, clamped policy grace Three fixes for the pinned-review P1 findings on the iroh transport stack, each covered by the failing test in the previous commit. Dial deadline (CmxIrohClientSession): the dial-phase bound raced the operation against a timer inside a throwing task group, but the group still awaits the losing child on scope exit and cancelAll() is only cooperative. The admission barrier's stream I/O suspends inside FFI calls whose generated bindings poll Rust futures that Task.cancel() never resumes, so a stalled peer defeated the bound entirely and the connect owner wedged. boundedByDialPhase now takes an abortOnDeadline hook that terminates the operation at the transport boundary before failing typed; the admission phase passes a hook that closes the QUIC connection, which fails every pending stream call and ends the child. The endpoint dial phase needs no hook because CmxIrohLibEndpoint already bridges cancellation across the FFI boundary through the fork's cancellable ConnectAttempt. Last-good discovery reuse (CmxIrohRegistryContextProvider): the failed-refresh fallback dialed with the last verified snapshot for every error class, masking rollback detection, non-transient broker rejections, and invalid-authentication failures behind stale identity data. The reuse is now gated on the codified transient taxonomy (CmxIrohTrustBrokerClientError.preservesVerifiedStateDuringRefresh), so connectivity failures and broker cooldowns keep the cmux#9724 behavior while trust signals fail closed toward re-discovery. Expired-policy reuse grace (CmxIrohRelayPolicyCache): load() accepted any positive grace, including .infinity, making an expired signed relay policy reusable indefinitely. The cache now owns a hard 24-hour maximum and clamps every caller value; NaN degrades to strict expiry. --- .../CmxIrohClientSession.swift | 33 +++++++++++++++---- .../CmxIrohRegistryContextProvider.swift | 19 +++++++---- .../CmxIrohRelayPolicyCache.swift | 28 ++++++++++++---- .../CmxIrohRelayPolicyTests.swift | 3 +- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift index 9c73968bbe57..7c39cc31d281 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift @@ -434,8 +434,18 @@ public actor CmxIrohClientSession { // A half-ready peer can accept the QUIC connection and then never // serve the admission frames. Bound the whole barrier like a dial // phase so a silent peer hands control back to recovery instead - // of holding the redial owner open-endedly (cmux#9724). - return try await boundedByDialPhase { [weak self] in + // of holding the redial owner open-endedly (cmux#9724). The + // barrier's stream I/O sits in FFI calls that ignore task + // cancellation, so the deadline aborts at the transport boundary: + // closing the connection is what actually ends a stalled read. + return try await boundedByDialPhase( + abortOnDeadline: { + await establishedConnection.close( + errorCode: 1, + reason: "admission_timeout" + ) + } + ) { [weak self] in guard let self else { throw CancellationError() } return try await self.performAdmissionBarrier( on: establishedConnection @@ -518,11 +528,21 @@ public actor CmxIrohClientSession { } } - /// Races one dial-phase operation against the injected phase bound. The - /// operation must cancel cooperatively; on timeout the loser is cancelled - /// and the phase fails typed so the redial machinery supersedes it - /// instead of wedging behind it (cmux#8531). + /// Races one dial-phase operation against the injected phase bound. + /// + /// The deadline must not depend on the operation observing cooperative + /// cancellation: the FFI driver suspends Swift callers on polled Rust + /// futures that `Task.cancel()` never resumes, and the task group still + /// awaits the losing child on scope exit. When the timer wins, + /// `abortOnDeadline` first terminates the operation at the transport + /// boundary (closing the QUIC connection fails every pending stream + /// call), so the phase reliably fails typed and the redial machinery + /// supersedes it instead of wedging behind it (cmux#8531, cmux#9724). + /// The endpoint dial phase passes no abort hook because the endpoint + /// already bridges cancellation across the FFI boundary through the + /// fork's cancellable `ConnectAttempt`. private func boundedByDialPhase( + abortOnDeadline: (@Sendable () async -> Void)? = nil, _ operation: @escaping @Sendable () async throws -> Value ) async throws -> Value { let bound = dialPhaseTimeout @@ -536,6 +556,7 @@ public actor CmxIrohClientSession { } defer { group.cancelAll() } guard let first = try await group.next(), let value = first else { + await abortOnDeadline?() throw CmxIrohClientSessionError.dialTimedOut } return value diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index 2c0fd5d50e3d..527934d61d6a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -194,12 +194,19 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { usedFreshDiscovery = true } catch { try Task.checkCancellation() - // The refresh failed. Dialing with the last verified snapshot - // beats not dialing at all (cmux#9724): the staleness mark - // survives, so the next attempt still refetches once the - // broker recovers. Verification is not weakened; this - // snapshot passed the same checks when it was fetched. - if let lastGood = authoritativeDiscovery { + // The refresh failed. For the transient class (connectivity, + // broker cooldown, availability blips) dialing with the last + // verified snapshot beats not dialing at all (cmux#9724): the + // staleness mark survives, so the next attempt still + // refetches once the broker recovers. Every other failure is + // a trust signal (rollback/equivocation detection, a + // non-transient rejection, invalid authentication, a + // malformed authority response) and fails closed toward + // re-discovery instead of being masked by stale identity + // data. + if CmxIrohTrustBrokerClientError + .preservesVerifiedStateDuringRefresh(error), + let lastGood = authoritativeDiscovery { do { return try await resolveContext( for: request, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift index df245a1ea796..e27d5105ae99 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift @@ -92,16 +92,25 @@ public actor CmxIrohRelayPolicyCache { return policy } + /// Hard upper bound on the expired-policy reuse window accepted by + /// ``load(trustRoot:now:expiredPolicyReuseGrace:)``. The grace exists to + /// ride out a failed refresh, not to make an expired signed policy + /// reusable indefinitely; the cache is the single authority on how far + /// past its signed expiry a record may still load, so any larger or + /// non-finite caller value is clamped here. + public static let maximumExpiredPolicyReuseGrace: TimeInterval = 24 * 60 * 60 + /// Loads and re-verifies the cached policy at the current time. /// /// - Parameters: /// - trustRoot: App-pinned public verification keys. /// - now: Verification time. /// - expiredPolicyReuseGrace: Bounded fail-open window after the signed - /// expiry in which the last-good policy still loads. The policy is - /// re-verified at its final valid instant, so signature, rollback, - /// and claim checks run unweakened; only the expiry gate is graced - /// (cmux#10375). Zero preserves strict expiry. + /// expiry in which the last-good policy still loads, clamped to + /// ``maximumExpiredPolicyReuseGrace``. The policy is re-verified at + /// its final valid instant, so signature, rollback, and claim checks + /// run unweakened; only the expiry gate is graced (cmux#10375). Zero + /// preserves strict expiry. /// - Returns: The verified policy, or `nil` when no policy is cached. /// - Throws: ``CmxIrohRelayPolicyError`` or a secure-storage error. public func load( @@ -122,11 +131,16 @@ public actor CmxIrohRelayPolicyCache { } catch CmxIrohRelayPolicyError.expired { // The recorded expiry is cross-checked against the signed claims // below; a record that overstates it re-fails as expired here or - // as rollback below. - guard expiredPolicyReuseGrace > 0, + // as rollback below. A NaN grace fails the positivity gate, so + // non-finite caller values degrade to strict expiry or the clamp. + let grace = min( + expiredPolicyReuseGrace, + Self.maximumExpiredPolicyReuseGrace + ) + guard grace > 0, let recordedExpiry = record.expiresAt, now.timeIntervalSince1970 - <= TimeInterval(recordedExpiry) + expiredPolicyReuseGrace else { + <= TimeInterval(recordedExpiry) + grace else { throw CmxIrohRelayPolicyError.expired } let lastValidInstant = Date( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift index 3c7fc55e0ba1..491d556952e9 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift @@ -283,9 +283,8 @@ struct CmxIrohRelayPolicyTests { // An unbounded caller grace must not make the expired signed policy // reusable indefinitely: past the cache's own maximum the load fails // closed as expired. - let expectedMaximumGrace: TimeInterval = 24 * 60 * 60 let farPastExpiry = fixture.now.addingTimeInterval( - 3_600 + expectedMaximumGrace + 60 + 3_600 + CmxIrohRelayPolicyCache.maximumExpiredPolicyReuseGrace + 60 ) await #expect(throws: CmxIrohRelayPolicyError.expired) { try await cache.load( From 554cdea9ccf5b2c5005ad0a4e84534e798dd9d21 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:03:28 -0700 Subject: [PATCH 42/71] test: a fresh host must not dial managed relays before registration is acknowledged Host-side twin of the client cold-launch admission race: a freshly installed Mac host binds its iroh endpoint with the managed relay map installed, native iroh dials the home relay immediately, and the relay's allow hook asks the broker about an endpoint whose registration is still in flight. The deny is negatively cached (cmux-relay#9: first deny 5s, escalating), delaying the host's own usable-home-relay publication gate. Red tests: a host with no verified cached policy must bind relay-less and install managed relays exactly once, only after broker.register returns; a verified cached policy keeps managed relays installed at bind with no post-registration swap, pinning the warm cache-first activation path. The existing startBindsExactRegisteredIdentity invariant is updated to the new contract, and HostRuntimeAcceptingEndpoint now records relay profile installs instead of rejecting them. --- .../CmxIrohHostRuntimeLifecycleTests.swift | 8 +- .../CmxIrohHostRuntimeTests.swift | 96 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift index 641be7418c18..f6e131d1ba2c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift @@ -284,7 +284,13 @@ extension CmxIrohHostRuntimeTests { #expect(configurations.count == 1) #expect(configurations.first?.secretKey == fixture.identity.secretKey) #expect(configurations.first?.bindPolicy == .ephemeral) - #expect(configurations.first?.managedRelayURLs == fixture.managedRelays) + // A fresh host binds relay-less and installs the managed relays + // exactly once, after its registration is acknowledged, so the first + // relay dial cannot race the relay's admission hook. + #expect(configurations.first?.managedRelayURLs == []) + let relayUpdates = await endpoint.observedRelayProfileUpdates() + #expect(relayUpdates.count == 1) + #expect(relayUpdates.first?.allowedRelayURLs == fixture.managedRelays) let lan = try #require(await runtime.lanAdvertisementContext()) #expect(lan.binding == CmxIrohBrokerBindingMetadata(binding: fixture.binding)) #expect(lan.rendezvous == fixture.discovery.lanRendezvous) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index b12f48b22501..3ecd50f4f2c8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift @@ -35,6 +35,93 @@ struct CmxIrohHostRuntimeTests { await runtime.stop() } + /// A fresh Mac host (no verified cached policy) must not dial a managed, + /// admission-gated relay before its broker registration is acknowledged: + /// the relay's allow hook denies an unregistered endpoint and negatively + /// caches the deny, costing the whole first activation. The endpoint + /// binds relay-less and the managed relays are installed only after + /// registration returns. + @Test + func freshHostWithholdsManagedRelaysUntilRegistrationIsAcknowledged() async throws { + let fixture = try HostRuntimeFixture() + let registrationGate = HostRuntimeRegistrationGate() + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationHook: { + await registrationGate.waitOnce() + return true + } + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + let start = Task { try await runtime.start() } + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + // The endpoint is bound and its registration is held in flight: no + // managed relay may be active at bind or installed yet. + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(boundConfigurations.first?.relayProfile.allowedRelayURLs.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + await registrationGate.open() + try await start.value + + let updates = await endpoint.observedRelayProfileUpdates() + #expect(updates.count == 1) + #expect(updates.first?.allowedRelayURLs == fixture.managedRelays) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A verified cached policy proves the broker already acknowledged this + /// endpoint, so cache-first activation keeps the managed relays installed + /// at bind and performs no post-registration relay swap. This pins the + /// warm ~20ms start path of the cache-first design. + @Test + func cachedPolicyKeepsManagedRelaysInstalledAtBind() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let cachedPolicy = try cachedFixture.policy() + let endpoint = try fixture.relayReadyEndpoint() + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect( + boundConfigurations.first?.relayProfile.allowedRelayURLs + == fixture.managedRelays + ) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + @Test("direct-only startup does not wait for relay readiness") func directOnlyStartupSkipsRelayReadiness() async throws { let fixture = try HostRuntimeFixture() @@ -777,6 +864,7 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { private let healthContinuation: AsyncStream.Continuation private var closed = false private var closeCallCount = 0 + private var relayProfileUpdates: [CmxIrohEndpointRelayProfile] = [] init(identity: CmxIrohPeerIdentity) { peerIdentity = identity @@ -785,6 +873,14 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { healthContinuation = stream.continuation } + func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) { + relayProfileUpdates.append(profile) + } + + func observedRelayProfileUpdates() -> [CmxIrohEndpointRelayProfile] { + relayProfileUpdates + } + func identity() -> CmxIrohPeerIdentity { peerIdentity } func address() -> CmxIrohEndpointAddress { From c0214d8e0c6a273864e0cd7a4a63e20d25eebc3a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:08:08 -0700 Subject: [PATCH 43/71] Withhold managed relays from a fresh host until registration is acknowledged Host-side twin of the client fix: CmxIrohHostRuntime.start() now binds the endpoint with CmxIrohEndpointRelayProfile.unavailableManagedSelection when no cached policy cryptographically verifies for this endpoint, and installs the real managed profile through the shared CmxConnectivityEngine.replaceRelayProfile machinery only after the policy resolve returns, i.e. once broker.register has been acknowledged (a fresh host cannot leave resolveInitialPolicy any other way: the cachedPolicy(after:) fallback requires the same verified cached policy whose absence made the bind withhold). The usable-home-relay publication gate then waits on a dial the relay can admit. The withhold decision runs the full validateCachedPolicy check before bind, using the endpoint identity derived from the configured secret key, so a stale or rotated cache withholds instead of racing. A verified cached policy keeps the old behavior: relays installed at bind, warm cache-first activation unchanged. Custom relays are user-operated, not admission-gated by the cmux broker, and stay installed at bind so a broker outage cannot disable them. --- .../CmxIrohHostRuntime+PolicyRefresh.swift | 27 +++++++++++++++++ .../CmxIrohHostRuntime.swift | 29 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 522703f70511..9326eb395de5 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -294,6 +294,33 @@ extension CmxIrohHostRuntime { ) } + /// Whether this activation binds its endpoint with the managed relays + /// withheld and installs them only after ``start()`` has an acknowledged + /// broker registration. True exactly when the profile is managed (custom + /// relays are user-operated, not admission-gated by the cmux broker) and + /// no cached policy verifies for this endpoint: a fresh endpoint that + /// dials an admission-gated relay before its registration lands is denied + /// by the relay's allow hook, and that deny is negatively cached, so one + /// lost race costs the whole activation. The endpoint identity is derived + /// from the configured secret key, so the decision is made before the + /// bind it governs. + func withholdsManagedRelaysUntilRegistered( + for profile: CmxIrohEndpointRelayProfile + ) -> Bool { + guard profile.source == .managed, !profile.activeRelays.isEmpty else { + return false + } + guard let expectedEndpointID = configuration.identity.peerIdentity else { + // Without a derivable identity no cached policy can verify; + // withholding is the safe default (the bind itself decides + // whether the key is usable at all). + return true + } + return validatedCachedStartPolicy( + expectedEndpointID: expectedEndpointID + ) == nil + } + func cachedPolicy( after error: any Error, expectedEndpointID: CmxIrohPeerIdentity, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index eb922887151f..bac8986bd2ed 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -192,11 +192,24 @@ public actor CmxIrohHostRuntime { let endpointRelayProfile = try currentEndpointRelayProfile ?? configuration.resolvedEndpointRelayProfile() currentEndpointRelayProfile = endpointRelayProfile + // A verified cached policy proves the broker has acknowledged + // this endpoint, so its relay dials pass the relay's allow hook + // immediately and the profile stays installed at bind. Without + // one the endpoint binds relay-less and the managed profile is + // installed only after registration is acknowledged below, + // ordering the first relay dial after broker admission (a denied + // pre-registration dial is negatively cached by the relay). + // Custom relays are user-operated and not admission-gated by the + // cmux broker, so they stay installed at bind. + let withholdsManagedRelaysUntilRegistered = + withholdsManagedRelaysUntilRegistered(for: endpointRelayProfile) let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, alpns: [protocolConfiguration.alpn], bindPolicy: configuration.bindPolicy, - relayProfile: endpointRelayProfile + relayProfile: withholdsManagedRelaysUntilRegistered + ? .unavailableManagedSelection + : endpointRelayProfile ) let connectivityEngine = CmxConnectivityEngine( factory: factory, @@ -237,6 +250,20 @@ public actor CmxIrohHostRuntime { ) } try requireCurrent(revision) + if withholdsManagedRelaysUntilRegistered { + // The broker has now acknowledged this endpoint's binding: a + // fresh host cannot leave resolveInitialPolicy otherwise, + // because the cachedPolicy(after:) fallback requires the same + // verified cached policy whose absence made this bind + // withhold. Installing the managed relays only now guarantees + // the first relay dial cannot race the relay's allow hook + // into a negatively cached deny. + try await connectivityEngine.replaceRelayProfile( + endpointRelayProfile, + expectedIdentity: endpointID + ) + try requireCurrent(revision) + } let offlineSessions = CmxIrohOfflinePairingSessions( pairingEnabled: policy.pairingEnabled From bf5de1ca55a0db19a9e8ee2a6becde83a3377846 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:26:36 -0700 Subject: [PATCH 44/71] test: dead client connection must not hold admission capacity against its own identity Deterministic regressions for manaflow-ai/cmux#10874: after an abnormal client death (no clean close), a redial from the SAME TLS-authenticated identity is refused "connection_capacity" until the transport idle timeout releases the dead predecessor's slot (measured 2-9 min in the 20260826 sim timing batch). Three red tests capture the required behavior: same-identity redial admitted promptly at identity capacity, same-identity redial admitted when its own dead predecessor holds the last global slot, and a transport-reported close releasing the slot without waiting for the parked handler. A fourth (passing) test pins the anti-DoS guard: a different identity can never preempt an occupied slot. --- ...ohEndpointServerCapacityReleaseTests.swift | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift new file mode 100644 index 000000000000..76f130b53ed8 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift @@ -0,0 +1,339 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Admission capacity must be tied to connection liveness, not to the idle +/// timer alone. A client that dies abnormally (no clean close) leaves a dead +/// connection whose handler never returns; the same TLS-authenticated identity +/// must still be admitted promptly on redial, and a transport-reported close +/// must release the slot without waiting for the handler to unwind. Strangers +/// can never preempt another identity's capacity. +@Suite +struct CmxIrohEndpointServerCapacityReleaseTests { + private enum ReplacementOutcome: Sendable { + case predecessorClosed(code: UInt64, reason: String) + case redialClosed(code: UInt64, reason: String) + } + + /// Waits for the one deterministic signal that distinguishes the fixed + /// behavior (the dead predecessor is superseded) from the defect (the + /// redial itself is refused and closed). + private static func firstReplacementOutcome( + predecessor: TestIrohConnection, + redial: TestIrohConnection + ) async -> ReplacementOutcome { + await withTaskGroup(of: ReplacementOutcome.self) { group in + group.addTask { + var closes = await predecessor.closeEvents().makeAsyncIterator() + let close = await closes.next() + return .predecessorClosed( + code: close?.code ?? 0, + reason: close?.reason ?? "stream_ended" + ) + } + group.addTask { + var closes = await redial.closeEvents().makeAsyncIterator() + let close = await closes.next() + return .redialClosed( + code: close?.code ?? 0, + reason: close?.reason ?? "stream_ended" + ) + } + let first = await group.next() + group.cancelAll() + return first ?? .redialClosed(code: 0, reason: "no_outcome") + } + } + + private static func makeSupervisor( + endpoint: TestAcceptingIrohEndpoint, + keyByte: UInt8 + ) throws -> CmxIrohEndpointSupervisor { + CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: keyByte, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + } + + @Test + func sameIdentityRedialAfterAbnormalPeerDeathIsAdmittedPromptly() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "1", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "2", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 11) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + // The identity is at its full capacity with one usable session, the + // worst case an abnormal client death leaves behind. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + // The dead peer never closes cleanly: its handler stays parked + // exactly like a session read against a silently dead QUIC peer. + await blocker.wait() + } + let deadPredecessor = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let redial = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(deadPredecessor) + #expect(await recorder.next().identity == clientIdentity) + + // The client process died abnormally; no close arrives. The SAME + // identity redials and must be admitted before any idle timeout. + await endpoint.enqueue(redial) + let outcome = await Self.firstReplacementOutcome( + predecessor: deadPredecessor, + redial: redial + ) + switch outcome { + case let .predecessorClosed(_, reason): + #expect(reason == "superseded_connection") + case let .redialClosed(_, reason): + Issue.record( + "same-identity redial was refused (\(reason)) instead of replacing its dead predecessor" + ) + } + #expect(await recorder.recordedCount() == 2) + #expect(await redial.observedCloseCallCount() == 0) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func deadPredecessorHoldingAGlobalSlotDoesNotRefuseItsOwnIdentitysRedial() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "3", count: 64) + ) + let deadClientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "4", count: 64) + ) + let liveClientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "5", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 12) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + // The global pool is full: one dead session plus one live session + // belonging to another identity. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 2, + maximumConnectionsPerIdentity: 2 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await blocker.wait() + } + let deadPredecessor = TestIrohConnection( + remoteIdentity: deadClientIdentity, + bidirectionalStreams: [] + ) + let liveBystander = TestIrohConnection( + remoteIdentity: liveClientIdentity, + bidirectionalStreams: [] + ) + let redial = TestIrohConnection( + remoteIdentity: deadClientIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(deadPredecessor) + #expect(await recorder.next().identity == deadClientIdentity) + await endpoint.enqueue(liveBystander) + #expect(await recorder.next().identity == liveClientIdentity) + + await endpoint.enqueue(redial) + let outcome = await Self.firstReplacementOutcome( + predecessor: deadPredecessor, + redial: redial + ) + switch outcome { + case let .predecessorClosed(_, reason): + #expect(reason == "superseded_connection") + case let .redialClosed(_, reason): + Issue.record( + "same-identity redial was refused (\(reason)) while its own dead predecessor held the global slot" + ) + } + #expect(await recorder.recordedCount() == 3) + #expect(await redial.observedCloseCallCount() == 0) + // Replacing your own dead predecessor must never disturb another + // identity's live session. + #expect(await liveBystander.observedCloseCallCount() == 0) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func differentIdentityCannotPreemptAnotherIdentitysSlot() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "6", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "7", count: 64) + ) + let strangerIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "8", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 13) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let stranger = TestIrohConnection( + remoteIdentity: strangerIdentity, + bidirectionalStreams: [] + ) + var strangerCloses = await stranger.closeEvents().makeAsyncIterator() + + await server.start() + await endpoint.enqueue(occupant) + #expect(await recorder.next().identity == clientIdentity) + + // A different, fully authenticated identity dials into the full + // server: it must be refused, and the occupant must keep its slot. + await endpoint.enqueue(stranger) + let close = try #require(await strangerCloses.next()) + #expect(close.reason == "connection_capacity") + #expect(await occupant.observedCloseCallCount() == 0) + #expect(await recorder.recordedCount() == 1) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func transportReportedCloseReleasesTheSlotWithoutWaitingForTheHandler() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "9", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ) + let newcomerIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "b", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 14) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + // The handler never unwinds on its own, like a serve loop that has + // not yet observed the failed connection. + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let newcomer = TestIrohConnection( + remoteIdentity: newcomerIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(occupant) + #expect(await recorder.next().identity == clientIdentity) + + // The transport reports the connection terminal (reset, error, or its + // own timeout). The slot must be released on that signal immediately, + // not when the parked handler eventually returns. + await occupant.close(errorCode: 0, reason: "transport_reported_loss") + + await endpoint.enqueue(newcomer) + // Deterministic either way: the fix admits the newcomer (a second + // recorded admission), the defect closes it "connection_capacity". + for _ in 0 ..< 1000 { + let admittedCount = await recorder.recordedCount() + let newcomerCloseCount = await newcomer.observedCloseCallCount() + if admittedCount == 2 || newcomerCloseCount > 0 { break } + await Task.yield() + } + #expect(await recorder.recordedCount() == 2) + #expect(await newcomer.observedCloseCallCount() == 0) + if await recorder.recordedCount() == 2 { + #expect(await recorder.next().identity == newcomerIdentity) + } + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } +} From 4661baec042fbedcf742ae20090f6ca2938e5b2c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:32:44 -0700 Subject: [PATCH 45/71] fix: release admission capacity on connection liveness, not the idle timer Fixes manaflow-ai/cmux#10874. After an abnormal client death the dead connection kept its admission slot until the QUIC idle timeout, so the same identity's redial was refused "connection_capacity" or "connection_identity_capacity" for the 2-9 minutes the timer needed. Two mechanisms, both driven by state the server already owns: 1. Identity-scoped preemption. A TLS-authenticated peer may always run one replacement admission against its own connections: the replacement reservation in registerEstablished no longer requires a never-usable predecessor or a per-identity bound above 1, and markAdmitted admits one connection over the bound when the same identity's predecessors are all usable (a dead peer's session stays "usable" until the transport notices). The predecessor is retired only by markUsable, after the replacement proves itself end to end, so a live session is never torn down for an unproven redial. 2. Transport-signal release. Every active connection gets a close watcher on connection.waitUntilClosed() (the driver's own terminal signal: peer close, transport error, or its timeout). The slot is released and the handler cancelled the moment the signal fires, instead of when the parked handler unwinds. Anti-DoS bounds are preserved: the global pending cap and the one-pending-admission-per-identity cap are unchanged, a replacement reservation exists only while no other admission from that identity is pending, the transient overshoot is at most one connection per identity that already holds a slot, and an identity with no connection of its own can never preempt anyone (pinned by the new differentIdentityCannotPreemptAnotherIdentitysSlot test). fullServerRejectsReconnectCandidateWithoutDisruptingActiveConnection asserted the defect (same-identity reconnect refused at full capacity); it now asserts the replacement semantics and keeps its stranger-refusal half. --- .../CmxIrohEndpointServer.swift | 51 +++++++++++++++---- .../CmxIrohEndpointServerTests+Capacity.swift | 27 ++++------ 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index a20b7cb2f941..e9390a44588b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -62,6 +62,10 @@ public actor CmxIrohEndpointServer { let remoteIdentity: CmxIrohPeerIdentity let connection: any CmxIrohConnection let handlerTask: Task + /// Awaits the transport's own terminal signal for this connection and + /// releases the admission slot the moment it fires, so capacity is + /// tied to connection liveness rather than to the handler unwinding. + let closeWatcherTask: Task let sequence: UInt64 var isUsable: Bool } @@ -171,6 +175,7 @@ public actor CmxIrohEndpointServer { } for connection in connections { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close( errorCode: 1, reason: "server_stopped" @@ -352,13 +357,14 @@ public actor CmxIrohEndpointServer { let activeForIdentity = activeConnections.values.lazy.filter { $0.remoteIdentity == remoteIdentity }.count - let hasReplaceableConnection = activeConnections.values.contains { - $0.remoteIdentity == remoteIdentity && !$0.isUsable - } + // A TLS-authenticated peer may always run one replacement admission + // against its own connections: capacity held by a dead predecessor + // must not refuse the redial until the idle timer notices the death. + // The reservation is identity-scoped (a stranger has nothing of its + // own to replace, so it can never preempt an occupied slot) and is + // bounded to one in flight by the pending-per-identity check above. let canReserveReplacement = pendingForIdentity == 0 - && maximumConnectionsPerIdentity > 1 - && activeForIdentity >= maximumConnectionsPerIdentity - && hasReplaceableConnection + && activeForIdentity > 0 let otherPendingCount = pendingAdmissions.count - 1 guard otherPendingCount + activeConnections.count < maximumConnections || canReserveReplacement else { @@ -415,9 +421,14 @@ public actor CmxIrohEndpointServer { admission.deadlineTask.cancel() // An authenticated replacement may use the one admission reservation - // above the steady identity bound. Reclaim only the oldest connection - // that never became application-usable. A known-good session is retired - // exclusively by markUsable below. + // above the steady identity bound. Reclaim the oldest connection that + // never became application-usable; when only usable predecessors + // exist (a dead peer's session stays "usable" until the idle timer + // notices), admission proceeds one over the bound and markUsable + // below retires the predecessor after the replacement proves itself, + // so a live session is never torn down for an unproven redial and a + // dead one stops pinning capacity. An identity with no connection of + // its own can never exceed the bounds. let activeForIdentity = activeConnections.filter { _, connection in connection.generation == generation && connection.remoteIdentity == remoteIdentity @@ -429,23 +440,29 @@ public actor CmxIrohEndpointServer { .filter { !$0.value.isUsable } .min { $0.value.sequence < $1.value.sequence } : nil - if requiresReplacement, replaced == nil { + if requiresReplacement, replaced == nil, activeForIdentity.isEmpty { return false } if let replaced { activeConnections[replaced.key] = nil } nextConnectionSequence &+= 1 + let closeWatcherTask = Task { [weak self] in + await connection.waitUntilClosed() + await self?.releaseClosedConnection(id) + } activeConnections[id] = ActiveConnection( generation: generation, remoteIdentity: remoteIdentity, connection: connection, handlerTask: admission.handlerTask, + closeWatcherTask: closeWatcherTask, sequence: nextConnectionSequence, isUsable: false ) if let replaced { replaced.value.handlerTask.cancel() + replaced.value.closeWatcherTask.cancel() await replaced.value.connection.close( errorCode: 0, reason: "superseded_unready_connection" @@ -474,6 +491,7 @@ public actor CmxIrohEndpointServer { activeConnections[id] = promoted for connection in superseded.values { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close( errorCode: 0, reason: "superseded_connection" @@ -496,6 +514,7 @@ public actor CmxIrohEndpointServer { guard let active = activeConnections.removeValue(forKey: id) else { return } + active.closeWatcherTask.cancel() if error != nil { await active.connection.close( errorCode: 1, @@ -504,6 +523,17 @@ public actor CmxIrohEndpointServer { } } + /// Releases the admission slot as soon as the transport reports the + /// connection terminal (peer close, transport error, or its own timeout), + /// instead of when the handler serving it eventually unwinds. + private func releaseClosedConnection(_ id: UUID) { + guard let active = activeConnections.removeValue(forKey: id) else { + return + } + active.handlerTask.cancel() + active.closeWatcherTask.cancel() + } + private func timeOutAdmission(_ id: UUID) async { guard let admission = pendingAdmissions.removeValue(forKey: id) else { return @@ -539,6 +569,7 @@ public actor CmxIrohEndpointServer { for id in active.keys { activeConnections[id] = nil } for connection in active.values { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close(errorCode: 1, reason: reason) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift index 85e43b19cfa8..455156c75c01 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift @@ -5,7 +5,7 @@ import Testing extension CmxIrohEndpointServerTests { @Test - func fullServerRejectsReconnectCandidateWithoutDisruptingActiveConnection() async throws { + func fullServerAdmitsOwnIdentityReplacementButRejectsOtherIdentities() async throws { let localIdentity = try CmxIrohPeerIdentity( endpointID: String(repeating: "8", count: 64) ) @@ -52,33 +52,28 @@ extension CmxIrohEndpointServerTests { remoteIdentity: newIdentity, bidirectionalStreams: [] ) - var replacementCloses = await replacement.closeEvents().makeAsyncIterator() + var activeCloses = await active.closeEvents().makeAsyncIterator() var newcomerCloses = await newcomer.closeEvents().makeAsyncIterator() await server.start() await endpoint.enqueue(active) #expect(await started.next().identity == activeIdentity) + // At full capacity a reconnect from the SAME authenticated identity + // replaces its own never-usable predecessor instead of being refused: + // capacity held by a dead connection must not refuse its owner. await endpoint.enqueue(replacement) - for _ in 0 ..< 100 { - let startedCount = await started.recordedCount() - let replacementCloseCount = await replacement.observedCloseCallCount() - guard startedCount == 1, replacementCloseCount == 0 else { break } - await Task.yield() - } - #expect(await started.recordedCount() == 1) - let replacementCloseCount = await replacement.observedCloseCallCount() - #expect(replacementCloseCount == 1) - if replacementCloseCount == 1 { - let replacementClose = try #require(await replacementCloses.next()) - #expect(replacementClose.reason == "connection_capacity") - } - #expect(await active.observedCloseCallCount() == 0) + #expect(await started.next().identity == activeIdentity) + let activeClose = try #require(await activeCloses.next()) + #expect(activeClose.reason == "superseded_unready_connection") + #expect(await replacement.observedCloseCallCount() == 0) + // A DIFFERENT identity can never preempt an occupied slot. await endpoint.enqueue(newcomer) await newcomer.waitUntilClosed() let newcomerClose = try #require(await newcomerCloses.next()) #expect(newcomerClose.reason == "connection_capacity") + #expect(await replacement.observedCloseCallCount() == 0) await connectionLifetime.releaseAll() await server.stop() From de2eede6f0b5eadbae99d24b77f2fb86d4ed8ad0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:09:38 -0700 Subject: [PATCH 46/71] test: host bind with unavailableManagedSelection must honor the debug relay override A Mac host without a verifiable cached policy is configured with the relay-less .unavailableManagedSelection placeholder. start() reads currentEndpointRelayProfile (copied from the configuration in init) before the override-aware resolvedEndpointRelayProfile(), so the DEBUG-only CMUX_IROH_RELAY_URL_OVERRIDE is never consulted at bind and the endpoint binds with zero relays and never dials the test relay. This commit adds the failing test (red) plus the injectable start plumbing that faithfully preserves the bug, and a companion test that pins the no-override baseline: without the override the placeholder still binds empty, preserving the withhold-managed-relays-until- registered ordering from manaflow-ai/cmux#10867. --- .../CmxIrohHostRuntime.swift | 15 +++- .../CmxIrohDebugRelayOverrideTests.swift | 72 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index bac8986bd2ed..da36320f7fa3 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -170,6 +170,17 @@ public actor CmxIrohHostRuntime { /// waits for a usable home relay so the Mac is never /// discoverable-but-undialable. public func start() async throws { + try await start(debugRelayOverride: CmxIrohDebugRelayOverride.activeProfile()) + } + + /// The injectable core of ``start()``. + /// + /// `debugRelayOverride` is the DEBUG-only forced relay, read once from + /// the ``CmxIrohDebugRelayOverride`` funnel by the public entrypoint so + /// tests can inject it deterministically. + func start( + debugRelayOverride: CmxIrohEndpointRelayProfile? + ) async throws { guard lifecyclePhase.allowsStart else { throw CmxIrohHostRuntimeError.alreadyActive } @@ -190,7 +201,9 @@ public actor CmxIrohHostRuntime { do { let endpointRelayProfile = try currentEndpointRelayProfile - ?? configuration.resolvedEndpointRelayProfile() + ?? configuration.resolvedEndpointRelayProfile( + debugOverride: debugRelayOverride + ) currentEndpointRelayProfile = endpointRelayProfile // A verified cached policy proves the broker has acknowledged // this endpoint, so its relay dials pass the relay's allow hook diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift index ca36798eacaa..af77f9c6d67c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift @@ -68,6 +68,78 @@ import Testing #expect(managed.allowedRelayURLs == fixture.managedRelays) } + /// A host without a verifiable cached policy is configured with the + /// relay-less `.unavailableManagedSelection` placeholder. The debug + /// override must still win at bind time, or the endpoint binds with zero + /// relays and can never dial the forced test relay. The override is a + /// custom profile and custom relays are exempt from the + /// withhold-until-registered ordering, so it stays installed at bind. + @Test func overrideWinsOverUnavailableManagedSelectionAtBind() async throws { + let fixture = try HostRuntimeFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start(debugRelayOverride: override) + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile == override) + // Installed at bind: no post-registration relay swap replaces it. + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// Without the override, a host configured with + /// `.unavailableManagedSelection` still binds with zero relays, + /// preserving the withhold-managed-relays-until-registered ordering + /// (manaflow-ai/cmux#10867): no managed relay may be dialable before + /// broker admission. + @Test func withoutOverrideUnavailableManagedSelectionBindsEmpty() async throws { + let fixture = try HostRuntimeFixture() + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start(debugRelayOverride: nil) + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(boundConfigurations.first?.relayProfile.allowedRelayURLs.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + @Test func clientResolutionPrefersOverride() throws { let fixture = try ClientRuntimeTestFixture() let override = try #require( From c0950bc6c6aebc1769b1213c06605754ad5cfc5d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:10:41 -0700 Subject: [PATCH 47/71] fix: apply the debug relay override at host bind time start() now resolves the endpoint relay profile as debugRelayOverride ?? currentEndpointRelayProfile ?? resolved(managed), so the DEBUG-only CMUX_IROH_RELAY_URL_OVERRIDE wins over the stored .unavailableManagedSelection placeholder (and any other configured profile) at the bind itself, matching the two existing application points (nil-profile resolution and replaceRelayProfile) through the same CmxIrohDebugRelayOverride funnel, read once by the public start(). The override is a custom profile and custom relays are exempt from the withhold-managed-relays-until-registered ordering, so applying it here does not reintroduce the pre-registration relay admission race from manaflow-ai/cmux#10867; the no-override placeholder path still binds with zero relays (pinned by test). --- .../CmuxIrohTransport/CmxIrohHostRuntime.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index da36320f7fa3..3729a9e18882 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -200,7 +200,16 @@ public actor CmxIrohHostRuntime { ) do { - let endpointRelayProfile = try currentEndpointRelayProfile + // The debug-only forced relay wins over every stored or + // configured profile, including the relay-less + // `.unavailableManagedSelection` placeholder a host without a + // verifiable cached policy is configured with. The override is a + // custom profile, and custom relays are exempt from the + // withhold-until-registered ordering below, so it stays installed + // at bind and the endpoint dials the test relay immediately + // without reintroducing the pre-registration managed-relay race. + let endpointRelayProfile = try debugRelayOverride + ?? currentEndpointRelayProfile ?? configuration.resolvedEndpointRelayProfile( debugOverride: debugRelayOverride ) From 1823b8787ab56d7c9f683d795b3297c4fd6ae4c2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:33:21 -0700 Subject: [PATCH 48/71] itest: trust the cmux-itest relay-policy signing key in Debug builds The cmux-staging Preview env signs /api/relay/policy with kid cmux-itest-relay-policy-2026-08 (dedicated integration-test key), but the Debug client trust root pinned only the two staging kids, so every policy fetch failed verification with an unknown kid (the 'Unknown failure' seen on the previous preview). Adds an optional third trust-key slot to the Info.plist array, populated only in the Debug configuration with the itest key. The trust-root parser now skips a slot whose two substitution variables are both empty (an unstaged slot expands to empty strings in Release), while any half-filled or invalid record still fails the whole trust root closed, pinned by tests. --- .../CmxIrohRelayPolicyTrustRoot.swift | 12 ++++- .../CmxIrohRelayPolicyTests.swift | 53 +++++++++++++++++++ Resources/Info.plist | 6 +++ cmux.xcodeproj/project.pbxproj | 2 + 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift index 454057efc53a..316dc21a1ea6 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift @@ -36,7 +36,15 @@ public struct CmxIrohRelayPolicyTrustRoot: Equatable, Sendable { } else { return nil } - let keys = records.compactMap { record -> CmxIrohRelayPolicyVerificationKey? in + // An unused rotation slot: a build configuration that does not stage + // a key for a slot leaves both substitution variables undefined, and + // the Info.plist build expands them to empty strings. Only the + // exactly-empty pair is skipped; a partially filled or otherwise + // invalid record still fails the whole trust root closed below. + let activeRecords = records.filter { record in + !(record["keyID"] == "" && record["publicKeyBase64"] == "") + } + let keys = activeRecords.compactMap { record -> CmxIrohRelayPolicyVerificationKey? in guard let keyID = record["keyID"], let publicKey = record["publicKeyBase64"] else { return nil } return try? CmxIrohRelayPolicyVerificationKey( @@ -44,7 +52,7 @@ public struct CmxIrohRelayPolicyTrustRoot: Equatable, Sendable { rawPublicKeyBase64: publicKey ) } - guard keys.count == records.count else { return nil } + guard keys.count == activeRecords.count else { return nil } return try? CmxIrohRelayPolicyTrustRoot(keys: keys) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift index 491d556952e9..a6181b9ac576 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift @@ -25,6 +25,59 @@ struct CmxIrohRelayPolicyTests { #expect(trustRoot?.keys.map(\.keyID) == ["policy-current", "policy-next"]) } + /// A build configuration that does not stage a key for an optional plist + /// slot leaves both substitution variables undefined, which the Info.plist + /// build expands to empty strings. The exactly-empty record is an unused + /// slot, not a malformed trust root. + @Test + func appPinnedTrustRootSkipsUnusedEmptyRotationSlot() throws { + let current = Curve25519.Signing.PrivateKey() + let next = Curve25519.Signing.PrivateKey() + let trustRoot = CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + [ + "keyID": "policy-current", + "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), + ], + [ + "keyID": "policy-next", + "publicKeyBase64": next.publicKey.rawRepresentation.base64EncodedString(), + ], + ["keyID": "", "publicKeyBase64": ""], + ], + ]) + + #expect(trustRoot?.keys.map(\.keyID) == ["policy-current", "policy-next"]) + } + + /// A half-filled slot (empty key ID with a non-empty key, or the reverse) + /// is a misconfiguration, not an unused slot, and must fail closed. + @Test + func appPinnedTrustRootFailsClosedForHalfFilledRotationSlot() throws { + let current = Curve25519.Signing.PrivateKey() + let orphanKey = Curve25519.Signing.PrivateKey() + let currentRecord = [ + "keyID": "policy-current", + "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), + ] + #expect(CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + currentRecord, + [ + "keyID": "", + "publicKeyBase64": orphanKey.publicKey.rawRepresentation + .base64EncodedString(), + ], + ], + ]) == nil) + #expect(CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + currentRecord, + ["keyID": "policy-extra", "publicKeyBase64": ""], + ], + ]) == nil) + } + @Test func appPinnedTrustRootFailsClosedForPartialRotationConfiguration() throws { let current = Curve25519.Signing.PrivateKey() diff --git a/Resources/Info.plist b/Resources/Info.plist index f8d344d1be44..8b9d04f1e9b2 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -268,6 +268,12 @@ publicKeyBase64 $(CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64) + + keyID + $(CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID) + publicKeyBase64 + $(CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64) + diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c005a781633b..7c3b23ab46d4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -11992,6 +11992,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; CMUX_AUTH_CALLBACK_SCHEME = "cmux-dev"; + CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID = "cmux-itest-relay-policy-2026-08"; + CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64 = "U3i4OPs1uJB00dClzigfPGxi0KCEelvAOqAoKU35fxo="; CMUX_IROH_RELAY_POLICY_KEY_ID = "cmux-staging-relay-policy-2026-07"; CMUX_IROH_RELAY_POLICY_NEXT_KEY_ID = "cmux-staging-relay-policy-2026-08"; CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64 = "KnOZ6gKmH05Mrfan2tXgwRygBKxcSUue4bp34udiQFA="; From 8700ea6821b372e2f747a7cbf199adee287aa2fa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:42:40 -0700 Subject: [PATCH 49/71] itest: DEBUG-only deployment-protection bypass header for broker previews Vercel preview deployments of the broker sit behind deployment protection; the relay carries the bypass token in its configured allow/report URLs, but the app's trust-broker client had no way to pass it, so a tagged test build could not register, fetch policy, or discover against a protected preview at all. CmxIrohDebugBrokerBypassHeader (CMUX_IROH_BROKER_PROTECTION_BYPASS, env first then UserDefaults, DEBUG builds only, mirroring CmxIrohDebugRelayOverride) now rides every broker request as x-vercel-protection-bypass through the client's single request funnel. Release builds compile it away. Pinned by tests: header present when active, absent when inactive, unusable values rejected. --- .../CmxIrohDebugBrokerBypassHeader.swift | 54 +++++++++++++ .../CmxIrohTrustBrokerClient.swift | 8 ++ .../CmxIrohTrustBrokerClientTests.swift | 75 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift new file mode 100644 index 000000000000..33e6dc4e4170 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift @@ -0,0 +1,54 @@ +public import Foundation + +/// A debug-build-only deployment-protection bypass for tagged test builds. +/// +/// Vercel preview deployments of the broker sit behind deployment +/// protection, which the app's broker client cannot pass. When active, +/// every trust-broker request carries the platform's +/// `x-vercel-protection-bypass` header so a tagged test build can talk to +/// a protected preview directly. Release builds compile the bypass away +/// entirely, and the value never applies to relay traffic (relays carry +/// their own bypass in their configured URLs). +public enum CmxIrohDebugBrokerBypassHeader { + /// The environment variable consulted first, and the `UserDefaults` + /// key consulted second, mirroring ``CmxIrohDebugRelayOverride``. + public static let key = "CMUX_IROH_BROKER_PROTECTION_BYPASS" + + /// The deployment-platform header that carries the bypass value. + static let headerField = "x-vercel-protection-bypass" + + /// The process-wide bypass value, or nil when inactive. + static func activeValue() -> String? { + #if DEBUG + value(rawValue: rawValue()) + #else + nil + #endif + } + + #if DEBUG + /// Reads the raw bypass value, preferring the process environment. + static func rawValue( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> String? { + if let fromEnvironment = environment[key], !fromEnvironment.isEmpty { + return fromEnvironment + } + return defaults.string(forKey: key) + } + + /// Accepts only a bounded single-line token safe to place in a header. + static func value(rawValue: String?) -> String? { + guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.utf8.count <= 128, + value.utf8.allSatisfy({ byte in + byte > 0x20 && byte < 0x7F + }) else { + return nil + } + return value + } + #endif +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index af52f63d8c14..a9999f351931 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -824,6 +824,14 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = requestTimeout + // A debug-only deployment-protection bypass lets a tagged test build + // reach a protected broker preview; nil in release builds. + if let bypass = CmxIrohDebugBrokerBypassHeader.activeValue() { + request.setValue( + bypass, + forHTTPHeaderField: CmxIrohDebugBrokerBypassHeader.headerField + ) + } request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") request.setValue(clientNamespace, forHTTPHeaderField: "X-Cmux-App-Namespace") diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift index 541e6b4bcdd0..ed268f43971a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift @@ -49,6 +49,81 @@ struct CmxIrohTrustBrokerClientTests { #expect(object["identityGeneration"] as? Int == 1) } + /// The DEBUG-only deployment-protection bypass rides every broker + /// request so a tagged test build can reach a protected preview. + @Test + func brokerRequestsCarryDebugProtectionBypassHeaderWhenActive() async throws { + UserDefaults.standard.set( + "test-bypass-token", + forKey: CmxIrohDebugBrokerBypassHeader.key + ) + defer { + UserDefaults.standard.removeObject( + forKey: CmxIrohDebugBrokerBypassHeader.key + ) + } + let transport = RecordingBrokerTransport(responses: [ + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + ]) + let client = try makeClient(transport: transport) + let payload = try registrationPayload() + let signer = try registrationSigner() + _ = try await client.issueChallenge( + try signer.prepare(payload: payload).challengeRequest + ) + + let captured = try #require(await transport.requests().first) + #expect( + captured.value(forHTTPHeaderField: "x-vercel-protection-bypass") + == "test-bypass-token" + ) + } + + @Test + func brokerRequestsOmitProtectionBypassHeaderWhenInactive() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + ]) + let client = try makeClient(transport: transport) + let payload = try registrationPayload() + let signer = try registrationSigner() + _ = try await client.issueChallenge( + try signer.prepare(payload: payload).challengeRequest + ) + + let captured = try #require(await transport.requests().first) + #expect( + captured.value(forHTTPHeaderField: "x-vercel-protection-bypass") == nil + ) + } + + /// Only bounded single-line tokens are usable as a bypass header value. + @Test(arguments: [ + nil, + "", + " ", + "two words", + "line\nbreak", + String(repeating: "a", count: 129), + ] as [String?]) + func bypassRejectsUnusableValues(_ raw: String?) { + #expect(CmxIrohDebugBrokerBypassHeader.value(rawValue: raw) == nil) + } + + @Test + func bypassTrimsAndAcceptsBoundedToken() { + #expect( + CmxIrohDebugBrokerBypassHeader.value(rawValue: " V4wToken123 ") + == "V4wToken123" + ) + } + @Test func combinedRegistrationUsesOneGateForBothHTTPLegs() async throws { let transport = RecordingBrokerTransport(responses: [ From f30713e9c5e931e804b9b00c3bebdb0b48efbda4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:51:13 -0700 Subject: [PATCH 50/71] test: reproduce three iroh admission/publication ownership gaps Three red tests for the P1s from the #10880 and #10858 reviews: 1. capacityFilledDuringAdmissionClosesTheUnplaceableConnection: capacity fills between registerEstablished and the admission marker; markAdmitted removes the pending entry, returns false, and orphans the established QUIC connection outside every capacity table. 2. timedOutHandshakeKeepsItsSlotUntilTheAttemptResolves: the admission deadline releases the slot while the consumed native handshake (not abortable by task cancellation) is still live, letting a remote peer mint more handshake work than maximumPendingAdmissions permits. 3. a not-ready refresh re-arms the ready gate: a refresh round that defers the first publication on relay readiness consumes the ready gate without re-arming it; with a stale binding no renewal deadline exists, so a relay that silently becomes usable again never publishes the binding. --- ...ohEndpointServerCapacityReleaseTests.swift | 111 ++++++++++++++++++ ...hEndpointServerStalledHandshakeTests.swift | 96 ++++++++++++++- ...ohHostRuntimeStartupPublicationTests.swift | 107 +++++++++++++++++ 3 files changed, 313 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift index 76f130b53ed8..24a3cc0560a9 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift @@ -60,6 +60,28 @@ struct CmxIrohEndpointServerCapacityReleaseTests { ) } + /// Records each admission-marker result so tests can await the exact + /// authenticate step of one connection deterministically. + private actor AdmissionMarkerOutcomeRecorder { + typealias Outcome = (identity: CmxIrohPeerIdentity, admitted: Bool) + private var outcomes: [Outcome] = [] + private var waiters: [CheckedContinuation] = [] + + func record(identity: CmxIrohPeerIdentity, admitted: Bool) { + let outcome = (identity, admitted) + if waiters.isEmpty { + outcomes.append(outcome) + } else { + waiters.removeFirst().resume(returning: outcome) + } + } + + func next() async -> Outcome { + if !outcomes.isEmpty { return outcomes.removeFirst() } + return await withCheckedContinuation { waiters.append($0) } + } + } + @Test func sameIdentityRedialAfterAbnormalPeerDeathIsAdmittedPromptly() async throws { let localIdentity = try CmxIrohPeerIdentity( @@ -265,6 +287,95 @@ struct CmxIrohEndpointServerCapacityReleaseTests { await supervisor.deactivate() } + @Test + func capacityFilledDuringAdmissionClosesTheUnplaceableConnection() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "e", count: 64) + ) + let occupantIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "f", count: 64) + ) + let strandedIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "0", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 15) + _ = try await supervisor.activate() + let strandedGate = EndpointServerHandlerBlocker() + let blocker = EndpointServerHandlerBlocker() + let established = EndpointServerRecorder() + let outcomes = AdmissionMarkerOutcomeRecorder() + // Global capacity 2. The stranded identity passes registerEstablished + // while one slot is still free, then both slots fill before its + // handler calls the admission marker. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 2, + maximumConnectionsPerIdentity: 2 + ) { connection, generation, admission in + let identity = await connection.remoteIdentity() + await established.record(identity: identity, generation: generation) + if identity == strandedIdentity { + await strandedGate.wait() + } + await outcomes.record( + identity: identity, + admitted: await admission() + ) + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: occupantIdentity, + bidirectionalStreams: [] + ) + let stranded = TestIrohConnection( + remoteIdentity: strandedIdentity, + bidirectionalStreams: [] + ) + let occupantRedial = TestIrohConnection( + remoteIdentity: occupantIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(occupant) + _ = await established.next() + _ = await outcomes.next() + + // The stranded connection completes its handshake and passes the + // registerEstablished capacity check (one global slot is free), then + // parks before authenticating. + await endpoint.enqueue(stranded) + #expect(await established.next().identity == strandedIdentity) + + // The occupant's same-identity redial reserves the replacement slot + // and is admitted, filling global capacity while the stranded + // admission is still parked. + await endpoint.enqueue(occupantRedial) + #expect(await established.next().identity == occupantIdentity) + let redialOutcome = await outcomes.next() + #expect(redialOutcome.admitted) + + // The stranded admission now finds capacity full with nothing of its + // own to replace. Refusal is correct, but the server must close the + // connection it still owns instead of orphaning it outside every + // capacity table. + await strandedGate.releaseAll() + let strandedOutcome = await outcomes.next() + #expect(strandedOutcome.identity == strandedIdentity) + #expect(!strandedOutcome.admitted) + #expect(await stranded.observedCloseCallCount() == 1) + var strandedCloses = await stranded.closeEvents().makeAsyncIterator() + if await stranded.observedCloseCallCount() == 1 { + let close = await strandedCloses.next() + #expect(close?.reason == "connection_capacity") + } + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + @Test func transportReportedCloseReleasesTheSlotWithoutWaitingForTheHandler() async throws { let localIdentity = try CmxIrohPeerIdentity( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift index c6d80ee58911..fc412a49978c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift @@ -69,6 +69,80 @@ struct CmxIrohEndpointServerStalledHandshakeTests { await server.stop() await supervisor.deactivate() } + + /// A timed-out handshake cannot be aborted once the native attempt is + /// consumed (task cancellation does not reach the driver), so its + /// admission slot must stay occupied until the attempt itself resolves. + /// Releasing the slot at the admission deadline lets a remote peer mint + /// more live handshake work than `maximumPendingAdmissions` permits. + @Test + func timedOutHandshakeKeepsItsSlotUntilTheAttemptResolves() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "e", count: 64) + ) + let healthyIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "f", count: 64) + ) + let endpoint = StalledHandshakeIrohEndpoint(identity: localIdentity) + let supervisor = CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: 10, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + _ = try await supervisor.activate() + let clock = EndpointServerManualClock() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumPendingAdmissions: 1, + admissionTimeout: 15, + clock: clock + ) { connection, generation, _ in + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await connection.close(errorCode: 0, reason: "test_complete") + } + + await server.start() + await endpoint.enqueueStalledHandshake() + await clock.waitUntilSleeping() + await clock.fire() + // The admission deadline fired against a handshake that never + // resolves on cancellation. The dialer is refused fast... + await endpoint.waitForAbandonCount(1) + + // ...but the slot must still be occupied: the next attempt has to be + // abandoned by the accept loop, not admitted alongside the live + // stalled handshake. Deterministic either way: the fix abandons the + // attempt ("admission_abandoned"), the defect admits it and the + // handler closes it "test_complete". + let overCapacity = TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + var overCapacityCloses = await overCapacity.closeEvents().makeAsyncIterator() + await endpoint.enqueue(overCapacity) + let close = try #require(await overCapacityCloses.next()) + #expect(close.reason == "admission_abandoned") + + // The driver finally bounds the stalled attempt. Its resolution, not + // the earlier deadline, releases the slot. + await endpoint.releaseStalledHandshakes() + let admittedAfterResolution = TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + await endpoint.enqueue(admittedAfterResolution) + #expect(await recorder.next().identity == healthyIdentity) + + await server.stop() + await supervisor.deactivate() + } } /// An endpoint whose accept queue can contain a connection attempt that stops @@ -84,6 +158,8 @@ private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { private var acceptEvents: [AcceptEvent] = [] private var acceptWaiters: [UUID: CheckedContinuation] = [:] private var stallWaiters: [CheckedContinuation] = [] + private var abandonCount = 0 + private var abandonWaiters: [(minimum: Int, continuation: CheckedContinuation)] = [] private let health: AsyncStream private let healthContinuation: AsyncStream.Continuation @@ -129,6 +205,20 @@ private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { await withCheckedContinuation { stallWaiters.append($0) } } + func recordAbandon() { + abandonCount += 1 + let ready = abandonWaiters.filter { abandonCount >= $0.minimum } + abandonWaiters.removeAll { abandonCount >= $0.minimum } + for waiter in ready { waiter.continuation.resume() } + } + + func waitForAbandonCount(_ minimum: Int) async { + guard abandonCount < minimum else { return } + await withCheckedContinuation { + abandonWaiters.append((minimum, $0)) + } + } + func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } @@ -171,5 +261,9 @@ private struct StalledIncomingConnection: CmxIrohIncomingConnection { throw TestIrohTransportError.unsupported } - func abandon() async {} + func abandon() async { + // Refusing a consumed attempt cannot stop the in-flight handshake, + // exactly like `Incoming.refuse()` after `accept()`. + await endpoint.recordAbandon() + } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift index 5a9ea91c7b9d..98ceefc1e1c4 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -13,6 +13,21 @@ extension CmxIrohHostRuntime { await task.value } } + + /// Awaits only the ready gate task, without draining refresh rounds, so a + /// test can observe the gate handing its deferred publication to an + /// in-flight round that is deliberately parked at the broker. + func waitForInitialPublicationGateForTesting() async { + await initialPublicationTask?.value + } + + /// Awaits every scheduled refresh round, including coalesced replays, + /// without touching the ready gate. + func waitForRegistrationRefreshRoundsForTesting() async { + while let task = registrationRefreshTask { + await task.value + } + } } extension TestIrohEndpoint { @@ -340,6 +355,98 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } + /// A refresh round that observes the deferred first publication while the + /// relay is unusable must re-arm the ready gate. The gate consumes itself + /// by handing the publication to an in-flight round; when that round then + /// finds readiness lost (relay profile rotation un-latches it without a + /// health event) and the binding is too stale to arm a renewal deadline, + /// no owner remains: a relay that silently becomes usable again (a + /// reconnect iroh does not re-announce) would never publish the binding. + @Test("a not-ready refresh re-arms the ready gate for the deferred first publication") + func notReadyRefreshReArmsTheReadyGateForTheDeferredFirstPublication() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + // Stale enough that neither binding freshness nor hint expiry can arm + // a registration renewal deadline. + let staleBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + lastSeenAt: now.addingTimeInterval(-24 * 60 * 60) + ) + let staleDiscovery = try HostRuntimeFixture.discovery( + binding: staleBinding, + relays: HostRuntimeFixture.relayURLs + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let registrationGate = HostRuntimeRegistrationGate() + let broker = TestIrohHostBroker( + registrationBinding: staleBinding, + discovery: staleDiscovery, + subsequentRegistrationHook: { await registrationGate.waitOnce() } + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + + try await runtime.start() + #expect(await bindings.count() == 0) + // The activation ready gate times out its first readiness wait and + // parks in its backoff on the injected clock. + await clock.waitUntilSleepCount(1) + + // The home relay comes up. The network-change refresh starts and + // parks at the broker; the woken gate hands its deferred publication + // to that in-flight round and consumes itself. + await endpoint.emit(.online) + #expect(await broker.waitForRegistrationCount(2, timeout: .seconds(5))) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + await runtime.waitForInitialPublicationGateForTesting() + + // A relay profile rotation un-latches readiness. The endpoint address + // is unchanged, so no network-change round is published: the parked + // round is the last owner of the deferred first publication. + try await runtime.replaceRelayProfile( + fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) + ) + + // The parked round resumes and correctly refuses to publish while the + // relay is unusable. + await registrationGate.open() + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await bindings.count() == 0) + + // The relay becomes usable again with no health event. Only the + // re-armed ready gate can observe this; drive its readiness backoff + // on the injected clock until the publication lands. + await endpoint.setPathHints([try HostRuntimeFixture.usableRelayHint()]) + var advancedDeadlineCount = clock.observedSleepDeadlines().count + var published = false + for _ in 0 ..< 10 { + if await bindings.waitForCount(1, timeout: .milliseconds(500)) { + published = true + break + } + let deadlines = clock.observedSleepDeadlines() + if deadlines.count > advancedDeadlineCount, let last = deadlines.last { + advancedDeadlineCount = deadlines.count + clock.advance(to: last) + } + } + #expect(published) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + /// A cache-first activation whose live reconcile adopts a server-side /// replacement binding while the home relay is still unusable must move /// the ready gate onto the adopted identity: the stale gate armed with From 616fc6982969d173b955f9f9f38ac7fb7dad5c6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:54:04 -0700 Subject: [PATCH 51/71] fix: close orphaned admissions, hold consumed-handshake slots, re-arm the ready gate Three ownership fixes for the reviews' P1s: 1. CmxIrohEndpointServer.markAdmitted: when capacity filled between registerEstablished and the admission marker and the identity has no predecessor to replace, close the connection (connection_capacity) before returning false. The pending entry is already removed at that point, so nothing else owns or closes the established connection. 2. CmxIrohEndpointServer.timeOutAdmission: a deadline that fires while the handshake is still in flight refuses the dialer but keeps the admission slot occupied (abandoned flag) until establish() resolves, because a consumed Incoming cannot be refused and the IrohLib bindings do not propagate task cancellation into the driver (uniffiRustCallAsync has no cancellation handler). registerEstablished/failEstablishment release the slot at resolution; the driver's handshake/idle timeout bounds it. Capacity is now honest: at most maximumPendingAdmissions native handshakes ever run. 3. CmxIrohHostRuntime: while the first publication is pending, a relay-readiness owner always exists. The not-ready refresh branch re-arms scheduleInitialPublication (it may have consumed the gate that scheduled it, readiness can return without a network-change event, and the renewal deadline is cadence-bound or nil for a stale binding), and the relay-required activation branch arms the gate alongside its retry loop. --- .../CmxIrohEndpointServer.swift | 43 ++++++++++++++++--- .../CmxIrohHostRuntime+PolicyRefresh.swift | 12 +++++- .../CmxIrohHostRuntime.swift | 6 +++ .../CmxIrohLibIncomingConnection.swift | 11 +++-- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index e9390a44588b..88734f48fd2f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -49,6 +49,11 @@ public actor CmxIrohEndpointServer { /// passed. `nil` while the attempt is still establishing. var remoteIdentity: CmxIrohPeerIdentity? var connection: (any CmxIrohConnection)? + /// Set when the admission deadline fired while the handshake was + /// still in flight. The slot stays occupied until the attempt + /// resolves; whichever of `registerEstablished`/`failEstablishment` + /// observes the resolution releases it. + var abandoned = false } /// The endpoint's accept queue ended while its generation is still @@ -336,6 +341,13 @@ public actor CmxIrohEndpointServer { connection: any CmxIrohConnection ) async -> Bool { let remoteIdentity = await connection.remoteIdentity() + if let admission = pendingAdmissions[id], admission.abandoned { + // The admission deadline fired while this handshake was in + // flight; its resolution releases the slot it kept occupied. + pendingAdmissions[id] = nil + await connection.close(errorCode: 1, reason: "admission_timeout") + return false + } guard var admission = pendingAdmissions[id], admission.generation == currentGeneration, admission.connection == nil else { @@ -406,7 +418,11 @@ public actor CmxIrohEndpointServer { return } admission.deadlineTask.cancel() - await admission.incoming.abandon() + // An abandoned attempt was already refused at its deadline; removing + // the entry above is what releases the slot its resolution freed. + if !admission.abandoned { + await admission.incoming.abandon() + } } private func markAdmitted(_ id: UUID, generation: UInt64) async -> Bool { @@ -441,6 +457,13 @@ public actor CmxIrohEndpointServer { .min { $0.value.sequence < $1.value.sequence } : nil if requiresReplacement, replaced == nil, activeForIdentity.isEmpty { + // Capacity filled between registerEstablished and this marker and + // the identity has nothing of its own to replace. The pending + // entry is already removed, so the server still owns the + // established connection here and must close it before disowning + // the admission; returning without closing would leave a live + // QUIC connection outside every capacity table. + await connection.close(errorCode: 1, reason: "connection_capacity") return false } if let replaced { @@ -535,15 +558,23 @@ public actor CmxIrohEndpointServer { } private func timeOutAdmission(_ id: UUID) async { - guard let admission = pendingAdmissions.removeValue(forKey: id) else { - return - } + guard var admission = pendingAdmissions[id] else { return } admission.handlerTask.cancel() if let connection = admission.connection { + pendingAdmissions[id] = nil await connection.close(errorCode: 1, reason: "admission_timeout") - } else { - await admission.incoming.abandon() + return } + // The handshake is still in flight, and cancellation does not reach + // the native attempt: a consumed `Incoming` cannot be refused, and + // the bindings do not propagate task cancellation into the driver. + // Refuse the dialer fast, but keep the slot occupied until + // establish() itself resolves (the driver's own handshake/idle + // timeout bounds that), so a remote peer cannot mint more live + // handshake work than `maximumPendingAdmissions` permits. + admission.abandoned = true + pendingAdmissions[id] = admission + await admission.incoming.abandon() } private func cancelConnections( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 9326eb395de5..bd35fa2eb462 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -637,14 +637,22 @@ extension CmxIrohHostRuntime { // The first publication of this lifecycle stays gated on // a verified usable relay path; the authenticated // reconcile above already applied admission policy, - // binding adoption, and renewal scheduling. The ready - // gate runs the publishing round once the relay works. + // binding adoption, and renewal scheduling. registrationRefreshFailureCount = 0 completedSuccessfully = true scheduleRegistrationRenewal( binding: registration.binding, revision: revision ) + // Re-arm the ready gate: this round may be the one the + // gate handed its deferred publication to (consuming + // itself), and readiness can return without any + // network-change event (a relay reconnect iroh does not + // re-announce). While the first publication is pending, a + // relay-readiness owner must always exist; the renewal + // deadline above is cadence-bound and can be nil for a + // stale binding. + scheduleInitialPublication(revision: revision) return } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index 3729a9e18882..b72bfd71fe40 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -417,6 +417,12 @@ public actor CmxIrohHostRuntime { revision: revision, retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds ) + // The retry loop owns the next broker round, but only the + // ready gate observes a relay that becomes usable without + // a network-change event. Arm it here too so a pending + // first publication always has a relay-readiness owner; + // it defers to the armed retry round when one exists. + scheduleInitialPublication(revision: revision) } else { allowsReplacedBindingAdoption = cachedStartPolicy != nil if let retryAfterSeconds = publishedPolicy diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift index 99f0fb608b8c..85d937c1d5cc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift @@ -5,10 +5,13 @@ import IrohLib /// /// ``establish()`` performs the server-side handshake (`Incoming.accept`, /// ALPN validation, handshake completion) that used to run inline in the -/// endpoint's accept path. The underlying driver bounds a peer that stops -/// making progress with its own handshake/idle timeout, and dropping the -/// consumed attempt aborts it, so a stalled attempt can only ever cost its -/// own admission slot. +/// endpoint's accept path. Once `accept()` consumes the `Incoming`, the +/// attempt cannot be aborted from Swift: `refuse()` reports "already +/// consumed" and the bindings do not propagate task cancellation into the +/// driver. The attempt therefore resolves only when the driver's own +/// handshake/idle timeout bounds it, and ``CmxIrohEndpointServer`` keeps +/// the admission slot occupied until that resolution, so a stalled attempt +/// can only ever cost its own admission slot. struct CmxIrohLibIncomingConnection: CmxIrohIncomingConnection { let incoming: Incoming let alpns: Set From e841b8ab1578f098ad9234805d3afccf75b588c5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:16:35 -0700 Subject: [PATCH 52/71] itest: trust the cmux-itest relay-policy signing key in iOS Debug builds Commit 1823b8787a added the optional third trust-key slot only to the macOS target; the iOS target's Info.plist and Debug build settings did not carry it, so an iOS Debug build could not verify the preview's policy signed with kid cmux-itest-relay-policy-2026-08. Mirrors the macOS change: a third slot in CMUXIrohRelayPolicyTrustKeys expanded from CMUX_IROH_RELAY_POLICY_EXTRA_* variables, populated only in the iOS Debug configuration. Release leaves both variables undefined and the shared trust-root parser (already fixed and tested in 1823b8787a) skips the exactly-empty slot. --- ios/Config/Info.plist | 6 ++++++ ios/cmux-ios.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 8 insertions(+) diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index ace55487c478..e845b331081a 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -136,6 +136,12 @@ publicKeyBase64 $(CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64) + + keyID + $(CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID) + publicKeyBase64 + $(CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64) + diff --git a/ios/cmux-ios.xcodeproj/project.pbxproj b/ios/cmux-ios.xcodeproj/project.pbxproj index 373bc8f5fdc7..0c96b291a794 100644 --- a/ios/cmux-ios.xcodeproj/project.pbxproj +++ b/ios/cmux-ios.xcodeproj/project.pbxproj @@ -474,6 +474,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID = "cmux-itest-relay-policy-2026-08"; + CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64 = "U3i4OPs1uJB00dClzigfPGxi0KCEelvAOqAoKU35fxo="; CMUX_IROH_RELAY_POLICY_KEY_ID = "cmux-staging-relay-policy-2026-07"; CMUX_IROH_RELAY_POLICY_NEXT_KEY_ID = "cmux-staging-relay-policy-2026-08"; CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64 = "KnOZ6gKmH05Mrfan2tXgwRygBKxcSUue4bp34udiQFA="; From 3689a276f95cbd1c59cddea99e778a9c4362aee8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:02:56 -0700 Subject: [PATCH 53/71] test: credential-less control-stream admission wire contract (red) An already-paired phone must be able to open its control stream with no in-band admission credential. These tests pin the wire contract: a control CmxIrohStreamHeader with credential nil is valid, encodes with credential code 0, and decodes back to credential nil. They fail until allowlist admission lands. --- .../CmxIrohPairedPeerWireTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift new file mode 100644 index 000000000000..e79c2edc8c16 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Wire-level contract for allowlist admission: an already-paired phone opens +/// its control stream with NO admission credential, and the header remains +/// representable and round-trippable in that credential-less form. +@Suite +struct CmxIrohPairedPeerWireTests { + @Test + func controlHeaderWithoutCredentialIsValid() throws { + let header = try CmxIrohStreamHeader(lane: .control, credential: nil) + #expect(header.credential == nil) + #expect(header.lane == .control) + } + + @Test + func codecRoundTripsCredentiallessControlHeader() throws { + let codec = try CmxIrohStreamHeaderCodec() + let encoded = try codec.encode( + try CmxIrohStreamHeader(lane: .control, credential: nil) + ) + let decoded = try codec.decodePrefix(encoded) + #expect(decoded.header.lane == .control) + #expect(decoded.header.credential == nil) + #expect(decoded.consumedByteCount == encoded.count) + } + + @Test + func codecDecodesCredentialCodeZeroControlFrame() throws { + let codec = try CmxIrohStreamHeaderCodec() + var frame = Data("CMUXIRH1".utf8) + frame.append(1) // version + frame.append(1) // lane: control + frame.append(0) // flags + frame.append(0) // credential code: none (allowlist admission) + frame.append(contentsOf: [0, 0, 0, 0] as [UInt8]) // payload byte count + let decoded = try codec.decodePrefix(frame) + #expect(decoded.header.lane == .control) + #expect(decoded.header.credential == nil) + } +} From 20902931fafefbc82bce6a5295e20fac5a4d95e8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:05:42 -0700 Subject: [PATCH 54/71] auth: regression tests for expiry-scheduled token freshness (red) A live host refreshed its Stack access token every ~78s forever (cmux#10897): isTokenFreshEnough treats any token issued more than 75s ago as stale, so the first token request after 75s of token age forces a network refresh even though the token lives 3600s. PresenceHeartbeatClient requests tokens every 15s, producing the observed cadence. This commit only adds the deterministic tests (injected now) plus the non-behavioral clock plumbing, so CI shows them red before the fix. --- .../Sources/StackAuth/APIClient.swift | 11 +-- .../StackAuthTests/TokenRefreshTests.swift | 74 ++++++++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index c4f24eb1f3b7..3296392fb0f0 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -75,15 +75,16 @@ func isTokenExpired(_ accessToken: String?) -> Bool { /// Check if token should NOT be refreshed (is "fresh enough"). /// Returns TRUE if token expires in > 20 seconds AND was issued < 75 seconds ago. -func isTokenFreshEnough(_ accessToken: String?) -> Bool { +/// `now` is injected for deterministic tests; production callers use the +/// default wall clock. +func isTokenFreshEnough(_ accessToken: String?, now: Date = Date()) -> Bool { guard let token = accessToken, let payload = decodeJWTPayload(token) else { return false // Can't decode, should refresh } - - let expiresInMoreThan20s = payload.expiresInMillis > 20_000 - let issuedLessThan75sAgo = payload.issuedMillisAgo < 75_000 - + let nowMillis = now.timeIntervalSince1970 * 1000 + let expiresInMoreThan20s = payload.exp.map { ($0 * 1000) - nowMillis > 20_000 } ?? true + let issuedLessThan75sAgo = payload.iat.map { nowMillis - ($0 * 1000) < 75_000 } ?? true return expiresInMoreThan20s && issuedLessThan75sAgo } diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift index 2010eb4439e3..ad9935bfe067 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift @@ -191,7 +191,79 @@ struct TokenRefreshAlgorithmTests { func invalidTokenIsNotFresh() { #expect(isTokenFreshEnough("not-a-jwt") == false) } - + + // MARK: - Expiry-Scheduled Freshness (cmux#10897) + + private func jwt(iat: Int?, exp: Int?) -> String { + var claims: [String] = ["\"sub\":\"test\""] + if let iat { claims.append("\"iat\":\(iat)") } + if let exp { claims.append("\"exp\":\(exp)") } + let payloadJson = "{\(claims.joined(separator: ","))}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\(payloadBase64).signature" + } + + /// Regression for the ~78s steady-state refresh loop: a 1h token whose + /// issued-age exceeded the old 75s heuristic must stay fresh while it is + /// still far from its real expiry. All times are fixed and the clock is + /// injected, so the test is deterministic. + @Test("1h token older than 75s stays fresh until near real expiry") + func hourTokenOlderThan75sStaysFresh() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // Issued 100s ago (>75s), expires in 3500s: fresh under expiry + // scheduling, stale under the removed issued-age heuristic. + let token = jwt(iat: epoch - 100, exp: epoch + 3500) + #expect(isTokenFreshEnough(token, now: now) == true) + } + + @Test("1h token refreshes inside the pre-expiry margin") + func hourTokenRefreshesInsideMargin() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 299s remain on a 3600s-lifetime token: inside the 300s margin. + let token = jwt(iat: epoch - 3301, exp: epoch + 299) + #expect(isTokenFreshEnough(token, now: now) == false) + // 301s remain: just outside the margin. + let fresh = jwt(iat: epoch - 3299, exp: epoch + 301) + #expect(isTokenFreshEnough(fresh, now: now) == true) + } + + @Test("Short-lived token margin clamps to half its lifetime") + func shortLivedTokenMarginClampsToHalfLifetime() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 90s lifetime clamps the margin to 45s: 50s remaining is fresh, + // 40s remaining is not. A fixed 300s margin would refresh a 90s + // token on every request. + let fresh = jwt(iat: epoch - 40, exp: epoch + 50) + #expect(isTokenFreshEnough(fresh, now: now) == true) + let stale = jwt(iat: epoch - 50, exp: epoch + 40) + #expect(isTokenFreshEnough(stale, now: now) == false) + } + + @Test("Margin floors at 20s for very short lifetimes") + func marginFloorsAt20Seconds() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 30s lifetime: half-lifetime would be 15s, floor keeps it at 20s. + let stale = jwt(iat: epoch - 12, exp: epoch + 18) + #expect(isTokenFreshEnough(stale, now: now) == false) + let fresh = jwt(iat: epoch - 9, exp: epoch + 21) + #expect(isTokenFreshEnough(fresh, now: now) == true) + } + + @Test("Token without exp claim never triggers a refresh") + func tokenWithoutExpIsAlwaysFresh() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + let token = jwt(iat: epoch - 100_000, exp: nil) + #expect(isTokenFreshEnough(token, now: now) == true) + } + // MARK: - Compare And Set Tests @Test("Should update tokens when refresh token matches") From 67f042882ad59af549c83c36c8ac1a07d13ea6c7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:06:30 -0700 Subject: [PATCH 55/71] auth: schedule token refresh off real expiry, not issued age (cmux#10897) isTokenFreshEnough now treats a token as fresh while more than 300s remain before its exp claim (clamped to half the token's exp-iat lifetime, floored at 20s). The removed issued-age heuristic (issued <75s ago) forced a network refresh plus token-file rewrite every ~75s of token age forever: PresenceHeartbeatClient requests tokens every 15s, so a signed-in idle Mac refreshed every ~78s (189 writes/session observed) against a 3600s token TTL. Idle steady state now refreshes once per ~55min. Genuinely short-lived tokens refresh at half-life instead of on every request, and a revoked session is still caught by the 401 -> fetchNewAccessToken retry path, which never consulted freshness. --- .../Sources/StackAuth/APIClient.swift | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index 3296392fb0f0..7be44a94044f 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -73,8 +73,20 @@ func isTokenExpired(_ accessToken: String?) -> Bool { return payload.expiresInMillis <= 0 } +/// Refresh this long before the token's real expiry, so callers never hold a +/// token that dies mid-request. Clamped to half the token's total lifetime so +/// short-lived tokens (e.g. a 90s TTL) are not refreshed on every request. +let tokenRefreshMarginSeconds: TimeInterval = 300 + /// Check if token should NOT be refreshed (is "fresh enough"). -/// Returns TRUE if token expires in > 20 seconds AND was issued < 75 seconds ago. +/// +/// Fresh means more than `tokenRefreshMarginSeconds` remain before the `exp` +/// claim (clamped to half the token's `exp - iat` lifetime, floored at 20s). +/// Refresh schedules off the token's REAL expiry: an earlier issued-age +/// heuristic ("issued < 75s ago") forced a network refresh every ~75s of +/// token age forever for any caller that requests tokens periodically, even +/// while the token was valid for a full hour (cmux#10897). +/// /// `now` is injected for deterministic tests; production callers use the /// default wall clock. func isTokenFreshEnough(_ accessToken: String?, now: Date = Date()) -> Bool { @@ -82,10 +94,15 @@ func isTokenFreshEnough(_ accessToken: String?, now: Date = Date()) -> Bool { let payload = decodeJWTPayload(token) else { return false // Can't decode, should refresh } - let nowMillis = now.timeIntervalSince1970 * 1000 - let expiresInMoreThan20s = payload.exp.map { ($0 * 1000) - nowMillis > 20_000 } ?? true - let issuedLessThan75sAgo = payload.iat.map { nowMillis - ($0 * 1000) < 75_000 } ?? true - return expiresInMoreThan20s && issuedLessThan75sAgo + guard let exp = payload.exp else { + return true // No expiry claim: nothing to refresh against + } + var margin = tokenRefreshMarginSeconds + if let iat = payload.iat, exp > iat { + margin = min(margin, (exp - iat) / 2) + } + margin = max(margin, 20) + return exp - now.timeIntervalSince1970 > margin } // MARK: - Refresh Lock Manager @@ -476,9 +493,10 @@ actor APIClient { // Network/server hiccup. PRESERVE the refresh token so a retry // after recovery succeeds, and never silently sign the user out. // Return the original access token only if it is still usable; - // a proactive refresh fires every 75s of token age (see - // `isTokenFreshEnough`) while the token is valid for ~1h, so the - // common transient-failure case still has a good token. When the + // a proactive refresh fires `tokenRefreshMarginSeconds` before + // the real expiry (see `isTokenFreshEnough`) while the token is + // valid for ~1h, so the common transient-failure case still has + // a good token. When the // token is genuinely expired, return nil access + non-nil refresh // so the caller can classify "recoverable" without decoding a JWT. let usableAccessToken = isTokenExpired(originalAccessToken) ? nil : originalAccessToken From e88dfc80031f3a284f68de5342d6e2a4a8fdc6e8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:14:01 -0700 Subject: [PATCH 56/71] iroh: red test for relay-policy recovery republication (cmux#10873) A host that activated during a relay policy outage installs the recovered policy via replaceRelayPolicy, which attaches the relay on the live endpoint but never republishes the registration: nothing owns a broker round after the relay set changes, so remote clients keep a direct-only route and the recovered host stays unreachable until some unrelated network change fires. Test only; the fix follows so CI shows red then green. --- ...CmxIrohHostRuntimeRelayRecoveryTests.swift | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift new file mode 100644 index 000000000000..4e41aa4b713b --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift @@ -0,0 +1,150 @@ +import CMUXMobileCore +import Foundation +import Testing + +@testable import CmuxIrohTransport + +/// Regression coverage for cmux#10873: a Mac that activated during a relay +/// policy outage (expired policy cache, persistently failing refresh) runs +/// with zero managed relays and publishes a direct-only registration. When a +/// later policy refresh finally succeeds, installing the recovered policy +/// must both attach the managed relay on the live endpoint AND republish the +/// registration, without an app restart, so remote clients can reach the +/// host again. +struct CmxIrohHostRuntimeRelayRecoveryTests { + @Test("relay policy recovery after outage attaches and republishes") + func recoveryAfterOutageAttachesAndRepublishes() async throws { + let fixture = try HostRuntimeFixture() + let recoveredRelayURL = HostRuntimeFixture.relayURLs[2] + // The endpoint has no relay hints: exactly the outage shape, where the + // host bound with `.unavailableManagedSelection` (zero relays). + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection, + managedRelayURLs: [] + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // Outage steady state: active, published direct-only, no relays. + #expect(await runtime.snapshot().state == .active) + #expect(await bindings.count() == 1) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + // A later policy refresh succeeds and the recovered policy is + // installed on the running host. + try await runtime.replaceRelayPolicy( + Self.recoveredPolicy(selectedRelayURL: recoveredRelayURL) + ) + + // Attach: the live endpoint received the recovered relay profile. + #expect( + await endpoint.observedRelayProfileUpdates().last?.allowedRelayURLs + == [recoveredRelayURL] + ) + // Publish: the host re-registers and republishes without a restart, + // so the broker can serve the recovered relay route to clients. + #expect(await bindings.waitForCount(2, timeout: .seconds(5))) + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await routes.values().count >= 2) + + await runtime.stop() + } + + /// A repeated install of an unchanged relay profile (every periodic + /// policy refresh success re-applies the effective policy) must NOT force + /// a broker registration round each time. + @Test("unchanged relay profile reinstall does not republish") + func unchangedProfileReinstallDoesNotRepublish() async throws { + let fixture = try HostRuntimeFixture() + let recoveredRelayURL = HostRuntimeFixture.relayURLs[2] + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection, + managedRelayURLs: [] + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + try await runtime.start() + #expect(await bindings.count() == 1) + + let recovered = Self.recoveredPolicy(selectedRelayURL: recoveredRelayURL) + try await runtime.replaceRelayPolicy(recovered) + #expect(await bindings.waitForCount(2, timeout: .seconds(5))) + await runtime.waitForRegistrationRefreshRoundsForTesting() + let publishedAfterRecovery = await bindings.count() + + // Same policy again: no relay change, no forced round. + try await runtime.replaceRelayPolicy(recovered) + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await bindings.count() == publishedAfterRecovery) + + await runtime.stop() + } + + /// An effective policy whose managed catalog carries the fixture fleet + /// and whose endpoint profile selects `selectedRelayURL`. + private static func recoveredPolicy( + selectedRelayURL: String + ) -> CmxIrohEffectiveRelayPolicy { + let descriptors = HostRuntimeFixture.relayURLs.enumerated().map { + index, url in + CmxIrohManagedRelayDescriptor( + id: "cmux-relay-\(index)", + provider: "cmux", + region: "region-\(index)", + url: url + ) + } + let policy = CmxIrohManagedRelayPolicy( + version: 1, + policyID: "123e4567-e89b-42d3-a456-426614174777", + sequence: 9, + issuedAt: 1_800_000_000, + notBefore: 1_800_000_000, + expiresAt: 1_800_003_600, + audience: "cmux-iroh-relay-policy", + relayProtocol: "iroh-relay-v1", + relays: descriptors + ) + let profile = try! CmxIrohEndpointRelayProfile( + managedRelayURLs: [selectedRelayURL] + ) + return CmxIrohEffectiveRelayPolicy( + endpointRelayProfile: profile, + managedSnapshot: nil, + managedPolicy: policy, + requestedConfiguration: .automatic, + effectivePreference: .automatic, + source: .managed, + usedCachedPolicy: false, + preferenceRevision: 3 + ) + } +} From 4e4111b64d0cfab0f725c4f02d8286d562f07363 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:14:39 -0700 Subject: [PATCH 57/71] iroh: republish registration when the installed relay set changes (cmux#10873) replaceRelayProfile now schedules a forced registration refresh when the new profile's allowed relay URLs differ from the installed set. Relay attach alone never updated the broker: the recovered host kept serving its outage-era direct-only route to remote clients. Unchanged reinstalls schedule nothing, so periodic policy refresh successes do not add broker rounds. Turns the red recovery tests green. --- .../CmxIrohHostRuntime+RelayPolicy.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift index 2a0a07fd07e3..f67f68e2df33 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift @@ -45,6 +45,9 @@ extension CmxIrohHostRuntime { throw CmxIrohHostRuntimeError.relayFleetMismatch } let revision = lifecycleRevision + let previousRelayURLs = currentEndpointRelayProfile?.allowedRelayURLs + ?? configuration.endpointRelayProfile?.allowedRelayURLs + ?? [] try await connectivityEngine.replaceRelayProfile( profile, @@ -56,5 +59,21 @@ extension CmxIrohHostRuntime { currentEndpointRelayProfile = profile await admissionController?.updateManagedRelayURLs(replacementManagedURLs) try requireCurrent(revision) + + // A changed relay allowlist changes how this host is dialed, so the + // registration must be republished. This is the recovery path for a + // host that activated during a relay policy outage (zero relays, + // direct-only route) and only regained a managed relay when a later + // policy refresh succeeded: without a forced round here nothing owns + // that republication, and the host stays unreachable for remote + // clients until an unrelated network change fires (cmux#10873). + // Unchanged reinstalls (every periodic refresh success re-applies the + // effective policy) schedule nothing. + if profile.allowedRelayURLs != previousRelayURLs { + scheduleRegistrationRefresh( + revision: revision, + forcePublication: true + ) + } } } From 2fe7eed5ade96884ad01da1845b734fb12344530 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:32:40 -0700 Subject: [PATCH 58/71] iroh: surface persistent relay-policy refresh failure (cmux#10873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CmxIrohRelayPolicyService now tracks the consecutive refresh failure streak (start time + count) and stamps it onto every published diagnostics snapshot; broker fetch failures, which previously published nothing, now republish diagnostics too. A success clears the streak. Visible state: the iroh_diag Active relay profile block reports 'Source: none — policy refresh failing since (N consecutive failures)' when no policy is installed, and appends a 'Policy refresh: failing since' line when one is; the existing Iroh settings runtime status flips to .degraded once the streak reaches persistentRefreshFailureThreshold (3), so a host that cannot renew relay authority is no longer silently unreachable while LAN paths mask the outage. --- .../CmxIrohRelayDiagnosticsSnapshot.swift | 60 ++++++++ .../CmxIrohRelayPolicyService.swift | 87 ++++++++--- ...mxIrohRelayPolicyServiceRefreshTests.swift | 140 ++++++++++++++++++ .../MobileHostIrohRuntime+RelayDiag.swift | 91 ++++++++++-- ...bileHostIrohRuntime+SettingsSnapshot.swift | 7 + Sources/Mobile/MobileHostIrohRuntime.swift | 14 +- .../MobileHostServiceSettingsTests.swift | 34 +++++ 7 files changed, 402 insertions(+), 31 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift index f396b8bd02e3..8cea8ceac3cf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift @@ -32,6 +32,66 @@ public struct CmxIrohRelayDiagnosticsSnapshot: Equatable, Sendable { /// Last non-secret policy resolution failure. public let failure: CmxIrohRelayPolicyFailure? + /// Start of the current run of consecutive policy refresh failures, `nil` + /// while the last refresh succeeded. A host whose refresh keeps failing + /// must be visibly degraded instead of silently unreachable + /// (cmux#10873); consumers treat the state as persistent once + /// ``consecutiveRefreshFailures`` reaches + /// ``CmxIrohRelayPolicyService/persistentRefreshFailureThreshold``. + public let refreshFailingSince: Date? + + /// Length of the current run of consecutive policy refresh failures. + public let consecutiveRefreshFailures: Int + + init( + source: CmxIrohRelayPolicySource, + policyID: String?, + policySequence: Int64?, + policyExpiresAt: Date?, + preferenceRevision: Int64?, + selectedRelayIDs: [String], + selectedRelayCount: Int, + staleRelayIDs: [String], + missingCredentialRelayIDs: [String], + failure: CmxIrohRelayPolicyFailure?, + refreshFailingSince: Date? = nil, + consecutiveRefreshFailures: Int = 0 + ) { + self.source = source + self.policyID = policyID + self.policySequence = policySequence + self.policyExpiresAt = policyExpiresAt + self.preferenceRevision = preferenceRevision + self.selectedRelayIDs = selectedRelayIDs + self.selectedRelayCount = selectedRelayCount + self.staleRelayIDs = staleRelayIDs + self.missingCredentialRelayIDs = missingCredentialRelayIDs + self.failure = failure + self.refreshFailingSince = refreshFailingSince + self.consecutiveRefreshFailures = consecutiveRefreshFailures + } + + /// The same snapshot restamped with the current refresh failure streak. + func withRefreshFailureStreak( + since: Date?, + count: Int + ) -> CmxIrohRelayDiagnosticsSnapshot { + CmxIrohRelayDiagnosticsSnapshot( + source: source, + policyID: policyID, + policySequence: policySequence, + policyExpiresAt: policyExpiresAt, + preferenceRevision: preferenceRevision, + selectedRelayIDs: selectedRelayIDs, + selectedRelayCount: selectedRelayCount, + staleRelayIDs: staleRelayIDs, + missingCredentialRelayIDs: missingCredentialRelayIDs, + failure: failure, + refreshFailingSince: since, + consecutiveRefreshFailures: count + ) + } + static let inactive = CmxIrohRelayDiagnosticsSnapshot( source: .inactive, policyID: nil, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift index 046b5d1b2988..0a5a9a71388c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift @@ -13,6 +13,12 @@ public actor CmxIrohRelayPolicyService { /// credential validity and rejects a truly stale token. public static let defaultExpiredPolicyReuseGrace: TimeInterval = 6 * 60 * 60 + /// Consecutive ``refresh(accountID:trustRoot:now:)`` failures after which + /// the failure streak counts as persistent and must surface as a visible + /// degraded host state (cmux#10873). Below this, transient broker or + /// network hiccups stay quiet because the retry loop is already armed. + public static let persistentRefreshFailureThreshold = 3 + private let policyCache: CmxIrohRelayPolicyCache private let preferenceStore: CmxIrohRelayPreferenceStore private let credentialStore: CmxIrohCustomRelayCredentialStore @@ -22,6 +28,11 @@ public actor CmxIrohRelayPolicyService { private var currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot.inactive private var continuations: [UUID: AsyncStream.Continuation] = [:] private var operationRevision: UInt64 = 0 + /// Current run of consecutive broker refresh failures; `nil` while the + /// last refresh succeeded. Stamped onto every published diagnostics + /// snapshot so a persistently failing refresh is visible host state. + private var refreshFailingSince: Date? + private var consecutiveRefreshFailures = 0 /// Creates an inactive relay policy service with injected persistence boundaries. public init( @@ -40,6 +51,12 @@ public actor CmxIrohRelayPolicyService { } /// Fetches and installs the broker's current signed relay policy. + /// + /// Every failure of this authenticated round (fetch or verification) + /// extends the refresh failure streak published with diagnostics; a + /// success clears it. Direct ``install(response:accountID:trustRoot:now:)`` + /// and ``restore(accountID:trustRoot:now:)`` calls leave the streak + /// untouched: restore is the failed-refresh fallback, not a refresh. @discardableResult public func refresh( accountID: String, @@ -47,13 +64,26 @@ public actor CmxIrohRelayPolicyService { now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { guard let broker else { throw CmxIrohRelayPolicyServiceError.brokerUnavailable } - let response = try await broker.fetchRelayPolicy() - return try await install( - response: response, - accountID: accountID, - trustRoot: trustRoot, - now: now - ) + let response: CmxIrohRelayPolicyResponse + do { + response = try await broker.fetchRelayPolicy() + } catch { + recordRefreshFailure(at: now) + throw error + } + do { + let effective = try await install( + response: response, + accountID: accountID, + trustRoot: trustRoot, + now: now + ) + clearRefreshFailureStreak() + return effective + } catch { + recordRefreshFailure(at: now) + throw error + } } /// Verifies and resolves one broker response without replacing last-known-good @@ -469,15 +499,12 @@ public actor CmxIrohRelayPolicyService { failure: CmxIrohRelayPolicyFailure? ) { currentEffective = effective - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } + yieldDiagnostics(Resolver.diagnostics(for: effective, failure: failure)) } private func publishFailure(_ failure: CmxIrohRelayPolicyFailure) { guard let effective = currentEffective else { - currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot( + yieldDiagnostics(CmxIrohRelayDiagnosticsSnapshot( source: .inactive, policyID: nil, policySequence: nil, @@ -488,18 +515,44 @@ public actor CmxIrohRelayPolicyService { staleRelayIDs: [], missingCredentialRelayIDs: [], failure: failure - ) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } + )) return } - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) + yieldDiagnostics(Resolver.diagnostics(for: effective, failure: failure)) + } + + /// The single diagnostics funnel: every published snapshot carries the + /// current refresh failure streak. + private func yieldDiagnostics(_ snapshot: CmxIrohRelayDiagnosticsSnapshot) { + currentDiagnostics = snapshot.withRefreshFailureStreak( + since: refreshFailingSince, + count: consecutiveRefreshFailures + ) for continuation in continuations.values { continuation.yield(currentDiagnostics) } } + private func recordRefreshFailure(at now: Date) { + consecutiveRefreshFailures = min(consecutiveRefreshFailures + 1, 1_000) + if refreshFailingSince == nil { + refreshFailingSince = now + } + // Re-publish so observers see the streak grow even when the failed + // round produced no new resolution (e.g. the broker fetch itself + // failed before install could publish anything). + yieldDiagnostics(currentDiagnostics) + } + + private func clearRefreshFailureStreak() { + guard refreshFailingSince != nil || consecutiveRefreshFailures > 0 else { + return + } + refreshFailingSince = nil + consecutiveRefreshFailures = 0 + yieldDiagnostics(currentDiagnostics) + } + private func removeContinuation(_ id: UUID) { continuations.removeValue(forKey: id) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift index a83d287dbe38..1c66830e6c9e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift @@ -64,4 +64,144 @@ struct CmxIrohRelayPolicyServiceRefreshTests { }) #expect(await broker.policyRequestCount == 1) } + + /// Persistent refresh failure must be visible host state (cmux#10873): + /// every failed authenticated refresh round extends a published failure + /// streak, whether the broker fetch itself failed or its response failed + /// verification, and the streak start survives later failures. + @Test + func refreshFailuresPublishGrowingStreak() async throws { + let fixture = RelayPolicyServiceTestFixture() + let broker = ScriptedPolicyBroker(results: [ + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + ]) + let service = Self.service(broker: broker) + let firstFailureAt = fixture.now + + for attempt in 1 ... 3 { + await #expect(throws: TestRelayPolicyTransportError.self) { + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: firstFailureAt.addingTimeInterval(TimeInterval(attempt - 1)) + ) + } + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == firstFailureAt) + #expect(snapshot.consecutiveRefreshFailures == attempt) + } + let snapshot = await service.diagnosticsSnapshot() + #expect( + snapshot.consecutiveRefreshFailures + >= CmxIrohRelayPolicyService.persistentRefreshFailureThreshold + ) + } + + /// A refresh whose broker response fails signature verification is a + /// refresh failure too: the streak grows exactly as for a fetch failure. + @Test + func rejectedPolicyCountsTowardStreak() async throws { + let fixture = RelayPolicyServiceTestFixture() + let broker = ScriptedPolicyBroker(results: [ + .success(try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1, signer: 2), + preference: .automatic, + preferenceRevision: 1 + )), + ]) + let service = Self.service(broker: broker) + + await #expect(throws: (any Error).self) { + // Signed by a key absent from the trust root. + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now + ) + } + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == fixture.now) + #expect(snapshot.consecutiveRefreshFailures == 1) + } + + /// A successful refresh clears the streak, so recovery is visible on the + /// same surface that reported the outage. + @Test + func successfulRefreshClearsStreak() async throws { + let fixture = RelayPolicyServiceTestFixture() + let broker = ScriptedPolicyBroker(results: [ + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + .success(try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + )), + ]) + let service = Self.service(broker: broker) + + for _ in 1 ... 2 { + await #expect(throws: TestRelayPolicyTransportError.self) { + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now + ) + } + } + #expect(await service.diagnosticsSnapshot().consecutiveRefreshFailures == 2) + + let effective = try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now + ) + #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == nil) + #expect(snapshot.consecutiveRefreshFailures == 0) + } + + private static func service( + broker: any CmxIrohRelayPolicyServing + ) -> CmxIrohRelayPolicyService { + CmxIrohRelayPolicyService( + policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), + preferenceStore: CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()), + credentialStore: CmxIrohCustomRelayCredentialStore( + secureStore: TestSecureCredentialStore() + ), + broker: broker + ) + } +} + +private enum TestRelayPolicyTransportError: Error { + case offline +} + +/// Serves one scripted result per fetch, repeating the last one. +private actor ScriptedPolicyBroker: CmxIrohRelayPolicyServing { + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + let result = results.count > 1 ? results.removeFirst() : results[0] + return try result.get() + } + + func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { + throw CmxIrohRelayPolicyServiceError.brokerUnavailable + } + + func updateRelayPreference( + _: CmxIrohRelayPreferenceUpdateRequest + ) async throws -> CmxIrohRelayPreferenceResponse { + throw CmxIrohRelayPolicyServiceError.brokerUnavailable + } } diff --git a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift index b959bbd73b61..d94023d25539 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift @@ -13,6 +13,33 @@ extension MobileHostIrohRuntime { let source: CmxIrohRelayPolicySource let usedCachedPolicy: Bool let relayURLs: [String] + /// Start of the current run of consecutive policy refresh failures, + /// mirrored from the service diagnostics so an unreachable-by-outage + /// host is visible in `iroh_diag` (cmux#10873). + let refreshFailingSince: Date? + let consecutiveRefreshFailures: Int + + init( + source: CmxIrohRelayPolicySource, + usedCachedPolicy: Bool, + relayURLs: [String], + refreshFailingSince: Date? = nil, + consecutiveRefreshFailures: Int = 0 + ) { + self.source = source + self.usedCachedPolicy = usedCachedPolicy + self.relayURLs = relayURLs + self.refreshFailingSince = refreshFailingSince + self.consecutiveRefreshFailures = consecutiveRefreshFailures + } + } + + /// The refresh failure streak alone, for the launches where no relay + /// policy was ever installed but the refresh loop is failing: the diag + /// must say why relays are absent instead of only "none installed". + struct RelayDiagRefreshFailure: Equatable, Sendable { + let since: Date + let consecutiveFailures: Int } /// Mirror of the relay policy most recently installed by the account @@ -24,26 +51,45 @@ extension MobileHostIrohRuntime { /// previous policy after installation), and the read must stay off the /// main actor so the verb keeps working when the main thread is wedged. /// Both critical sections are tiny value copies with no reentrancy. - private nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( - initialState: nil + private nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( + initialState: RelayDiagMirror(policy: nil, refreshFailure: nil) ) - /// The single write funnel, called from `relayPolicyEffective`'s - /// `didSet` so every installation and clearing site is mirrored before - /// the property write returns. - static func publishRelayDiagMirror(from policy: CmxIrohEffectiveRelayPolicy?) { + struct RelayDiagMirror: Equatable, Sendable { + var policy: RelayDiagState? + var refreshFailure: RelayDiagRefreshFailure? + } + + /// The single write funnel, called from the `relayPolicyEffective` and + /// `relayPolicyDiagnostics` `didSet`s so every installation, clearing, + /// and refresh-outcome site is mirrored before the property write + /// returns. + static func publishRelayDiagMirror( + from policy: CmxIrohEffectiveRelayPolicy?, + diagnostics: CmxIrohRelayDiagnosticsSnapshot? + ) { + let refreshFailure = diagnostics?.refreshFailingSince.map { + RelayDiagRefreshFailure( + since: $0, + consecutiveFailures: diagnostics?.consecutiveRefreshFailures ?? 0 + ) + } let state = policy.map { RelayDiagState( source: $0.source, usedCachedPolicy: $0.usedCachedPolicy, - relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted() + relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted(), + refreshFailingSince: refreshFailure?.since, + consecutiveRefreshFailures: refreshFailure?.consecutiveFailures ?? 0 ) } - relayDiagMirror.withLock { $0 = state } + relayDiagMirror.withLock { + $0 = RelayDiagMirror(policy: state, refreshFailure: refreshFailure) + } } nonisolated static func currentRelayDiagState() -> RelayDiagState? { - relayDiagMirror.withLock { $0 } + relayDiagMirror.withLock { $0.policy } } /// The relay section appended to `iroh_diag` output: the profile the @@ -52,14 +98,17 @@ extension MobileHostIrohRuntime { /// consulted first because every profile installation funnel replaces /// the installed profile with it while it is active. nonisolated static func relayDiagReportText() -> String { - relayDiagReport( - policy: currentRelayDiagState(), + let mirror = relayDiagMirror.withLock { $0 } + return relayDiagReport( + policy: mirror.policy, + refreshFailure: mirror.refreshFailure, debugOverrideRelayURL: CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL ) } nonisolated static func relayDiagReport( policy: RelayDiagState?, + refreshFailure: RelayDiagRefreshFailure? = nil, debugOverrideRelayURL: String? ) -> String { var lines = ["Active relay profile"] @@ -70,7 +119,15 @@ extension MobileHostIrohRuntime { return lines.joined(separator: "\n") } guard let policy else { - lines.append("Source: none installed (no relay policy this launch)") + if let refreshFailure { + lines.append( + "Source: none — policy refresh failing since " + + iso8601(refreshFailure.since) + + " (\(refreshFailure.consecutiveFailures) consecutive failures)" + ) + } else { + lines.append("Source: none installed (no relay policy this launch)") + } return lines.joined(separator: "\n") } let source = switch policy.source { @@ -91,6 +148,16 @@ extension MobileHostIrohRuntime { } else { lines.append("Relays: \(policy.relayURLs.joined(separator: ", "))") } + if let since = policy.refreshFailingSince { + lines.append( + "Policy refresh: failing since " + iso8601(since) + + " (\(policy.consecutiveRefreshFailures) consecutive failures)" + ) + } return lines.joined(separator: "\n") } + + private nonisolated static func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } } diff --git a/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift b/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift index 1914519f76dc..0260714628d7 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift @@ -36,6 +36,8 @@ extension MobileHostIrohRuntime { runtimeStatus: Self.settingsRuntimeStatus( runtimeState, failure: diagnostics?.failure, + refreshFailurePersistent: (diagnostics?.consecutiveRefreshFailures ?? 0) + >= CmxIrohRelayPolicyService.persistentRefreshFailureThreshold, selectedPath: selectedPath ), selectedTransportPath: selectedPath, @@ -66,9 +68,14 @@ extension MobileHostIrohRuntime { private nonisolated static func settingsRuntimeStatus( _ state: CmxIrohHostRuntimeSnapshot.State?, failure: CmxIrohRelayPolicyFailure?, + refreshFailurePersistent: Bool, selectedPath: CmxIrohSelectedTransportPath ) -> CmxIrohSettingsSnapshot.RuntimeStatus { if failure != nil { return .degraded } + // A persistently failing policy refresh means this host cannot renew + // relay authority: without this the outage was invisible while LAN + // and direct paths still worked (cmux#10873). + if refreshFailurePersistent { return .degraded } switch state { case .active: return CmxIrohSettingsSnapshot.RuntimeStatus(activePath: selectedPath) diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index fd4ae3ff76d1..04aa410b23c0 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -110,10 +110,20 @@ final class MobileHostIrohRuntime { var relayPolicyService: CmxIrohRelayPolicyService? var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? { didSet { - Self.publishRelayDiagMirror(from: relayPolicyEffective) + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) + } + } + var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? { + didSet { + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) } } - var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? var relayPolicyEndpointID: CmxIrohPeerIdentity? var relayPolicyObservationTask: Task? var relayPolicyRefreshTask: Task? diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 0c5de77db892..ed4ad5d89050 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -508,4 +508,38 @@ struct MobileHostIrohRelayDiagReportTests { Source: none installed (no relay policy this launch) """) } + + @Test func missingPolicyWithFailingRefreshReportsFailingSince() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: nil, + refreshFailure: MobileHostIrohRuntime.RelayDiagRefreshFailure( + since: Date(timeIntervalSince1970: 1_782_000_000), + consecutiveFailures: 4 + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: none — policy refresh failing since 2026-06-21T00:00:00Z (4 consecutive failures) + """) + } + + @Test func installedPolicyWithFailingRefreshAppendsFailureLine() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managedUnavailable, + usedCachedPolicy: false, + relayURLs: [], + refreshFailingSince: Date(timeIntervalSince1970: 1_782_000_000), + consecutiveRefreshFailures: 7 + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: managed selection unavailable (relays disabled) + Relays: (none) + Policy refresh: failing since 2026-06-21T00:00:00Z (7 consecutive failures) + """) + } } From e32f96df58ab06499593d4ec278847356b9b456d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:34:09 -0700 Subject: [PATCH 59/71] iroh: admit paired phones from a Mac-side EndpointId allowlist The QUIC handshake already proves the phone's EndpointId. Until now every session still fetched a backend pair-grant JWS and presented it in-band, so the Mac re-verified pairing per connection. Pairing authorization is now written down once: when a pair grant verifies for the FIRST time for a phone endpoint, CmxIrohAdmissionController records the grant's exact initiator and acceptor tuples (bounded by the grant's signed expiry) in CmxIrohPairedPeerAllowlist, a keychain-backed store scoped to the active account, app instance, and bundle namespace. On later connections the phone opens its control stream with NO admission credential (control header credential code 0). The controller resolves the TLS-proven remoteId against the allowlist and routes the pinned tuples through the SAME online-registry validation a live grant gets: both bindings must appear in this Mac account's authenticated broker discovery (snapshot <= 30s old whenever reachable; the existing connectivity-only fallback and 30s lease monitor apply unchanged), so allowlist admission never bypasses the account scoping the grant carried. Eviction: local revoke removes matching entries; a definitive registry refusal (device unpaired, binding replaced) evicts on re-validation; an acceptor identity change invalidates entries at lookup; sign-out wipes the store. A stranger's proven-but-unpaired key is refused before any broker round, and an evicted key stays refused even with a stale cached grant. Phone side: after one fully admitted session the registry context provider marks the Mac established (persisted via the offline policy cache, so it survives relaunch) and builds later contexts credential-less with zero pair-grant HTTP calls. A refused credential-less admission falls back once, in the same dial, to a freshly fetched grant via CmxConnectivityEngine. Grant bootstrap remains the path for unpaired identities and the revocation-aware fallback. --- .../CmxConnectivityEngine.swift | 81 +++-- .../CmxIrohAdmissionAuthorizing.swift | 8 +- .../CmxIrohAdmissionController.swift | 105 ++++++- .../CmxIrohClientContext.swift | 11 +- .../CmxIrohClientContextProvider.swift | 16 + .../CmxIrohClientOfflinePolicyCache.swift | 102 +++++- .../CmxIrohClientOfflinePolicyModels.swift | 15 + .../CmxIrohClientSession.swift | 5 +- .../CmxIrohHostRuntime.swift | 15 +- .../CmxIrohOnlineAdmissionAuthorization.swift | 21 ++ .../CmxIrohOnlineAdmissionRegistry.swift | 35 +++ .../CmxIrohPairedPeerAllowlist.swift | 291 ++++++++++++++++++ .../CmxIrohRegistryContextProvider.swift | 91 +++++- .../CmxIrohServerSession.swift | 8 +- .../CmxIrohStreamHeader.swift | 13 +- .../CmxIrohStreamHeaderCodec.swift | 12 +- .../CmxIrohPairedPeerAdmissionTests.swift | 280 +++++++++++++++++ .../CmxIrohPairedPeerAllowlistTests.swift | 163 ++++++++++ .../CmxIrohPairedPeerWireTests.swift | 68 ++++ ...CmxIrohPrivatePathTransportGateTests.swift | 2 +- ...RegistryContextProviderFallbackTests.swift | 10 +- ...ohRegistryContextProviderPairedTests.swift | 154 +++++++++ ...ohRegistryContextProviderPolicyTests.swift | 14 +- .../CmxIrohServerSessionTestDoubles.swift | 2 +- .../CmxIrohStreamHeaderCodecTests.swift | 8 +- .../MobileHostIrohRuntime+Activation.swift | 1 + .../MobileHostIrohRuntime+Lifecycle.swift | 7 + Sources/Mobile/MobileHostIrohRuntime.swift | 7 + 28 files changed, 1471 insertions(+), 74 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAllowlistTests.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPairedTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index 4de4ddea1fa7..4716684b4dd5 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift @@ -540,37 +540,58 @@ public actor CmxConnectivityEngine { peerID: peerID, buildSession: { request in let endpoint = try await supervisor.activeEndpoint() - let context = try await contextProvider.context(for: request) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: peerID.identity, - dialPlan: context.dialPlan, - credential: context.credential, - privateFallbackAuthorization: context.privateFallbackAuthorization, - privateFallbackValidator: contextProvider, - privateFallbackContextProvider: { - try await contextProvider.contextWithPrivateFallback( - for: request, - basedOn: context - ) - }, - dialPhaseTimeout: dialPhaseTimeout, - protocolConfiguration: protocolConfiguration, - diagnostics: diagnosticLog - ) - do { - try await session.connect() - return session - } catch { - await session.close() - if !(Task.isCancelled || error is CancellationError) { - await contextProvider.noteDialFailure( - for: request, - dialPlan: context.dialPlan, - failure: DiagnosticFailureKind.classify(error) - ) + var context = try await contextProvider.context(for: request) + var attemptedCredentialFallback = false + while true { + let attemptContext = context + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: peerID.identity, + dialPlan: attemptContext.dialPlan, + credential: attemptContext.credential, + privateFallbackAuthorization: attemptContext.privateFallbackAuthorization, + privateFallbackValidator: contextProvider, + privateFallbackContextProvider: { + try await contextProvider.contextWithPrivateFallback( + for: request, + basedOn: attemptContext + ) + }, + dialPhaseTimeout: dialPhaseTimeout, + protocolConfiguration: protocolConfiguration, + diagnostics: diagnosticLog + ) + do { + try await session.connect() + await contextProvider.noteAdmissionSucceeded(for: request) + return session + } catch { + await session.close() + if !(Task.isCancelled || error is CancellationError) { + await contextProvider.noteDialFailure( + for: request, + dialPlan: attemptContext.dialPlan, + failure: DiagnosticFailureKind.classify(error) + ) + } + // A refused credential-less (allowlist) admission + // falls back to the bootstrap grant path once: the + // provider is told to require a credential again and + // asked for a fresh context, which may fetch a grant. + if case CmxIrohClientSessionError.admissionDenied = error, + attemptContext.credential == nil, + !attemptedCredentialFallback, + !(Task.isCancelled) { + attemptedCredentialFallback = true + await contextProvider.noteAllowlistAdmissionRefused( + for: request + ) + context = try await contextProvider.context(for: request) + guard context.credential != nil else { throw error } + continue + } + throw error } - throw error } }, handleSnapshot: { [weak self] snapshot in diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift index 0935875b90ab..f15dc1470b2f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift @@ -2,8 +2,14 @@ public import CMUXMobileCore /// Fail-closed authorization seam for the first control stream on a connection. public protocol CmxIrohAdmissionAuthorizing: Sendable { + /// Authorizes one authenticated connection. + /// + /// - Parameters: + /// - credential: The in-band admission proof, or `nil` when the client + /// requests allowlist admission of its TLS-proven EndpointID. + /// - authenticatedPeerID: The remote identity proven by the QUIC handshake. func authorize( - credential: CmxIrohAdmissionCredential, + credential: CmxIrohAdmissionCredential?, authenticatedPeerID: CmxIrohPeerIdentity ) async -> CmxIrohAdmissionAuthorization } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift index 2476f3673fdc..df1b807b0903 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift @@ -1,10 +1,13 @@ public import CMUXMobileCore public import Foundation -/// Mac admission policy combining online grants, offline sessions, and local revoke state. +/// Mac admission policy combining online grants, offline sessions, the paired +/// endpoint allowlist, and local revoke state. public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { private let offlineSessions: CmxIrohOfflinePairingSessions private let onlineRegistry: CmxIrohOnlineAdmissionRegistry + private let allowlist: CmxIrohPairedPeerAllowlist? + private let allowlistScope: CmxIrohPairedPeerAllowlistScope? private let now: @Sendable () -> Date private var acceptor: CmxIrohGrantPeer private var pairingEnabled: Bool @@ -17,12 +20,16 @@ public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { pairingEnabled: Bool, offlineSessions: CmxIrohOfflinePairingSessions, onlineRegistry: CmxIrohOnlineAdmissionRegistry, + allowlist: CmxIrohPairedPeerAllowlist? = nil, + allowlistScope: CmxIrohPairedPeerAllowlistScope? = nil, now: @escaping @Sendable () -> Date = { Date() } ) { self.acceptor = acceptor self.pairingEnabled = pairingEnabled self.offlineSessions = offlineSessions self.onlineRegistry = onlineRegistry + self.allowlist = allowlist + self.allowlistScope = allowlistScope self.now = now } @@ -54,10 +61,16 @@ public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { revokedBindingIDs.insert(bindingID) await offlineSessions.revoke(bindingID: bindingID) await onlineRegistry.revoke(bindingID: bindingID) + if let allowlist, let allowlistScope { + await allowlist.removeEntries( + bindingID: bindingID, + scope: allowlistScope + ) + } } public func authorize( - credential: CmxIrohAdmissionCredential, + credential: CmxIrohAdmissionCredential?, authenticatedPeerID: CmxIrohPeerIdentity ) async -> CmxIrohAdmissionAuthorization { guard policyMutationCount == 0, @@ -67,6 +80,12 @@ public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { return .denied(code: 1) } let revision = policyRevision + guard let credential else { + return await authorizeAllowlistedPeer( + authenticatedPeerID: authenticatedPeerID, + revision: revision + ) + } do { switch credential.kind { case .pairGrant: @@ -78,7 +97,9 @@ public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { authenticatedPeerID: authenticatedPeerID ) { case let .accepted(lease): - return checkedAuthorization(lease, revision: revision) + let authorization = checkedAuthorization(lease, revision: revision) + await recordVerifiedPairing(lease, authorization: authorization) + return authorization case .denied: return .denied(code: 1) } @@ -103,6 +124,84 @@ public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { } } + /// Admits a TLS-proven EndpointID directly from the persisted allowlist. + /// + /// The entry pins the exact initiator and acceptor tuples the original + /// verified grant carried. The online registry revalidates both bindings + /// against this Mac account's authenticated broker view exactly as it does + /// for an in-band grant, so allowlist admission never bypasses the account + /// check the grant used to carry. A refusal evicts the entry: the phone's + /// grant-fetch fallback then re-establishes (or is refused) authority. + private func authorizeAllowlistedPeer( + authenticatedPeerID: CmxIrohPeerIdentity, + revision: UInt64 + ) async -> CmxIrohAdmissionAuthorization { + guard let allowlist, let allowlistScope else { return .denied(code: 1) } + guard let entry = await allowlist.entry( + forInitiatorEndpointID: authenticatedPeerID, + scope: allowlistScope, + now: now() + ) else { + return .denied(code: 1) + } + guard policyMutationCount == 0, policyRevision == revision else { + return .denied(code: 1) + } + guard entry.acceptor == acceptor, + !revokedBindingIDs.contains(entry.initiator.bindingID) else { + // The Mac's own binding identity changed since pairing, or the + // phone binding was locally revoked: the entry is dead. + await allowlist.removeEntry( + forInitiatorEndpointID: authenticatedPeerID, + scope: allowlistScope + ) + return .denied(code: 1) + } + switch await onlineRegistry.authorizePairedEndpoint( + initiator: entry.initiator, + acceptor: entry.acceptor, + expiresAt: entry.expiresAt, + authenticatedPeerID: authenticatedPeerID + ) { + case let .accepted(lease): + return checkedAuthorization(lease, revision: revision) + case .denied: + // Definitive local or registry refusal (revoked, unpaired, + // expired). Evict so a stale entry cannot be retried forever; + // the bootstrap grant path remains the recovery route. + await allowlist.removeEntry( + forInitiatorEndpointID: authenticatedPeerID, + scope: allowlistScope + ) + return .denied(code: 1) + } + } + + /// Persists the paired endpoint after its grant verified, bounded by the + /// grant's own signed expiry. Recording failure only costs the fast path. + private func recordVerifiedPairing( + _ lease: CmxIrohOnlineAdmissionLease, + authorization: CmxIrohAdmissionAuthorization + ) async { + guard case .accepted = authorization, + let allowlist, + let allowlistScope, + case let .pairGrant(_, initiator, grantAcceptor) = lease.authority else { + return + } + let clock = now() + await allowlist.record( + CmxIrohPairedPeerAllowlistEntry( + initiator: initiator, + acceptor: grantAcceptor, + expiresAt: lease.expiresAt, + recordedAt: clock + ), + scope: allowlistScope, + now: clock + ) + } + private func checkedAuthorization( _ lease: CmxIrohOnlineAdmissionLease, revision: UInt64 diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift index 80901c78e0d8..9cd3550cd450 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift @@ -5,8 +5,10 @@ public struct CmxIrohClientContext: Equatable, Sendable { /// Public paths followed by profile-gated private fallback paths. public let dialPlan: CmxIrohDialPlan - /// The admission proof bound to the exact local and remote endpoints. - public let credential: CmxIrohAdmissionCredential + /// The admission proof bound to the exact local and remote endpoints, or + /// `nil` for allowlist admission of an already-paired Mac: the phone then + /// presents no in-band credential and relies on its TLS-proven EndpointID. + public let credential: CmxIrohAdmissionCredential? /// The generation-bound authorization for explicit private fallback hints. public let privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? @@ -15,12 +17,13 @@ public struct CmxIrohClientContext: Equatable, Sendable { /// /// - Parameters: /// - dialPlan: The explicit two-phase reachability plan. - /// - credential: The signed grant or offline pairing proof. + /// - credential: The signed grant or offline pairing proof, or `nil` + /// to request allowlist admission with no in-band credential. /// - privateFallbackAuthorization: The local generation snapshot that /// admitted the plan's private hints, or `nil` for a public-only plan. public init( dialPlan: CmxIrohDialPlan, - credential: CmxIrohAdmissionCredential, + credential: CmxIrohAdmissionCredential?, privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? = nil ) { self.dialPlan = dialPlan diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift index f7937c798aeb..92ebe49a8e7d 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift @@ -27,6 +27,16 @@ public protocol CmxIrohClientContextProvider: CmxIrohPrivateFallbackValidating, dialPlan: CmxIrohDialPlan, failure: DiagnosticFailureKind ) async + + /// Records one fully admitted session so the provider may serve later + /// warm dials for the same Mac credential-less (allowlist admission), + /// with zero pair-grant fetches on the hot path. + func noteAdmissionSucceeded(for request: CmxByteTransportRequest) async + + /// Records that the Mac refused a credential-less admission attempt + /// (allowlist miss or eviction). The next context for this peer must + /// carry a pair-grant credential again. + func noteAllowlistAdmissionRefused(for request: CmxByteTransportRequest) async } public extension CmxIrohClientContextProvider { @@ -51,4 +61,10 @@ public extension CmxIrohClientContextProvider { dialPlan _: CmxIrohDialPlan, failure _: DiagnosticFailureKind ) async {} + + /// Providers without paired-peer state always present a credential. + func noteAdmissionSucceeded(for _: CmxByteTransportRequest) async {} + + /// Providers without paired-peer state have nothing to fall back from. + func noteAllowlistAdmissionRefused(for _: CmxByteTransportRequest) async {} } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift index e448af7ffcf9..21a47dbc6f0d 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift @@ -80,13 +80,21 @@ public actor CmxIrohClientOfflinePolicyCache { )) != nil else { continue } - retained.append(.init(binding: fresh, pairGrant: stored.pairGrant)) + retained.append(.init( + binding: fresh, + pairGrant: stored.pairGrant, + establishedSessionAt: stored.establishedSessionAt + )) } } let candidate = CmxIrohStoredClientPolicyTarget( binding: targetBinding, - pairGrant: pairGrant + pairGrant: pairGrant, + establishedSessionAt: retained.first(where: { + $0.binding.endpointID == targetBinding.endpointID + && $0.binding.bindingID == targetBinding.bindingID + })?.establishedSessionAt ) var merged = [candidate] merged.append(contentsOf: retained.filter { @@ -237,6 +245,90 @@ public actor CmxIrohClientOfflinePolicyCache { ) } + /// Records or clears the established-session marker for one stored target. + /// + /// A set marker lets a later launch dial the target credential-less + /// (allowlist admission) with zero pair-grant fetches; clearing it forces + /// the next dial back onto the bootstrap grant path. A missing record or + /// unknown target is a silent no-op: the marker is an optimization, never + /// an authority. + public func setSessionEstablished( + _ established: Bool, + targetEndpointID: CmxIrohPeerIdentity, + for expectation: CmxIrohClientOfflinePolicyExpectation, + confirmedLocalBinding: CmxIrohBrokerBinding?, + now: Date + ) async throws { + let epoch = try beginOperation() + guard var record = try await loadRecord( + for: expectation, + confirmedLocalBinding: confirmedLocalBinding, + epoch: epoch + ) else { + try requireCurrent(epoch) + return + } + var changed = false + var targets = record.targets + for index in targets.indices + where targets[index].binding.endpointID == targetEndpointID { + let value: Date? = established ? now : nil + if targets[index].establishedSessionAt != value { + targets[index].establishedSessionAt = value + changed = true + } + } + guard changed else { + try requireCurrent(epoch) + return + } + record = CmxIrohStoredClientPolicyRecord( + version: record.version, + scopeDigest: record.scopeDigest, + localBinding: record.localBinding, + relayFleet: record.relayFleet, + grantVerificationKeys: record.grantVerificationKeys, + lanRendezvous: record.lanRendezvous, + targets: targets + ) + try await persistOrDelete(record, epoch: epoch) + try requireCurrent(epoch) + } + + /// Returns targets whose pairing this phone has already exercised with a + /// fully admitted session, after the same signature reverification the + /// offline fallback applies. + public func establishedTargetEndpointIDs( + for expectation: CmxIrohClientOfflinePolicyExpectation, + confirmedLocalBinding: CmxIrohBrokerBinding?, + now: Date + ) async throws -> Set { + let epoch = try beginOperation() + guard var record = try await loadRecord( + for: expectation, + confirmedLocalBinding: confirmedLocalBinding, + epoch: epoch + ) else { + try requireCurrent(epoch) + return [] + } + try requireCurrent(epoch) + record = try reverifiedRecord( + record, + localBinding: confirmedLocalBinding ?? record.localBinding, + currentTargets: record.targets.map(\.binding), + keys: record.grantVerificationKeys, + lanRendezvous: record.lanRendezvous, + now: now + ) + try requireCurrent(epoch) + return Set( + record.targets + .filter { $0.establishedSessionAt != nil } + .map(\.binding.endpointID) + ) + } + /// Removes every active-account client policy during account/app teardown. public func deactivate() async throws { lifecycleEpoch &+= 1 @@ -305,7 +397,11 @@ public actor CmxIrohClientOfflinePolicyCache { )) != nil else { continue } - targets.append(.init(binding: current, pairGrant: stored.pairGrant)) + targets.append(.init( + binding: current, + pairGrant: stored.pairGrant, + establishedSessionAt: stored.establishedSessionAt + )) } return CmxIrohStoredClientPolicyRecord( version: record.version, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift index f7c8e50ff4be..885e87e50a64 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift @@ -91,6 +91,21 @@ public struct CmxIrohClientOfflinePolicyContext: Sendable { struct CmxIrohStoredClientPolicyTarget: Codable, Equatable, Sendable { let binding: CmxIrohBrokerBinding let pairGrant: CmxIrohPairGrantResponse + /// When this phone last completed a fully admitted session with the + /// target. Presence lets a later launch dial credential-less (allowlist + /// admission) with zero pair-grant fetches. Optional so records written + /// before this field decode unchanged. + var establishedSessionAt: Date? + + init( + binding: CmxIrohBrokerBinding, + pairGrant: CmxIrohPairGrantResponse, + establishedSessionAt: Date? = nil + ) { + self.binding = binding + self.pairGrant = pairGrant + self.establishedSessionAt = establishedSessionAt + } } struct CmxIrohStoredClientPolicyRecord: Codable, Equatable, Sendable { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift index 7c39cc31d281..a017e72dde4e 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift @@ -13,7 +13,8 @@ public actor CmxIrohClientSession { private let dialPhaseTimeout: Duration private let targetIdentity: CmxIrohPeerIdentity private let dialPlan: CmxIrohDialPlan - private let credential: CmxIrohAdmissionCredential + /// `nil` requests allowlist admission with no in-band credential. + private let credential: CmxIrohAdmissionCredential? private let privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? private let privateFallbackValidator: (any CmxIrohPrivateFallbackValidating)? private let privateFallbackContextProvider: PrivateFallbackContextProvider? @@ -49,7 +50,7 @@ public actor CmxIrohClientSession { endpoint: any CmxIrohEndpoint, targetIdentity: CmxIrohPeerIdentity, dialPlan: CmxIrohDialPlan, - credential: CmxIrohAdmissionCredential, + credential: CmxIrohAdmissionCredential?, privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? = nil, privateFallbackValidator: (any CmxIrohPrivateFallbackValidating)? = nil, privateFallbackContextProvider: PrivateFallbackContextProvider? = nil, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index b72bfd71fe40..adc61f4351d8 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -62,6 +62,9 @@ public actor CmxIrohHostRuntime { let factory: any CmxIrohEndpointFactory let broker: any CmxIrohHostBrokerServing let configuration: CmxIrohHostRuntimeConfiguration + /// Durable paired-phone allowlist consulted for credential-less admission. + /// `nil` disables allowlist admission entirely (fail closed). + let pairedPeerAllowlist: CmxIrohPairedPeerAllowlist? let pendingRevocations: CmxIrohPendingRevocationOutbox let protocolConfiguration: CmxIrohProtocolConfiguration let now: @Sendable () -> Date @@ -126,6 +129,7 @@ public actor CmxIrohHostRuntime { broker: any CmxIrohHostBrokerServing, configuration: CmxIrohHostRuntimeConfiguration, pendingRevocations: CmxIrohPendingRevocationOutbox, + pairedPeerAllowlist: CmxIrohPairedPeerAllowlist? = nil, protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, now: @escaping @Sendable () -> Date = { Date() }, admissionClock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), @@ -145,6 +149,7 @@ public actor CmxIrohHostRuntime { self.factory = factory self.broker = broker self.configuration = configuration + self.pairedPeerAllowlist = pairedPeerAllowlist self.pendingRevocations = pendingRevocations self.protocolConfiguration = protocolConfiguration self.now = now @@ -301,7 +306,15 @@ public actor CmxIrohHostRuntime { acceptor: grantPeer(for: policy.binding), pairingEnabled: policy.pairingEnabled, offlineSessions: offlineSessions, - onlineRegistry: onlineAdmissionRegistry + onlineRegistry: onlineAdmissionRegistry, + allowlist: pairedPeerAllowlist, + allowlistScope: pairedPeerAllowlist == nil + ? nil + : CmxIrohPairedPeerAllowlistScope( + accountID: configuration.accountID, + clientNamespace: configuration.clientNamespace, + appInstanceID: configuration.appInstanceID + ) ) self.offlineSessions = offlineSessions self.onlineAdmissionRegistry = onlineAdmissionRegistry diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift index 571de262ae5c..4e68341f641b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift @@ -21,11 +21,19 @@ public struct CmxIrohOnlineAdmissionLease: Equatable, Sendable { initiator: CmxIrohEndpointExpectation, acceptor: CmxIrohEndpointExpectation ) + /// A previously grant-verified pairing admitted from the Mac's local + /// allowlist with no in-band credential. Broker bindings are validated + /// exactly as for a live pair grant. + case pairedEndpoint( + initiator: CmxIrohGrantPeer, + acceptor: CmxIrohGrantPeer + ) var initiatorBindingID: String { switch self { case let .pairGrant(_, initiator, _): initiator.bindingID case let .offlinePairing(initiator, _): initiator.bindingID + case let .pairedEndpoint(initiator, _): initiator.bindingID } } @@ -33,6 +41,7 @@ public struct CmxIrohOnlineAdmissionLease: Equatable, Sendable { switch self { case let .pairGrant(_, _, acceptor): acceptor.bindingID case let .offlinePairing(_, acceptor): acceptor.bindingID + case let .pairedEndpoint(_, acceptor): acceptor.bindingID } } } @@ -92,6 +101,18 @@ public struct CmxIrohOnlineAdmissionLease: Equatable, Sendable { self.onlineValidatedAt = onlineValidatedAt } + init( + pairedInitiator initiator: CmxIrohGrantPeer, + acceptor: CmxIrohGrantPeer, + expiresAt: Date, + onlineValidatedAt: Date? + ) { + peer = CmxIrohAdmittedPeer(peer: initiator) + self.expiresAt = expiresAt + authority = .pairedEndpoint(initiator: initiator, acceptor: acceptor) + self.onlineValidatedAt = onlineValidatedAt + } + func validatedOnline(at date: Date) -> Self { Self( peer: peer, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift index e3d87a27139a..934abd9553cf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift @@ -118,6 +118,34 @@ public actor CmxIrohOnlineAdmissionRegistry { ) } + /// Authorizes an already-paired phone endpoint from a persisted allowlist + /// entry, with no in-band credential. The TLS identity must match the + /// entry's initiator, and the entry's acceptor must be this Mac's exact + /// current binding. Broker bindings are then revalidated exactly as for a + /// live pair grant, so an unpaired (registry-removed) endpoint is refused + /// whenever the broker is reachable and its denial is learned locally. + func authorizePairedEndpoint( + initiator: CmxIrohGrantPeer, + acceptor entryAcceptor: CmxIrohGrantPeer, + expiresAt: Date, + authenticatedPeerID: CmxIrohPeerIdentity + ) async -> CmxIrohOnlineAdmissionAuthorization { + guard initiator.platform == .ios, + initiator.endpointID == authenticatedPeerID, + entryAcceptor.platform == .mac, + entryAcceptor == acceptor else { + return .denied + } + return await authorize( + CmxIrohOnlineAdmissionLease( + pairedInitiator: initiator, + acceptor: entryAcceptor, + expiresAt: expiresAt, + onlineValidatedAt: nil + ) + ) + } + /// AdmissionController is the only production caller, after locally verifying and /// consuming the one-use proof, TLS identity, and both signed attestations. func authorizeOfflinePair( @@ -416,6 +444,13 @@ public actor CmxIrohOnlineAdmissionRegistry { acceptor: acceptor, learnDenial: learnDenial ) + case let .pairedEndpoint(initiator, acceptor): + return validatePairGrantBindings( + response.bindings, + initiator: initiator, + acceptor: acceptor, + learnDenial: learnDenial + ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift new file mode 100644 index 000000000000..b675c1aa4c17 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift @@ -0,0 +1,291 @@ +import CryptoKit +public import CMUXMobileCore +public import Foundation + +/// The Mac-local account, app, and namespace scope owning one allowlist store. +public struct CmxIrohPairedPeerAllowlistScope: Equatable, Sendable { + public let accountID: String + public let clientNamespace: String + public let appInstanceID: String + + public init( + accountID: String, + clientNamespace: String, + appInstanceID: String + ) { + self.accountID = accountID + self.clientNamespace = clientNamespace + self.appInstanceID = appInstanceID + } +} + +/// One phone endpoint whose pairing this Mac has already verified once. +/// +/// The entry pins the complete initiator and acceptor tuples the verified pair +/// grant carried, so allowlist admission preserves exactly the account-scoped +/// binding authority the grant used to prove in-band. `expiresAt` is the +/// signed expiry of the last verified grant: allowlist authority never +/// outlives the credential that established it. +public struct CmxIrohPairedPeerAllowlistEntry: Equatable, Sendable { + public let initiator: CmxIrohGrantPeer + public let acceptor: CmxIrohGrantPeer + public let expiresAt: Date + public let recordedAt: Date + + public init( + initiator: CmxIrohGrantPeer, + acceptor: CmxIrohGrantPeer, + expiresAt: Date, + recordedAt: Date + ) { + self.initiator = initiator + self.acceptor = acceptor + self.expiresAt = expiresAt + self.recordedAt = recordedAt + } +} + +/// Durable Mac-side allowlist of phone EndpointIDs whose pairing was verified. +/// +/// Written once when a pair grant is verified for the first time for a given +/// phone endpoint; read on later connections to admit the TLS-proven remote +/// EndpointID with no in-band credential. Entries are evicted on local revoke, +/// on a definitive online registry denial, on acceptor identity change, and on +/// grant-expiry lapse. Storage follows the host-policy convention: one secure +/// record scoped to the active account, app instance, and bundle namespace. +public actor CmxIrohPairedPeerAllowlist { + private static let storageAccount = "paired-peer-allowlist" + + /// Bounds the persisted record; oldest entries fall off first. + public static let maximumEntryCount = 32 + + private struct StoredPeer: Codable, Equatable { + let bindingID: String + let deviceID: String + let tag: String + let platform: String + let endpointID: String + let identityGeneration: Int + + init(_ peer: CmxIrohGrantPeer) { + bindingID = peer.bindingID + deviceID = peer.deviceID + tag = peer.tag + platform = peer.platform.rawValue + endpointID = peer.endpointID.endpointID + identityGeneration = peer.identityGeneration + } + + func grantPeer() throws -> CmxIrohGrantPeer { + guard let platform = CmxIrohPlatform(rawValue: platform) else { + throw CancellationError() + } + return CmxIrohGrantPeer( + bindingID: bindingID, + deviceID: deviceID, + tag: tag, + platform: platform, + endpointID: try CmxIrohPeerIdentity(endpointID: endpointID), + identityGeneration: identityGeneration + ) + } + } + + private struct StoredEntry: Codable, Equatable { + let initiator: StoredPeer + let acceptor: StoredPeer + let expiresAtSeconds: Int64 + let recordedAtSeconds: Int64 + } + + private struct StoredRecord: Codable, Equatable { + static let currentVersion = 1 + + let version: Int + let scopeDigest: String + let entries: [StoredEntry] + } + + private let secureStore: any CmxIrohSecureCredentialStoring + private var loadedEntries: [StoredEntry]? + private var loadedScopeDigest: String? + private var deactivationCount = 0 + + /// Creates an allowlist with injectable secure storage. + /// + /// The production default uses a Keychain service distinct from host + /// policy and relay credentials, with device-only data protection. + public init( + secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( + service: "com.cmuxterm.iroh.paired-peers.v1" + ) + ) { + self.secureStore = secureStore + } + + /// Records one verified pairing, replacing any prior entry for the same + /// phone endpoint. A no-op when an identical entry is already stored. + public func record( + _ entry: CmxIrohPairedPeerAllowlistEntry, + scope: CmxIrohPairedPeerAllowlistScope, + now: Date + ) async { + guard deactivationCount == 0, + entry.initiator.platform == .ios, + entry.acceptor.platform == .mac, + entry.expiresAt > now else { return } + var entries = await entries(scope: scope) + let stored = StoredEntry( + initiator: StoredPeer(entry.initiator), + acceptor: StoredPeer(entry.acceptor), + expiresAtSeconds: Int64(entry.expiresAt.timeIntervalSince1970.rounded(.down)), + recordedAtSeconds: Int64(entry.recordedAt.timeIntervalSince1970.rounded(.down)) + ) + if let existing = entries.first(where: { + $0.initiator.endpointID == stored.initiator.endpointID + }), existing == stored { + return + } + entries.removeAll { $0.initiator.endpointID == stored.initiator.endpointID } + entries.append(stored) + if entries.count > Self.maximumEntryCount { + entries.sort { $0.recordedAtSeconds < $1.recordedAtSeconds } + entries.removeFirst(entries.count - Self.maximumEntryCount) + } + await persist(entries, scope: scope) + } + + /// Returns the unexpired entry for one TLS-proven phone EndpointID, or + /// `nil` when the endpoint was never paired under this scope. An expired + /// entry is deleted and reported as a miss. + public func entry( + forInitiatorEndpointID endpointID: CmxIrohPeerIdentity, + scope: CmxIrohPairedPeerAllowlistScope, + now: Date + ) async -> CmxIrohPairedPeerAllowlistEntry? { + guard deactivationCount == 0 else { return nil } + let entries = await entries(scope: scope) + guard let stored = entries.first(where: { + $0.initiator.endpointID == endpointID.endpointID + }) else { return nil } + let expiresAt = Date(timeIntervalSince1970: TimeInterval(stored.expiresAtSeconds)) + guard expiresAt > now, + let initiator = try? stored.initiator.grantPeer(), + let acceptor = try? stored.acceptor.grantPeer() else { + await persist( + entries.filter { + $0.initiator.endpointID != endpointID.endpointID + }, + scope: scope + ) + return nil + } + return CmxIrohPairedPeerAllowlistEntry( + initiator: initiator, + acceptor: acceptor, + expiresAt: expiresAt, + recordedAt: Date( + timeIntervalSince1970: TimeInterval(stored.recordedAtSeconds) + ) + ) + } + + /// Removes the entry for one phone endpoint after a definitive refusal. + public func removeEntry( + forInitiatorEndpointID endpointID: CmxIrohPeerIdentity, + scope: CmxIrohPairedPeerAllowlistScope + ) async { + guard deactivationCount == 0 else { return } + let entries = await entries(scope: scope) + let retained = entries.filter { + $0.initiator.endpointID != endpointID.endpointID + } + guard retained.count != entries.count else { return } + await persist(retained, scope: scope) + } + + /// Applies a local revoke: entries whose initiator carries the binding are + /// removed, and a revoke of this Mac's own acceptor binding clears all. + public func removeEntries( + bindingID: String, + scope: CmxIrohPairedPeerAllowlistScope + ) async { + guard deactivationCount == 0 else { return } + let entries = await entries(scope: scope) + let retained = entries.filter { + $0.initiator.bindingID != bindingID && $0.acceptor.bindingID != bindingID + } + guard retained.count != entries.count else { return } + await persist(retained, scope: scope) + } + + /// Removes every entry during sign-out or app-instance revocation. + public func deactivate() async throws { + deactivationCount += 1 + defer { deactivationCount -= 1 } + loadedEntries = nil + loadedScopeDigest = nil + try await secureStore.deleteAll() + } + + private func entries( + scope: CmxIrohPairedPeerAllowlistScope + ) async -> [StoredEntry] { + let digest = Self.scopeDigest(for: scope) + if let loadedEntries, loadedScopeDigest == digest { + return loadedEntries + } + let data = try? await secureStore.read(account: Self.storageAccount) + guard let data, + let record = try? JSONDecoder().decode(StoredRecord.self, from: data), + record.version == StoredRecord.currentVersion, + record.scopeDigest == digest else { + // Wrong scope (account/app-instance transition) or corrupt data: + // the prior owner's entries must not authorize this scope. + loadedEntries = [] + loadedScopeDigest = digest + if data != nil { + try? await secureStore.delete(account: Self.storageAccount) + } + return [] + } + loadedEntries = record.entries + loadedScopeDigest = digest + return record.entries + } + + private func persist( + _ entries: [StoredEntry], + scope: CmxIrohPairedPeerAllowlistScope + ) async { + let digest = Self.scopeDigest(for: scope) + loadedEntries = entries + loadedScopeDigest = digest + guard !entries.isEmpty else { + try? await secureStore.delete(account: Self.storageAccount) + return + } + let record = StoredRecord( + version: StoredRecord.currentVersion, + scopeDigest: digest, + entries: entries + ) + guard let data = try? JSONEncoder().encode(record) else { return } + try? await secureStore.write( + data, + account: Self.storageAccount, + accessibility: .afterFirstUnlockThisDeviceOnly + ) + } + + private static func scopeDigest( + for scope: CmxIrohPairedPeerAllowlistScope + ) -> String { + let transcript = Data( + "cmux/iroh/paired-peer-allowlist-scope/v1\0\(scope.accountID)\0\(scope.clientNamespace)\0\(scope.appInstanceID)".utf8 + ) + return SHA256.hash(data: transcript) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index 527934d61d6a..3b2650760bbf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -34,6 +34,14 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { let verifier: CmxIrohGrantVerifier let now: @Sendable () -> Date var grantCache: [CmxIrohPeerIdentity: CmxIrohRegistryGrantCache] = [:] + /// Macs this phone has completed at least one fully admitted session + /// with. A member's warm dial carries no credential (allowlist admission) + /// and performs zero pair-grant fetches. + var establishedPeers: Set = [] + /// Macs whose last credential-less admission was refused (allowlist miss + /// or eviction). Their next context carries a pair grant again. + var credentialRequiredPeers: Set = [] + private var hydratedEstablishedPeers = false var pairGrantRetryDeadline: (code: String?, date: Date)? var lanAuthorities: [CmxIrohPeerIdentity: CmxIrohRegistryLANAuthority] = [:] private var verifiedDiscoverySnapshot: VerifiedDiscoverySnapshot? @@ -331,6 +339,20 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { throw CmxIrohRegistryContextError.targetNotPairable } replaceLANAuthorities(with: discovery) + // Already-paired Mac: dial credential-less and let the Mac's + // persisted allowlist admit the TLS-proven EndpointID. Zero grant + // HTTP calls on this hot path; a refusal falls back through + // noteAllowlistAdmissionRefused to the grant fetch below. + if !credentialRequiredPeers.contains(targetIdentity), + await isEstablished(targetIdentity) { + return try await context( + targetBinding: targetBinding, + routeHints: routeHints, + directOnly: request.irohDirectOnlyDialCandidates, + pairGrantToken: nil, + at: clock + ) + } let initiator = CmxIrohGrantPeer(binding: localBinding) let acceptor = CmxIrohGrantPeer(binding: targetBinding) let pairGrant: CmxIrohPairGrantResponse @@ -400,6 +422,9 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { authoritativeDiscovery = nil staleDiscoveryPeers.removeAll(keepingCapacity: false) staleDiscoveryDeviceIDs.removeAll(keepingCapacity: false) + establishedPeers.removeAll(keepingCapacity: false) + credentialRequiredPeers.removeAll(keepingCapacity: false) + hydratedEstablishedPeers = false } self.localBindingExpectation = localBindingExpectation self.managedRelayURLs = managedRelayURLs @@ -594,7 +619,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { targetBinding: CmxIrohBrokerBinding, routeHints: [CmxIrohPathHint], directOnly: [CmxIrohDirectDialCandidate]? = nil, - pairGrantToken: String, + pairGrantToken: String?, at clock: Date ) async throws -> CmxIrohClientContext { if let directOnly { @@ -650,7 +675,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } return CmxIrohClientContext( dialPlan: dialPlan, - credential: try .pairGrant(pairGrantToken), + credential: try pairGrantToken.map(CmxIrohAdmissionCredential.pairGrant), privateFallbackAuthorization: fallbackAuthorization ) } @@ -676,7 +701,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { private func directOnlyContext( candidates: [CmxIrohDirectDialCandidate], targetBinding: CmxIrohBrokerBinding, - pairGrantToken: String, + pairGrantToken: String?, at clock: Date ) throws -> CmxIrohClientContext { let peerAlias = DiagnosticCorrelation().handle(for: targetBinding.deviceID) @@ -740,7 +765,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { )) return CmxIrohClientContext( dialPlan: dialPlan, - credential: try .pairGrant(pairGrantToken), + credential: try pairGrantToken.map(CmxIrohAdmissionCredential.pairGrant), privateFallbackAuthorization: nil ) } @@ -1040,6 +1065,64 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } } + /// Records one fully admitted session: later warm dials to this Mac go + /// credential-less and skip the pair-grant fetch entirely. The marker is + /// also persisted through the offline policy cache so it survives + /// relaunch; persistence failure only costs the fast path. + public func noteAdmissionSucceeded(for request: CmxByteTransportRequest) async { + guard request.route.kind == .iroh, + case let .peer(targetIdentity, _) = request.route.endpoint else { + return + } + credentialRequiredPeers.remove(targetIdentity) + guard !establishedPeers.contains(targetIdentity) else { return } + establishedPeers.insert(targetIdentity) + guard let offlinePolicy else { return } + try? await offlinePolicy.cache.setSessionEstablished( + true, + targetEndpointID: targetIdentity, + for: offlinePolicy.expectation, + confirmedLocalBinding: offlinePolicy.localBinding, + now: now() + ) + } + + /// Records a refused credential-less admission: the Mac has no (or a + /// stale) allowlist entry for this phone, so the next context must carry + /// a pair grant again. The persisted marker is cleared so a relaunch does + /// not retry the refused path first. + public func noteAllowlistAdmissionRefused( + for request: CmxByteTransportRequest + ) async { + guard request.route.kind == .iroh, + case let .peer(targetIdentity, _) = request.route.endpoint else { + return + } + establishedPeers.remove(targetIdentity) + credentialRequiredPeers.insert(targetIdentity) + guard let offlinePolicy else { return } + try? await offlinePolicy.cache.setSessionEstablished( + false, + targetEndpointID: targetIdentity, + for: offlinePolicy.expectation, + confirmedLocalBinding: offlinePolicy.localBinding, + now: now() + ) + } + + private func isEstablished(_ identity: CmxIrohPeerIdentity) async -> Bool { + if establishedPeers.contains(identity) { return true } + guard !hydratedEstablishedPeers, let offlinePolicy else { return false } + hydratedEstablishedPeers = true + let persisted = (try? await offlinePolicy.cache.establishedTargetEndpointIDs( + for: offlinePolicy.expectation, + confirmedLocalBinding: offlinePolicy.localBinding, + now: now() + )) ?? [] + establishedPeers.formUnion(persisted) + return establishedPeers.contains(identity) + } + public func invalidateGrant(for identity: CmxIrohPeerIdentity? = nil) { if let identity { grantCache.removeValue(forKey: identity) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift index c83a76d054f2..feb92aa8c5fd 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift @@ -68,13 +68,15 @@ public actor CmxIrohServerSession { from: stream.receiveStream, headerCodec: headerCodec ) - guard decoded.header.lane == .control, - let credential = decoded.header.credential else { + guard decoded.header.lane == .control else { throw CmxIrohServerSessionError.invalidFirstLane } let peerID = await connection.remoteIdentity() + // A nil credential is a valid allowlist-admission request: the + // authorizer decides purely from the TLS-proven identity against + // the Mac's persisted paired-peer allowlist. let authorization = await authorizer.authorize( - credential: credential, + credential: decoded.header.credential, authenticatedPeerID: peerID ) let checkedAuthorization: CmxIrohAdmissionAuthorization diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift index 51c7759ede9f..3ecdb99f5848 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift @@ -3,23 +3,26 @@ public struct CmxIrohStreamHeader: Equatable, Sendable { /// The application lane carried by the stream. public let lane: CmxIrohLane - /// The admission proof, present only on the first control stream. + /// The admission proof carried only on the first control stream. + /// + /// `nil` on a control lane requests allowlist admission: the Mac may admit + /// the TLS-proven EndpointID directly from its persisted paired-peer + /// allowlist, with no in-band credential. public let credential: CmxIrohAdmissionCredential? /// Creates a validated stream header. /// /// - Parameters: /// - lane: The lane this stream will carry. - /// - credential: The control-stream admission proof. + /// - credential: The control-stream admission proof, or `nil` for + /// allowlist admission of an already-paired endpoint. /// - Throws: ``CmxIrohStreamHeaderError`` for an invalid lane and credential combination. public init( lane: CmxIrohLane, credential: CmxIrohAdmissionCredential? = nil ) throws { switch (lane, credential) { - case (.control, nil): - throw CmxIrohStreamHeaderError.missingControlCredential - case (.control, .some): + case (.control, _): break case (_, .some): throw CmxIrohStreamHeaderError.credentialOnNonControlLane diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift index 22f9fb216385..742cede270e0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift @@ -38,7 +38,12 @@ public struct CmxIrohStreamHeaderCodec: Sendable { laneCode = 1 flags = 0 guard let credential = header.credential else { - throw CmxIrohStreamHeaderCodecError.invalidPayload + // Allowlist admission: the control stream declares its lane + // with no in-band credential; authorization rests entirely on + // the TLS-proven EndpointID against the Mac's paired-peer + // allowlist. + credentialCode = 0 + break } switch credential.kind { case .pairGrant: @@ -209,8 +214,11 @@ public struct CmxIrohStreamHeaderCodec: Sendable { private func decodeCredential( code: UInt8, payload: inout CmxIrohBinaryCursor - ) throws -> CmxIrohAdmissionCredential { + ) throws -> CmxIrohAdmissionCredential? { switch code { + case 0: + // Credential-less control stream: allowlist admission request. + return nil case 1: let length = Int(try payload.readUInt16()) return try .pairGrant(payload.readString(byteCount: length)) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift new file mode 100644 index 000000000000..66b4ad4db892 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift @@ -0,0 +1,280 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Mac-side allowlist admission: a grant-verified pairing is persisted once, +/// later connections are admitted from the TLS-proven EndpointID with no +/// admission credential, and revocation or registry removal evicts the entry. +@Suite +struct CmxIrohPairedPeerAdmissionTests { + private struct Harness { + let fixture: OnlineAdmissionFixture + let broker: OnlineAdmissionBroker + let store: TestSecureCredentialStore + let allowlist: CmxIrohPairedPeerAllowlist + let scope: CmxIrohPairedPeerAllowlistScope + let clock: OnlineAdmissionManualClock + let controller: CmxIrohAdmissionController + + init( + fixture: OnlineAdmissionFixture, + responses: [Result], + store: TestSecureCredentialStore = TestSecureCredentialStore() + ) { + self.fixture = fixture + self.store = store + broker = OnlineAdmissionBroker(responses: responses) + allowlist = CmxIrohPairedPeerAllowlist(secureStore: store) + scope = CmxIrohPairedPeerAllowlistScope( + accountID: "account-a", + clientNamespace: "com.cmuxterm.dev", + appInstanceID: "123e4567-e89b-42d3-a456-426614174005" + ) + let clock = OnlineAdmissionManualClock(now: fixture.now) + self.clock = clock + controller = CmxIrohAdmissionController( + acceptor: fixture.acceptor, + pairingEnabled: true, + offlineSessions: CmxIrohOfflinePairingSessions(pairingEnabled: true), + onlineRegistry: fixture.registry(broker: broker, clock: clock), + allowlist: allowlist, + allowlistScope: scope, + now: { clock.now() } + ) + } + } + + @Test + func verifiedGrantRecordsAllowlistEntryAndAdmitsLaterWithoutCredential() async throws { + let fixture = try OnlineAdmissionFixture() + let harness = Harness( + fixture: fixture, + responses: [ + .success(try fixture.discovery()), + .success(try fixture.discovery()), + ] + ) + + // Bootstrap: in-band pair grant, verified and admitted. + let bootstrap = await harness.controller.authorize( + credential: try .pairGrant(fixture.grant()), + authenticatedPeerID: fixture.initiator.endpointID + ) + guard case let .accepted(peer, _) = bootstrap else { + Issue.record("bootstrap grant admission was denied") + return + } + #expect(peer == CmxIrohAdmittedPeer(peer: fixture.initiator)) + let recorded = await harness.allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: harness.scope, + now: fixture.now + ) + #expect(recorded?.initiator == fixture.initiator) + #expect(recorded?.acceptor == fixture.acceptor) + + // Transition: the next connection presents NO credential and is + // admitted purely from the proven EndpointID via the allowlist. + let warm = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + guard case let .accepted(warmPeer, warmLease) = warm else { + Issue.record("allowlist admission was denied") + return + } + #expect(warmPeer == CmxIrohAdmittedPeer(peer: fixture.initiator)) + #expect(warmLease != nil) + } + + @Test + func allowlistAdmissionSurvivesControllerRelaunch() async throws { + let fixture = try OnlineAdmissionFixture() + let store = TestSecureCredentialStore() + let first = Harness( + fixture: fixture, + responses: [.success(try fixture.discovery())], + store: store + ) + _ = await first.controller.authorize( + credential: try .pairGrant(fixture.grant()), + authenticatedPeerID: fixture.initiator.endpointID + ) + + // A fresh controller + allowlist over the same secure store models a + // Mac relaunch: the pairing survives with no new credential needed. + let second = Harness( + fixture: fixture, + responses: [.success(try fixture.discovery())], + store: store + ) + let warm = await second.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(warm.isAcceptedForTest) + } + + @Test + func strangerProvenKeyWithoutCredentialIsRefused() async throws { + let fixture = try OnlineAdmissionFixture() + let harness = Harness( + fixture: fixture, + responses: [.success(try fixture.discovery())] + ) + // A cryptographically proven but never-paired EndpointID gets no + // admission without a credential. + let stranger = try fixture.replacementInitiator() + let refused = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: stranger.endpointID + ) + #expect(refused == .denied(code: 1)) + // No broker round is spent on a stranger's credential-less attempt. + #expect(await harness.broker.callCount() == 0) + } + + @Test + func revokedPairingIsRefusedWithoutCredentialAndWithStaleGrant() async throws { + let fixture = try OnlineAdmissionFixture() + let harness = Harness( + fixture: fixture, + responses: [ + .success(try fixture.discovery()), + .success(try fixture.discovery()), + .success(try fixture.discovery()), + ] + ) + let staleGrant = fixture.grant() + _ = await harness.controller.authorize( + credential: try .pairGrant(staleGrant), + authenticatedPeerID: fixture.initiator.endpointID + ) + + // Unpair: local revoke of the phone binding. + await harness.controller.revoke(bindingID: fixture.initiator.bindingID) + + let warm = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(warm == .denied(code: 1)) + // The allowlist entry is gone, not just ignored. + let entry = await harness.allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: harness.scope, + now: fixture.now + ) + #expect(entry == nil) + + // The evicted key stays refused even when it replays its stale, + // still-signed cached grant. + let replay = await harness.controller.authorize( + credential: try .pairGrant(staleGrant), + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(replay == .denied(code: 1)) + } + + @Test + func registryRemovalEvictsAllowlistEntryOnRevalidation() async throws { + let fixture = try OnlineAdmissionFixture(grantLifetime: 600) + let harness = Harness( + fixture: fixture, + responses: [ + .success(try fixture.discovery()), + // After the unpair, the broker no longer lists the phone. + .success(try fixture.discovery(includeInitiator: false)), + ] + ) + _ = await harness.controller.authorize( + credential: try .pairGrant(fixture.grant()), + authenticatedPeerID: fixture.initiator.endpointID + ) + + // Age the cached broker snapshot past its 30s reuse window so the + // next admission must revalidate against the post-unpair registry. + harness.clock.advance(by: 31) + + let warm = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(warm == .denied(code: 1)) + let entry = await harness.allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: harness.scope, + now: harness.clock.now() + ) + #expect(entry == nil) + } + + @Test + func acceptorIdentityChangeInvalidatesEntries() async throws { + let fixture = try OnlineAdmissionFixture() + let harness = Harness( + fixture: fixture, + responses: [ + .success(try fixture.discovery()), + .success(try fixture.discovery()), + ] + ) + _ = await harness.controller.authorize( + credential: try .pairGrant(fixture.grant()), + authenticatedPeerID: fixture.initiator.endpointID + ) + + // The Mac re-registered under a new binding: entries pinned to the + // old acceptor tuple must not admit anyone. + await harness.controller.update( + keys: fixture.keySet, + acceptor: fixture.replacementAcceptor(), + pairingEnabled: true + ) + let warm = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(warm == .denied(code: 1)) + let entry = await harness.allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: harness.scope, + now: fixture.now + ) + #expect(entry == nil) + } + + @Test + func pairingDisabledRefusesAllowlistAdmission() async throws { + let fixture = try OnlineAdmissionFixture() + let harness = Harness( + fixture: fixture, + responses: [ + .success(try fixture.discovery()), + .success(try fixture.discovery()), + ] + ) + _ = await harness.controller.authorize( + credential: try .pairGrant(fixture.grant()), + authenticatedPeerID: fixture.initiator.endpointID + ) + await harness.controller.update( + keys: fixture.keySet, + acceptor: fixture.acceptor, + pairingEnabled: false + ) + let warm = await harness.controller.authorize( + credential: nil, + authenticatedPeerID: fixture.initiator.endpointID + ) + #expect(warm == .denied(code: 1)) + } +} + +private extension CmxIrohAdmissionAuthorization { + var isAcceptedForTest: Bool { + if case .accepted = self { return true } + return false + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAllowlistTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAllowlistTests.swift new file mode 100644 index 000000000000..76801e0ff47e --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAllowlistTests.swift @@ -0,0 +1,163 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +@Suite +struct CmxIrohPairedPeerAllowlistTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func scope( + accountID: String = "acct-1", + appInstanceID: String = "123e4567-e89b-42d3-a456-426614174005" + ) -> CmxIrohPairedPeerAllowlistScope { + CmxIrohPairedPeerAllowlistScope( + accountID: accountID, + clientNamespace: "com.cmuxterm.dev", + appInstanceID: appInstanceID + ) + } + + private func entry( + fixture: OnlineAdmissionFixture, + lifetime: TimeInterval = 3_600 + ) -> CmxIrohPairedPeerAllowlistEntry { + CmxIrohPairedPeerAllowlistEntry( + initiator: fixture.initiator, + acceptor: fixture.acceptor, + expiresAt: now.addingTimeInterval(lifetime), + recordedAt: now + ) + } + + @Test + func recordedEntryRoundTripsThroughPersistence() async throws { + let fixture = try OnlineAdmissionFixture() + let store = TestSecureCredentialStore() + let scope = scope() + await CmxIrohPairedPeerAllowlist(secureStore: store).record( + entry(fixture: fixture), + scope: scope, + now: now + ) + + // A fresh instance over the same store proves relaunch durability. + let reloaded = CmxIrohPairedPeerAllowlist(secureStore: store) + let found = await reloaded.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: now + ) + #expect(found?.initiator == fixture.initiator) + #expect(found?.acceptor == fixture.acceptor) + } + + @Test + func scopeMismatchIsAMissAndDropsForeignEntries() async throws { + let fixture = try OnlineAdmissionFixture() + let store = TestSecureCredentialStore() + await CmxIrohPairedPeerAllowlist(secureStore: store).record( + entry(fixture: fixture), + scope: scope(accountID: "acct-1"), + now: now + ) + + let otherAccount = CmxIrohPairedPeerAllowlist(secureStore: store) + let found = await otherAccount.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope(accountID: "acct-2"), + now: now + ) + #expect(found == nil) + // The prior account's entries must not survive into the new scope. + let back = await otherAccount.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope(accountID: "acct-1"), + now: now + ) + #expect(back == nil) + } + + @Test + func expiredEntryIsAMissAndIsDeleted() async throws { + let fixture = try OnlineAdmissionFixture() + let store = TestSecureCredentialStore() + let allowlist = CmxIrohPairedPeerAllowlist(secureStore: store) + let scope = scope() + await allowlist.record( + entry(fixture: fixture, lifetime: 60), + scope: scope, + now: now + ) + + let afterExpiry = now.addingTimeInterval(120) + let found = await allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: afterExpiry + ) + #expect(found == nil) + let again = await allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: now + ) + #expect(again == nil) + } + + @Test + func revokedInitiatorBindingIsRemoved() async throws { + let fixture = try OnlineAdmissionFixture() + let allowlist = CmxIrohPairedPeerAllowlist( + secureStore: TestSecureCredentialStore() + ) + let scope = scope() + await allowlist.record(entry(fixture: fixture), scope: scope, now: now) + await allowlist.removeEntries( + bindingID: fixture.initiator.bindingID, + scope: scope + ) + let found = await allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: now + ) + #expect(found == nil) + } + + @Test + func revokedAcceptorBindingClearsItsEntries() async throws { + let fixture = try OnlineAdmissionFixture() + let allowlist = CmxIrohPairedPeerAllowlist( + secureStore: TestSecureCredentialStore() + ) + let scope = scope() + await allowlist.record(entry(fixture: fixture), scope: scope, now: now) + await allowlist.removeEntries( + bindingID: fixture.acceptor.bindingID, + scope: scope + ) + let found = await allowlist.entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: now + ) + #expect(found == nil) + } + + @Test + func deactivateRemovesEverything() async throws { + let fixture = try OnlineAdmissionFixture() + let store = TestSecureCredentialStore() + let allowlist = CmxIrohPairedPeerAllowlist(secureStore: store) + let scope = scope() + await allowlist.record(entry(fixture: fixture), scope: scope, now: now) + try await allowlist.deactivate() + let found = await CmxIrohPairedPeerAllowlist(secureStore: store).entry( + forInitiatorEndpointID: fixture.initiator.endpointID, + scope: scope, + now: now + ) + #expect(found == nil) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift index e79c2edc8c16..b33faf80195b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift @@ -1,3 +1,4 @@ +import CMUXMobileCore import Foundation import Testing @testable import CmuxIrohTransport @@ -39,4 +40,71 @@ struct CmxIrohPairedPeerWireTests { #expect(decoded.header.lane == .control) #expect(decoded.header.credential == nil) } + + /// End-to-end server admission of a credential-less control stream: the + /// authorizer sees credential nil bound to the TLS-proven identity, and + /// the ordinary admission barrier (accept frame, clientReady, serverReady) + /// still runs. + @Test + func serverAdmitsCredentiallessControlStreamThroughAuthorizer() async throws { + let peerID = try CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ) + let admittedPeer = CmxIrohAdmittedPeer( + bindingID: "123e4567-e89b-42d3-a456-426614174001", + deviceID: "123e4567-e89b-42d3-a456-426614174002", + endpointID: peerID, + identityGeneration: 7, + platform: .ios + ) + let authorizer = CredentialRecordingAuthorizer( + authorization: .accepted(admittedPeer, onlineLease: nil) + ) + let codec = try CmxIrohStreamHeaderCodec() + let header = try codec.encode( + CmxIrohStreamHeader(lane: .control, credential: nil) + ) + let controlStream = CmxIrohBidirectionalStream( + receiveStream: TestIrohReceiveStream( + buffer: header + admissionFrame(status: 2) + ), + sendStream: TestIrohSendStream( + eventRecorder: nil, + eventName: "control.send" + ) + ) + let connection = TestIrohConnection( + remoteIdentity: peerID, + bidirectionalStreams: [controlStream] + ) + let server = try CmxIrohServerSession( + connection: connection, + authorizer: authorizer + ) + let peer = try await server.admit() + #expect(peer == admittedPeer) + let observed = await authorizer.observedCredentials() + #expect(observed == [nil]) + } +} + +private actor CredentialRecordingAuthorizer: CmxIrohAdmissionAuthorizing { + private let authorization: CmxIrohAdmissionAuthorization + private var credentials: [CmxIrohAdmissionCredential?] = [] + + init(authorization: CmxIrohAdmissionAuthorization) { + self.authorization = authorization + } + + func authorize( + credential: CmxIrohAdmissionCredential?, + authenticatedPeerID _: CmxIrohPeerIdentity + ) -> CmxIrohAdmissionAuthorization { + credentials.append(credential) + return authorization + } + + func observedCredentials() -> [CmxIrohAdmissionCredential?] { + credentials + } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift index 09b015236f32..4ecbda1b1cdb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift @@ -89,7 +89,7 @@ struct CmxIrohPrivatePathTransportGateTests { && $0.privacyScope == .privateNetwork && $0.networkProfile == profile }) - #expect(context.credential.kind == .pairGrant) + #expect(context.credential?.kind == .pairGrant) let authorizer = admissionController( fixture: fixture, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift index 04ba1333439c..b41117203fcb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift @@ -120,8 +120,8 @@ extension CmxIrohRegistryContextProviderTests { #expect(context.dialPlan.publicPaths == [managedRelay]) #expect(context.dialPlan.privateFallbackPaths == [tailscale]) - #expect(context.credential.kind == .pairGrant) - #expect(context.credential.pairGrantToken == response.grant) + #expect(context.credential?.kind == .pairGrant) + #expect(context.credential?.pairGrantToken == response.grant) let authorization = try #require(context.privateFallbackAuthorization) #expect(authorization.networkPathSnapshot == pathSnapshot) #expect(authorization.pathHints == [tailscale]) @@ -274,12 +274,12 @@ extension CmxIrohRegistryContextProviderTests { ) let request = try fixture.request(hints: []) - #expect(try await provider.context(for: request).credential.pairGrantToken == first.grant) - #expect(try await provider.context(for: request).credential.pairGrantToken == first.grant) + #expect(try await provider.context(for: request).credential?.pairGrantToken == first.grant) + #expect(try await provider.context(for: request).credential?.pairGrantToken == first.grant) #expect(await broker.pairGrantRequestCount() == 1) clock.set(refreshedAt) - #expect(try await provider.context(for: request).credential.pairGrantToken == second.grant) + #expect(try await provider.context(for: request).credential?.pairGrantToken == second.grant) #expect(await broker.pairGrantRequestCount() == 2) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPairedTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPairedTests.swift new file mode 100644 index 000000000000..2d2305d494cb --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPairedTests.swift @@ -0,0 +1,154 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Phone-side warm dial: once a session has been fully admitted, later dials +/// to the same Mac carry no credential and perform zero pair-grant fetches; +/// a refused allowlist admission falls back to the grant path. +@Suite +struct CmxIrohRegistryContextProviderPairedTests { + private func seededCache( + fixture: RegistryFixture, + discovery: CmxIrohDiscoveryResponse, + store: TestSecureCredentialStore + ) async throws -> CmxIrohClientOfflinePolicyContext { + let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) + let expectation = try fixture.offlineExpectation() + try await cache.save( + localBinding: discovery.bindings[0], + targetBinding: discovery.bindings[1], + discovery: discovery, + pairGrant: try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ), + for: expectation, + now: fixture.now + ) + return try CmxIrohClientOfflinePolicyContext( + cache: cache, + expectation: expectation, + localBinding: discovery.bindings[0] + ) + } + + @Test + func warmDialAfterAdmissionCarriesNoCredentialAndFetchesNoGrant() async throws { + let fixture = try RegistryFixture() + let discovery = try fixture.discovery(targetHints: []) + let broker = TestIrohRegistryBroker( + discovery: discovery, + pairGrantResponses: [ + try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ), + ] + ) + let provider = CmxIrohRegistryContextProvider( + supervisor: try await fixture.activeSupervisor(), + broker: broker, + localBindingExpectation: try fixture.localExpectation(), + managedRelayURLs: [fixture.relayURL], + activeNetworkProfiles: { [] }, + now: { fixture.now } + ) + let request = try fixture.request(hints: []) + + // Bootstrap dial fetches the grant. + let bootstrap = try await provider.context(for: request) + #expect(bootstrap.credential != nil) + #expect(await broker.pairGrantRequestCount() == 1) + + // The session was fully admitted: later dials go credential-less + // with ZERO further pair-grant HTTP calls. + await provider.noteAdmissionSucceeded(for: request) + let warm = try await provider.context(for: request) + #expect(warm.credential == nil) + #expect(await broker.pairGrantRequestCount() == 1) + } + + @Test + func establishedMarkerPersistsAcrossProviderRelaunch() async throws { + let fixture = try RegistryFixture() + let discovery = try fixture.discovery(targetHints: []) + let store = TestSecureCredentialStore() + let offlinePolicy = try await seededCache( + fixture: fixture, + discovery: discovery, + store: store + ) + let firstBroker = TestIrohRegistryBroker( + discovery: discovery, + pairGrantResponses: [] + ) + let first = CmxIrohRegistryContextProvider( + supervisor: try await fixture.activeSupervisor(), + broker: firstBroker, + localBindingExpectation: try fixture.localExpectation(), + managedRelayURLs: [fixture.relayURL], + activeNetworkProfiles: { [] }, + offlinePolicy: offlinePolicy, + now: { fixture.now } + ) + await first.noteAdmissionSucceeded(for: try fixture.request(hints: [])) + + // A fresh provider (app relaunch) over the same offline cache dials + // credential-less immediately: zero grant HTTP on the cold warm dial. + let secondBroker = TestIrohRegistryBroker( + discovery: discovery, + pairGrantResponses: [] + ) + let second = CmxIrohRegistryContextProvider( + supervisor: try await fixture.activeSupervisor(), + broker: secondBroker, + localBindingExpectation: try fixture.localExpectation(), + managedRelayURLs: [fixture.relayURL], + activeNetworkProfiles: { [] }, + offlinePolicy: try CmxIrohClientOfflinePolicyContext( + cache: CmxIrohClientOfflinePolicyCache(secureStore: store), + expectation: try fixture.offlineExpectation(), + localBinding: discovery.bindings[0] + ), + now: { fixture.now } + ) + let warm = try await second.context(for: try fixture.request(hints: [])) + #expect(warm.credential == nil) + #expect(await secondBroker.pairGrantRequestCount() == 0) + } + + @Test + func refusedAllowlistAdmissionFallsBackToGrantFetch() async throws { + let fixture = try RegistryFixture() + let discovery = try fixture.discovery(targetHints: []) + let broker = TestIrohRegistryBroker( + discovery: discovery, + pairGrantResponses: [ + try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ), + ] + ) + let provider = CmxIrohRegistryContextProvider( + supervisor: try await fixture.activeSupervisor(), + broker: broker, + localBindingExpectation: try fixture.localExpectation(), + managedRelayURLs: [fixture.relayURL], + activeNetworkProfiles: { [] }, + now: { fixture.now } + ) + let request = try fixture.request(hints: []) + await provider.noteAdmissionSucceeded(for: request) + #expect(try await provider.context(for: request).credential == nil) + + // The Mac refused the credential-less dial (allowlist miss): the + // provider re-arms the bootstrap path and the next context carries a + // freshly fetched grant. + await provider.noteAllowlistAdmissionRefused(for: request) + let fallback = try await provider.context(for: request) + #expect(fallback.credential?.kind == .pairGrant) + #expect(await broker.pairGrantRequestCount() == 1) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift index ae617b096acb..4ddd8e2cee16 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift @@ -111,7 +111,7 @@ extension CmxIrohRegistryContextProviderTests { expectedPeerDeviceID: fixture.acceptor.deviceID.uppercased() )) - #expect(context.credential.pairGrantToken == response.grant) + #expect(context.credential?.pairGrantToken == response.grant) #expect(await broker.pairGrantRequestCount() == 1) } @@ -183,7 +183,7 @@ extension CmxIrohRegistryContextProviderTests { let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.credential.pairGrantToken == grant.grant) + #expect(context.credential?.pairGrantToken == grant.grant) #expect(await store.readCount() > 0) } @@ -228,7 +228,7 @@ extension CmxIrohRegistryContextProviderTests { let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.credential.pairGrantToken == grant.grant) + #expect(context.credential?.pairGrantToken == grant.grant) #expect(await broker.pairGrantRequestCount() == 1) } @@ -275,7 +275,7 @@ extension CmxIrohRegistryContextProviderTests { let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.credential.pairGrantToken == grant.grant) + #expect(context.credential?.pairGrantToken == grant.grant) #expect(await broker.discoveryRequestCount() == 1) #expect(await broker.pairGrantRequestCount() == 1) #expect(await store.readCount() > 0) @@ -387,7 +387,11 @@ extension CmxIrohRegistryContextProviderTests { } #expect(await broker.pairGrantRequestCount() == 1) - #expect(await seeded.store.readCount() == readsBeforeDial) + // Exactly one extra store read is allowed: the one-time hydration of + // the established-session markers for allowlist admission. The cached + // GRANT itself is still never consulted after an unauthorized + // rejection (no cached-policy dial, no new record writes). + #expect(await seeded.store.readCount() == readsBeforeDial + 1) #expect(await seeded.store.recordCount() == 1) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift index b4645b6cffaa..53e4c656fdf9 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift @@ -47,7 +47,7 @@ actor FixedAdmissionAuthorizer: CmxIrohAdmissionAuthorizing { } func authorize( - credential _: CmxIrohAdmissionCredential, + credential _: CmxIrohAdmissionCredential?, authenticatedPeerID _: CmxIrohPeerIdentity ) -> CmxIrohAdmissionAuthorization { observedCalls += 1 diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift index 615c8faa3e66..c7a9609bd8e5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift @@ -55,10 +55,10 @@ struct CmxIrohStreamHeaderCodecTests { } @Test - func controlRequiresCredentialAndOtherLanesRejectIt() throws { - #expect(throws: CmxIrohStreamHeaderError.missingControlCredential) { - try CmxIrohStreamHeader(lane: .control) - } + func controlAllowsCredentiallessAllowlistAdmissionAndOtherLanesRejectCredentials() throws { + // Credential-less control is the allowlist-admission request. + let allowlistHeader = try CmxIrohStreamHeader(lane: .control) + #expect(allowlistHeader.credential == nil) let credential = try CmxIrohAdmissionCredential.pairGrant("e30.e30.AA") #expect(throws: CmxIrohStreamHeaderError.credentialOnNonControlLane) { diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift index b14622aa595c..b27158a87da9 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift @@ -253,6 +253,7 @@ extension MobileHostIrohRuntime { broker: broker, configuration: configuration, pendingRevocations: pendingRevocations, + pairedPeerAllowlist: pairedPeers, protocolConfiguration: protocolConfiguration, handleTransport: { [weak self] session, isCurrent in guard let self else { diff --git a/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift b/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift index 5f13922a280a..e286c20fd644 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift @@ -556,6 +556,13 @@ extension MobileHostIrohRuntime { "Iroh offline policy deletion failed: \(String(describing: error), privacy: .private)" ) } + do { + try await pairedPeers.deactivate() + } catch { + mobileHostIrohLog.error( + "Iroh paired-peer allowlist deletion failed: \(String(describing: error), privacy: .private)" + ) + } do { try await brokerCredentials.deactivate() } catch { diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index fd4ae3ff76d1..097ae2c365b0 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -90,6 +90,7 @@ final class MobileHostIrohRuntime { let brokerCredentials: CmxIrohBrokerCredentialRepository let brokerBackpressureGate: CmxIrohBrokerBackpressureGate let hostPolicies: CmxIrohHostPolicyCache + let pairedPeers: CmxIrohPairedPeerAllowlist let pendingRevocations: CmxIrohPendingRevocationOutbox let customRelayProfiles: CmxIrohCustomRelayProfileStore let relayPolicyCache: CmxIrohRelayPolicyCache @@ -180,6 +181,11 @@ final class MobileHostIrohRuntime { directory: Self.developmentStoreDirectory(service: "host-policy") ) ) + pairedPeers = CmxIrohPairedPeerAllowlist( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "paired-peers") + ) + ) pendingRevocations = CmxIrohPendingRevocationOutbox( secureStore: CmxIrohDevelopmentFileCredentialStore( directory: Self.developmentStoreDirectory( @@ -213,6 +219,7 @@ final class MobileHostIrohRuntime { installState: installState ) hostPolicies = CmxIrohHostPolicyCache() + pairedPeers = CmxIrohPairedPeerAllowlist() pendingRevocations = CmxIrohPendingRevocationOutbox( secureStore: CmxIrohKeychainCredentialStore( service: "com.cmuxterm.iroh.pending-revocations.v1" From b4adf832b7de664797b866f91796dc0fc134255c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:37:42 -0700 Subject: [PATCH 60/71] test: red tests for client cache-first activation and warm cache-first dials A warm client (verified cached binding + verified offline route record) must activate with zero blocking broker rounds, and a warm dial covered by the offline record must be served from cache with the discovery refresh running behind the dial. The immediate authenticated refresh fails closed per the existing taxonomy (non-transient rejection tears down and wipes cached policy), staleness evidence still bypasses every cached source, and a background refresh that proves the cached target vanished marks the peer stale. These tests fail on the current head: activation blocks on the overlapped discovery sync, and dials block on a live broker snapshot. --- .../CmxIrohClientRuntimeCacheFirstTests.swift | 248 ++++++++++++++++++ .../CmxIrohClientRuntimeTests.swift | 2 +- ...gistryContextProviderCacheFirstTests.swift | 219 ++++++++++++++++ ...egistryContextProviderStalenessTests.swift | 6 + 4 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift new file mode 100644 index 000000000000..d14bb4d8daab --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift @@ -0,0 +1,248 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Warm-client cache-first activation: a client holding a verified cached +/// binding AND a verified offline route record activates with ZERO blocking +/// broker rounds. The authenticated registration refresh runs immediately +/// behind activation and fails closed on a non-transient rejection, mirroring +/// the Mac host's cache-first activation (cmux#10737). +@Suite +struct CmxIrohClientRuntimeCacheFirstTests { + /// The broker is completely hung (discovery sync and registration both + /// block forever). A warm client must still reach `.active` from its + /// verified caches and install the cached target routes. + @Test + func warmStartActivatesFromVerifiedCacheWhileBrokerIsHung() async throws { + let seed = try await CacheFirstRuntimeSeed() + let broker = TestRevisionedClientBroker( + binding: seed.localBinding, + discoveries: [ + try seed.fixture.discovery(targetHints: [], revision: 1), + try seed.fixture.discovery(targetHints: [], revision: 1), + ], + blockedSyncCount: 1, + blockedRegistrationCount: 1, + registrationRevision: 1 + ) + let recorder = ClientRuntimeTestRecorder() + let runtime = try CmxIrohClientRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + TestIrohEndpoint(identity: seed.fixture.initiator.endpointID), + ]), + broker: broker, + configuration: seed.configuration, + pendingRevocations: CmxIrohPendingRevocationOutbox( + secureStore: TestSecureCredentialStore() + ), + offlinePolicyCache: seed.cache, + now: { seed.fixture.now }, + handleCachedBindings: { bindings, _ in + await recorder.recordCachedBindings(bindings) + } + ) + + let start = Task { try await runtime.start() } + var activatedWhileBrokerHung = false + for _ in 0 ..< 50_000 { + if await runtime.snapshot().state == .active { + activatedWhileBrokerHung = true + break + } + await Task.yield() + } + #expect(activatedWhileBrokerHung) + #expect( + await recorder.observedCachedBindingDeviceIDs() + == [[seed.fixture.acceptor.deviceID]] + ) + + await broker.releaseBlockedSync() + await broker.releaseBlockedRegistration() + try? await start.value + // The immediate background refresh re-authenticates the cached + // binding once the broker recovers. + await broker.waitUntilRegistrationCount(1) + for _ in 0 ..< 50_000 { + if await runtime.liveDiscoverySnapshotGeneration() >= 1 { break } + await Task.yield() + } + #expect(await runtime.liveDiscoverySnapshotGeneration() >= 1) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// #10737 semantics: cache-first activation succeeds, then the immediate + /// authenticated refresh is rejected non-transiently (403). The runtime + /// must tear down, invalidate persisted policy, and wipe the offline + /// route cache instead of staying up on withdrawn authority. + @Test + func cacheFirstActivationFailsClosedWhenImmediateRefreshIsRejected() async throws { + let seed = try await CacheFirstRuntimeSeed() + let broker = TestRevisionedClientBroker( + binding: seed.localBinding, + discoveries: [], + registrationError: .rejected(statusCode: 403, code: "binding_revoked") + ) + let recorder = ClientRuntimeTestRecorder() + let runtime = try CmxIrohClientRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + TestIrohEndpoint(identity: seed.fixture.initiator.endpointID), + ]), + broker: broker, + configuration: seed.configuration, + pendingRevocations: CmxIrohPendingRevocationOutbox( + secureStore: TestSecureCredentialStore() + ), + offlinePolicyCache: seed.cache, + now: { seed.fixture.now }, + handlePolicyInvalidation: { + await recorder.recordPolicyInvalidation() + } + ) + + try await runtime.start() + #expect(await runtime.snapshot().state == .active) + + await broker.waitUntilRegistrationCount(1) + var failedClosed = false + for _ in 0 ..< 50_000 { + if await runtime.snapshot().state == .failed { + failedClosed = true + break + } + await Task.yield() + } + #expect(failedClosed) + #expect(await recorder.observedPolicyInvalidationCount() == 1) + #expect(await seed.store.recordCount() == 0) + } + + /// Transient refresh failures preserve the cache-first activation: a + /// connectivity-failing broker cannot tear down verified local state. + @Test + func cacheFirstActivationSurvivesConnectivityOnlyRefreshFailures() async throws { + let seed = try await CacheFirstRuntimeSeed() + let broker = TestRevisionedClientBroker( + binding: seed.localBinding, + discoveries: [], + registrationError: .connectivity + ) + let runtime = try CmxIrohClientRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + TestIrohEndpoint(identity: seed.fixture.initiator.endpointID), + ]), + broker: broker, + configuration: seed.configuration, + pendingRevocations: CmxIrohPendingRevocationOutbox( + secureStore: TestSecureCredentialStore() + ), + offlinePolicyCache: seed.cache, + now: { seed.fixture.now } + ) + + try await runtime.start() + #expect(await runtime.snapshot().state == .active) + + await broker.waitUntilRegistrationCount(1) + // Give the failed refresh a chance to (incorrectly) tear down. + for _ in 0 ..< 2_000 { + await Task.yield() + } + #expect(await runtime.snapshot().state == .active) + #expect(await seed.store.recordCount() == 1) + await runtime.stop() + } + + /// A cached broker binding WITHOUT a verified offline route record keeps + /// today's ordering: activation waits for the overlapped discovery sync. + @Test + func cachedBindingWithoutOfflineRecordStillRequiresLiveDiscovery() async throws { + let seed = try await CacheFirstRuntimeSeed(seedOfflineRecord: false) + let broker = TestRevisionedClientBroker( + binding: seed.localBinding, + discoveries: [ + try seed.fixture.discovery(targetHints: [], revision: 1), + try seed.fixture.discovery(targetHints: [], revision: 1), + ], + blockedSyncCount: 1, + registrationRevision: 1 + ) + let runtime = try CmxIrohClientRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + TestIrohEndpoint(identity: seed.fixture.initiator.endpointID), + ]), + broker: broker, + configuration: seed.configuration, + pendingRevocations: CmxIrohPendingRevocationOutbox( + secureStore: TestSecureCredentialStore() + ), + offlinePolicyCache: seed.cache, + now: { seed.fixture.now } + ) + + let start = Task { try await runtime.start() } + await broker.waitUntilSyncCount(1) + for _ in 0 ..< 2_000 { + await Task.yield() + } + // The discovery sync is still hung, so activation must not complete. + #expect(await runtime.snapshot().state == .starting) + + await broker.releaseBlockedSync() + try await start.value + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } +} + +/// A warm client's persisted state: a broker binding metadata cache plus a +/// signed offline route record whose pair grant verifies against the stored +/// key set. +struct CacheFirstRuntimeSeed { + let fixture: RegistryFixture + let localBinding: CmxIrohBrokerBinding + let targetBinding: CmxIrohBrokerBinding + let store: TestSecureCredentialStore + let cache: CmxIrohClientOfflinePolicyCache + let configuration: CmxIrohClientRuntimeConfiguration + + init(seedOfflineRecord: Bool = true) async throws { + fixture = try RegistryFixture() + let discovery = try fixture.discovery(targetHints: [], revision: 1) + localBinding = discovery.bindings[0] + targetBinding = discovery.bindings[1] + store = TestSecureCredentialStore() + cache = CmxIrohClientOfflinePolicyCache(secureStore: store) + if seedOfflineRecord { + try await cache.save( + localBinding: localBinding, + targetBinding: targetBinding, + discovery: discovery, + pairGrant: fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 3_600 + ), + for: fixture.offlineExpectation(), + now: fixture.now + ) + } + let identity = try CmxIrohIdentityMaterial( + secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), + generation: fixture.initiator.identityGeneration + ) + configuration = CmxIrohClientRuntimeConfiguration( + accountID: "account-a", + deviceID: fixture.initiator.deviceID, + appInstanceID: localBinding.appInstanceID, + clientNamespace: localBinding.clientNamespace, + tag: fixture.initiator.tag, + displayName: nil, + identity: identity, + capabilities: localBinding.capabilities, + managedRelayURLs: [fixture.relayURL], + cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) + ) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index 2d4017d8fd8d..b352c406e5fa 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -1435,7 +1435,7 @@ struct CmxIrohClientRuntimeTests { } -private actor TestRevisionedClientBroker: +actor TestRevisionedClientBroker: CmxIrohClientBrokerServing, CmxConnectivityAuthorityServing { diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift new file mode 100644 index 000000000000..74353c4da212 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift @@ -0,0 +1,219 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Warm-dial cache-first behavior: a dial whose exact target tuple is covered +/// by the verified offline route record is served immediately from that +/// record, with the broker discovery refresh running BEHIND the dial instead +/// of in front of it. Staleness evidence (cmux#10739/#10865) still bypasses +/// every cached source and forces a fresh broker snapshot. +@Suite +struct CmxIrohRegistryContextProviderCacheFirstTests { + @Test + func warmDialServesCachedRecordWithoutBlockingBrokerRounds() async throws { + let fixture = try RegistryFixture() + let seeded = try await seedOfflinePolicy(fixture: fixture) + // The broker rejects discovery outright: under dial-blocking + // discovery this dial could not succeed at all. + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: []), + pairGrantResponses: [] + ) + await broker.setDiscoverError( + CmxIrohTrustBrokerClientError.rejected(statusCode: 503, code: nil) + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + offlinePolicy: seeded.policy + ) + + let context = try await provider.context(for: fixture.request(hints: [])) + + #expect(context.credential.pairGrantToken == seeded.grant.grant) + #expect(await broker.pairGrantRequestCount() == 0) + } + + /// The cache-first dial arms one background discovery refresh so the + /// route cache converges behind the dial instead of staying frozen. + @Test + func cacheFirstDialRefreshesDiscoveryBehindTheDial() async throws { + let fixture = try RegistryFixture() + let seeded = try await seedOfflinePolicy(fixture: fixture) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [], revision: 3), + pairGrantResponses: [] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + offlinePolicy: seeded.policy + ) + + let context = try await provider.context(for: fixture.request(hints: [])) + #expect(context.credential.pairGrantToken == seeded.grant.grant) + + var refreshed = false + for _ in 0 ..< 50_000 { + if await broker.discoveryRequestCount() >= 1 { + refreshed = true + break + } + await Task.yield() + } + #expect(refreshed) + #expect(await broker.pairGrantRequestCount() == 0) + } + + /// Staleness evidence from a failed dial beats every cached source: the + /// next dial must fetch a fresh snapshot and rebuild, not redial the + /// cached corpse route. + @Test + func staleEvidenceBypassesCachedRecordAndForcesFreshDiscovery() async throws { + let fixture = try RegistryFixture() + let seeded = try await seedOfflinePolicy(fixture: fixture) + let relay = try CmxIrohPathHint( + kind: .relayURL, + value: fixture.relayURL, + source: .native, + privacyScope: .publicInternet, + observedAt: fixture.now, + expiresAt: fixture.now.addingTimeInterval(60) + ) + let freshGrant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [freshGrant] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + offlinePolicy: seeded.policy + ) + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try testIrohDialPlan(publicPaths: []), + failure: .timedOut + ) + + let context = try await provider.context(for: fixture.request(hints: [])) + + #expect(await broker.discoveryRequestCount() == 1) + #expect(context.dialPlan.publicPaths == [relay]) + } + + /// A background refresh that proves the cached target vanished (revoked + /// or replaced server-side) marks the peer stale, so the NEXT dial + /// rebuilds from fresh discovery instead of reusing the dead record. + @Test + func backgroundRefreshEvidenceMarksVanishedTargetStale() async throws { + let fixture = try RegistryFixture() + let seeded = try await seedOfflinePolicy(fixture: fixture) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [], includeTarget: false), + pairGrantResponses: [] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + offlinePolicy: seeded.policy + ) + + // First dial is served cache-first; its background refresh sees the + // target dropped from the account. + _ = try await provider.context(for: fixture.request(hints: [])) + var refreshed = false + for _ in 0 ..< 50_000 { + if await broker.discoveryRequestCount() >= 1 { + refreshed = true + break + } + await Task.yield() + } + #expect(refreshed) + // Let the refresh's staleness verdict land in actor state. + for _ in 0 ..< 2_000 { + await Task.yield() + } + + // The next dial must NOT be served from the cached record: the fresh + // snapshot (fetched because the peer is stale) has no such target. + await #expect(throws: CmxIrohRegistryContextError.targetBindingUnavailable) { + try await provider.context(for: fixture.request(hints: [])) + } + #expect(await broker.discoveryRequestCount() == 2) + } + + // MARK: - Support + + private func makeProvider( + fixture: RegistryFixture, + broker: any CmxIrohRegistryServing, + offlinePolicy: CmxIrohClientOfflinePolicyContext?, + verifiedDiscovery: CmxIrohDiscoveryResponse? = nil + ) async throws -> CmxIrohRegistryContextProvider { + CmxIrohRegistryContextProvider( + supervisor: try await fixture.activeSupervisor(), + broker: broker, + localBindingExpectation: try fixture.localExpectation(), + managedRelayURLs: [fixture.relayURL], + networkPathSnapshot: { + CmxIrohNetworkPathSnapshot( + generation: 1, + activeNetworkProfiles: [] + ) + }, + offlinePolicy: offlinePolicy, + verifiedDiscovery: verifiedDiscovery, + now: { fixture.now } + ) + } + + private func seedOfflinePolicy( + fixture: RegistryFixture + ) async throws -> ( + store: TestSecureCredentialStore, + policy: CmxIrohClientOfflinePolicyContext, + grant: CmxIrohPairGrantResponse + ) { + // The stored target carries a live relay hint so a cache-first dial + // has a usable plan without a broker round. + let storedRelayHint = try CmxIrohPathHint( + kind: .relayURL, + value: fixture.relayURL, + source: .native, + privacyScope: .publicInternet, + observedAt: fixture.now, + expiresAt: fixture.now.addingTimeInterval(60) + ) + let discovery = try fixture.discovery(targetHints: [storedRelayHint]) + let grant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let store = TestSecureCredentialStore() + let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) + let expectation = try fixture.offlineExpectation() + try await cache.save( + localBinding: discovery.bindings[0], + targetBinding: discovery.bindings[1], + discovery: discovery, + pairGrant: grant, + for: expectation, + now: fixture.now + ) + return ( + store, + try CmxIrohClientOfflinePolicyContext( + cache: cache, + expectation: expectation, + localBinding: discovery.bindings[0] + ), + grant + ) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift index 620b7594f3e0..20c6484e1815 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift @@ -651,6 +651,7 @@ actor ConfigurableRegistryBroker: CmxIrohRegistryServing { private var pairGrantResponses: [CmxIrohPairGrantResponse] private var discoverError: (any Error)? private var completedDiscoverCalls = 0 + private var completedPairGrantCalls = 0 private var holdDiscover = false private var heldDiscoverContinuations: [CheckedContinuation] = [] @@ -677,12 +678,17 @@ actor ConfigurableRegistryBroker: CmxIrohRegistryServing { initiatorBindingID _: String, acceptorBindingID _: String ) throws -> CmxIrohPairGrantResponse { + completedPairGrantCalls += 1 guard !pairGrantResponses.isEmpty else { throw TestRegistryError.noGrantResponse } return pairGrantResponses.removeFirst() } + func pairGrantRequestCount() -> Int { + completedPairGrantCalls + } + func setDiscovery(_ discovery: CmxIrohDiscoveryResponse) { discoveryResponse = discovery } From 5dabef1e39e7eb85c3fff5fe2914a1d444c02197 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:44:07 -0700 Subject: [PATCH 61/71] iroh: review round 1 policy fixes for the paired-peer allowlist Split CmxIrohPairedPeerAllowlist.swift into one-major-type files with Swift-DocC on every public symbol, move the scope digest to a file-scope private helper, and lift the shared test authorizer into its own file. No behavior change; 661 transport tests still pass. --- .../CmxIrohPairedPeerAllowlist.swift | 90 ++++++++----------- .../CmxIrohPairedPeerAllowlistEntry.swift | 41 +++++++++ .../CmxIrohPairedPeerAllowlistScope.swift | 31 +++++++ .../CmxIrohPairedPeerAdmissionTests.swift | 12 +-- .../CmxIrohPairedPeerWireTests.swift | 21 ----- .../CredentialRecordingAuthorizer.swift | 25 ++++++ 6 files changed, 136 insertions(+), 84 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistEntry.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistScope.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CredentialRecordingAuthorizer.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift index b675c1aa4c17..7b2971fffd72 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift @@ -2,49 +2,6 @@ import CryptoKit public import CMUXMobileCore public import Foundation -/// The Mac-local account, app, and namespace scope owning one allowlist store. -public struct CmxIrohPairedPeerAllowlistScope: Equatable, Sendable { - public let accountID: String - public let clientNamespace: String - public let appInstanceID: String - - public init( - accountID: String, - clientNamespace: String, - appInstanceID: String - ) { - self.accountID = accountID - self.clientNamespace = clientNamespace - self.appInstanceID = appInstanceID - } -} - -/// One phone endpoint whose pairing this Mac has already verified once. -/// -/// The entry pins the complete initiator and acceptor tuples the verified pair -/// grant carried, so allowlist admission preserves exactly the account-scoped -/// binding authority the grant used to prove in-band. `expiresAt` is the -/// signed expiry of the last verified grant: allowlist authority never -/// outlives the credential that established it. -public struct CmxIrohPairedPeerAllowlistEntry: Equatable, Sendable { - public let initiator: CmxIrohGrantPeer - public let acceptor: CmxIrohGrantPeer - public let expiresAt: Date - public let recordedAt: Date - - public init( - initiator: CmxIrohGrantPeer, - acceptor: CmxIrohGrantPeer, - expiresAt: Date, - recordedAt: Date - ) { - self.initiator = initiator - self.acceptor = acceptor - self.expiresAt = expiresAt - self.recordedAt = recordedAt - } -} - /// Durable Mac-side allowlist of phone EndpointIDs whose pairing was verified. /// /// Written once when a pair grant is verified for the first time for a given @@ -115,6 +72,8 @@ public actor CmxIrohPairedPeerAllowlist { /// /// The production default uses a Keychain service distinct from host /// policy and relay credentials, with device-only data protection. + /// + /// - Parameter secureStore: The secure persistence boundary. public init( secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( service: "com.cmuxterm.iroh.paired-peers.v1" @@ -125,6 +84,11 @@ public actor CmxIrohPairedPeerAllowlist { /// Records one verified pairing, replacing any prior entry for the same /// phone endpoint. A no-op when an identical entry is already stored. + /// + /// - Parameters: + /// - entry: The verified initiator and acceptor tuples plus expiry. + /// - scope: The active account, namespace, and app-instance owner. + /// - now: The verification time; already-expired entries are dropped. public func record( _ entry: CmxIrohPairedPeerAllowlistEntry, scope: CmxIrohPairedPeerAllowlistScope, @@ -158,6 +122,11 @@ public actor CmxIrohPairedPeerAllowlist { /// Returns the unexpired entry for one TLS-proven phone EndpointID, or /// `nil` when the endpoint was never paired under this scope. An expired /// entry is deleted and reported as a miss. + /// + /// - Parameters: + /// - endpointID: The remote identity proven by the QUIC handshake. + /// - scope: The active account, namespace, and app-instance owner. + /// - now: The admission time used for the expiry check. public func entry( forInitiatorEndpointID endpointID: CmxIrohPeerIdentity, scope: CmxIrohPairedPeerAllowlistScope, @@ -191,6 +160,10 @@ public actor CmxIrohPairedPeerAllowlist { } /// Removes the entry for one phone endpoint after a definitive refusal. + /// + /// - Parameters: + /// - endpointID: The refused entry's initiator EndpointID. + /// - scope: The active account, namespace, and app-instance owner. public func removeEntry( forInitiatorEndpointID endpointID: CmxIrohPeerIdentity, scope: CmxIrohPairedPeerAllowlistScope @@ -206,6 +179,10 @@ public actor CmxIrohPairedPeerAllowlist { /// Applies a local revoke: entries whose initiator carries the binding are /// removed, and a revoke of this Mac's own acceptor binding clears all. + /// + /// - Parameters: + /// - bindingID: The locally revoked broker binding. + /// - scope: The active account, namespace, and app-instance owner. public func removeEntries( bindingID: String, scope: CmxIrohPairedPeerAllowlistScope @@ -220,6 +197,8 @@ public actor CmxIrohPairedPeerAllowlist { } /// Removes every entry during sign-out or app-instance revocation. + /// + /// - Throws: A secure-storage error. public func deactivate() async throws { deactivationCount += 1 defer { deactivationCount -= 1 } @@ -231,7 +210,7 @@ public actor CmxIrohPairedPeerAllowlist { private func entries( scope: CmxIrohPairedPeerAllowlistScope ) async -> [StoredEntry] { - let digest = Self.scopeDigest(for: scope) + let digest = pairedPeerAllowlistScopeDigest(for: scope) if let loadedEntries, loadedScopeDigest == digest { return loadedEntries } @@ -258,7 +237,7 @@ public actor CmxIrohPairedPeerAllowlist { _ entries: [StoredEntry], scope: CmxIrohPairedPeerAllowlistScope ) async { - let digest = Self.scopeDigest(for: scope) + let digest = pairedPeerAllowlistScopeDigest(for: scope) loadedEntries = entries loadedScopeDigest = digest guard !entries.isEmpty else { @@ -277,15 +256,16 @@ public actor CmxIrohPairedPeerAllowlist { accessibility: .afterFirstUnlockThisDeviceOnly ) } +} - private static func scopeDigest( - for scope: CmxIrohPairedPeerAllowlistScope - ) -> String { - let transcript = Data( - "cmux/iroh/paired-peer-allowlist-scope/v1\0\(scope.accountID)\0\(scope.clientNamespace)\0\(scope.appInstanceID)".utf8 - ) - return SHA256.hash(data: transcript) - .map { String(format: "%02x", $0) } - .joined() - } +/// Digest binding one persisted allowlist record to its exact owner scope. +private func pairedPeerAllowlistScopeDigest( + for scope: CmxIrohPairedPeerAllowlistScope +) -> String { + let transcript = Data( + "cmux/iroh/paired-peer-allowlist-scope/v1\0\(scope.accountID)\0\(scope.clientNamespace)\0\(scope.appInstanceID)".utf8 + ) + return SHA256.hash(data: transcript) + .map { String(format: "%02x", $0) } + .joined() } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistEntry.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistEntry.swift new file mode 100644 index 000000000000..08df2a598aa2 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistEntry.swift @@ -0,0 +1,41 @@ +public import Foundation + +/// One phone endpoint whose pairing this Mac has already verified once. +/// +/// The entry pins the complete initiator and acceptor tuples the verified pair +/// grant carried, so allowlist admission preserves exactly the account-scoped +/// binding authority the grant used to prove in-band. `expiresAt` is the +/// signed expiry of the last verified grant: allowlist authority never +/// outlives the credential that established it. +public struct CmxIrohPairedPeerAllowlistEntry: Equatable, Sendable { + /// The exact phone tuple the verified grant named as initiator. + public let initiator: CmxIrohGrantPeer + + /// The exact Mac tuple the verified grant named as acceptor. + public let acceptor: CmxIrohGrantPeer + + /// The signed expiry of the grant that established this entry. + public let expiresAt: Date + + /// When the pairing was recorded; oldest entries are pruned first. + public let recordedAt: Date + + /// Creates one verified-pairing entry. + /// + /// - Parameters: + /// - initiator: The grant's exact phone tuple. + /// - acceptor: The grant's exact Mac tuple. + /// - expiresAt: The grant's signed expiry. + /// - recordedAt: The verification time used for pruning order. + public init( + initiator: CmxIrohGrantPeer, + acceptor: CmxIrohGrantPeer, + expiresAt: Date, + recordedAt: Date + ) { + self.initiator = initiator + self.acceptor = acceptor + self.expiresAt = expiresAt + self.recordedAt = recordedAt + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistScope.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistScope.swift new file mode 100644 index 000000000000..278343b5658c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlistScope.swift @@ -0,0 +1,31 @@ +/// The Mac-local account, app, and namespace scope owning one allowlist store. +/// +/// Entries recorded under one scope never authorize another: the store's +/// persisted record carries a digest of these fields, and a mismatch on load +/// is treated as another owner's data and dropped. +public struct CmxIrohPairedPeerAllowlistScope: Equatable, Sendable { + /// The authenticated account that owns the current host binding. + public let accountID: String + + /// The exact Mac build namespace sent to every broker request. + public let clientNamespace: String + + /// The current app-instance UUID; a reinstall starts an empty allowlist. + public let appInstanceID: String + + /// Creates the allowlist ownership scope for the active host lifecycle. + /// + /// - Parameters: + /// - accountID: The authenticated account that owns the host binding. + /// - clientNamespace: The installed Mac bundle namespace. + /// - appInstanceID: The current app-instance UUID. + public init( + accountID: String, + clientNamespace: String, + appInstanceID: String + ) { + self.accountID = accountID + self.clientNamespace = clientNamespace + self.appInstanceID = appInstanceID + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift index 66b4ad4db892..85c87e12c284 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift @@ -113,7 +113,10 @@ struct CmxIrohPairedPeerAdmissionTests { credential: nil, authenticatedPeerID: fixture.initiator.endpointID ) - #expect(warm.isAcceptedForTest) + guard case .accepted = warm else { + Issue.record("allowlist admission after relaunch was denied") + return + } } @Test @@ -271,10 +274,3 @@ struct CmxIrohPairedPeerAdmissionTests { #expect(warm == .denied(code: 1)) } } - -private extension CmxIrohAdmissionAuthorization { - var isAcceptedForTest: Bool { - if case .accepted = self { return true } - return false - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift index b33faf80195b..b62fb114f7fb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift @@ -87,24 +87,3 @@ struct CmxIrohPairedPeerWireTests { #expect(observed == [nil]) } } - -private actor CredentialRecordingAuthorizer: CmxIrohAdmissionAuthorizing { - private let authorization: CmxIrohAdmissionAuthorization - private var credentials: [CmxIrohAdmissionCredential?] = [] - - init(authorization: CmxIrohAdmissionAuthorization) { - self.authorization = authorization - } - - func authorize( - credential: CmxIrohAdmissionCredential?, - authenticatedPeerID _: CmxIrohPeerIdentity - ) -> CmxIrohAdmissionAuthorization { - credentials.append(credential) - return authorization - } - - func observedCredentials() -> [CmxIrohAdmissionCredential?] { - credentials - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CredentialRecordingAuthorizer.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CredentialRecordingAuthorizer.swift new file mode 100644 index 000000000000..c70c7f059279 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CredentialRecordingAuthorizer.swift @@ -0,0 +1,25 @@ +import CMUXMobileCore +@testable import CmuxIrohTransport + +/// Test authorizer that records each observed admission credential (including +/// credential-less allowlist requests) and returns a fixed authorization. +actor CredentialRecordingAuthorizer: CmxIrohAdmissionAuthorizing { + private let authorization: CmxIrohAdmissionAuthorization + private var credentials: [CmxIrohAdmissionCredential?] = [] + + init(authorization: CmxIrohAdmissionAuthorization) { + self.authorization = authorization + } + + func authorize( + credential: CmxIrohAdmissionCredential?, + authenticatedPeerID _: CmxIrohPeerIdentity + ) -> CmxIrohAdmissionAuthorization { + credentials.append(credential) + return authorization + } + + func observedCredentials() -> [CmxIrohAdmissionCredential?] { + credentials + } +} From 3c103f3940556f443ed786ab01c03d37715462cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:42:41 -0700 Subject: [PATCH 62/71] test: pin the registry AddressLookupService contract (red) Consume manaflow-ai/iroh-ffi v1.0.2-cmux.9-dev.1 (fork PR #11's foreign-implementable AddressLookupService plus regenerated Swift bindings, published as a checksum-pinned prerelease xcframework) and pin the intended cmux-side behavior with failing contract tests: - CmxIrohRegistryAddressLookup resolve must answer record cache first (zero network), then persisted caches, then at most ONE bounded broker fetch, single-flight, cooling down by the codified transient taxonomy (CmxIrohTrustBrokerClientError.preservesVerifiedStateDuringRefresh). - Records naming relays outside the managed catalog / custom profile / debug override allowlist are dropped whole (the signature covers the full packet), mirroring the trust rule in web/services/relay/report.ts. - publish must verify the endpoint's own record, prime the resolve cache, and upload the blob to the trust broker. - Flag OFF (CMUX_IROH_ADDRESS_LOOKUP, Debug-only, default off) binds with byte-identical endpoint options. This commit ships the surrounding surface green (record policy + cache, broker record fetch/publish transport, endpoint-record web route with storage and discovery exposure, debug flag, factory plumbing, iroh-diag section, mac + iOS install seams) with resolve/publish stubbed, so the suite runs red on exactly the lookup behaviors the follow-up implements. --- .../Shared/CmuxIrohTransport/Package.resolved | 4 +- .../Shared/CmuxIrohTransport/Package.swift | 2 +- .../CmxIrohBackpressuredBroker.swift | 35 ++ .../CmxIrohBrokerBackpressureGate.swift | 1 + .../CmxIrohBrokerModels.swift | 31 ++ .../CmxIrohDebugAddressLookupFlag.swift | 51 +++ .../CmxIrohEndpointRecordCache.swift | 69 ++++ .../CmxIrohEndpointRecordPolicy.swift | 98 ++++++ .../CmxIrohLibEndpointFactory.swift | 22 +- .../CmxIrohRegistryAddressLookup.swift | 289 +++++++++++++++ ...rohTrustBrokerClient+EndpointRecords.swift | 18 + .../CmxIrohTrustBrokerClient.swift | 30 ++ .../CmxIrohDebugAddressLookupFlagTests.swift | 96 +++++ .../CmxIrohRegistryAddressLookupTests.swift | 331 ++++++++++++++++++ .../MobileHostIrohRuntime+Activation.swift | 16 +- ...ileHostIrohRuntime+AddressLookupDiag.swift | 84 +++++ Sources/Mobile/MobileHostIrohRuntime.swift | 1 + Sources/TerminalController.swift | 3 +- cmux.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- ios/cmuxPackage/Package.resolved | 4 +- .../MobileIrohRuntimeComposition.swift | 45 ++- .../MobileIrohRuntimeCompositionTests.swift | 2 +- .../api/devices/iroh/endpoint-record/route.ts | 6 + .../migration.sql | 13 + web/db/schema.ts | 21 ++ web/services/iroh/model.ts | 45 +++ web/services/iroh/repository.ts | 39 +++ web/services/iroh/routeHandler.ts | 9 +- web/services/iroh/trustBroker.ts | 54 +++ web/tests/iroh-db-behavior.test.ts | 110 ++++++ web/tests/iroh-route-handler.test.ts | 11 +- web/tests/iroh-trust-broker.test.ts | 156 +++++++++ 34 files changed, 1680 insertions(+), 28 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugAddressLookupFlag.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordCache.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugAddressLookupFlagTests.swift create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryAddressLookupTests.swift create mode 100644 Sources/Mobile/MobileHostIrohRuntime+AddressLookupDiag.swift create mode 100644 web/app/api/devices/iroh/endpoint-record/route.ts create mode 100644 web/db/migrations/20260827010000_iroh_endpoint_records/migration.sql diff --git a/Packages/Shared/CmuxIrohTransport/Package.resolved b/Packages/Shared/CmuxIrohTransport/Package.resolved index 7164e3b3a26d..3eeba3156972 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.resolved +++ b/Packages/Shared/CmuxIrohTransport/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "20f0e67cc3cb5179e816ef45b7a7ec5c8c58b0e0", - "version" : "1.0.2-cmux.7" + "revision" : "e74f6ec46c3bd037a4059aa43f67822f62615fc5", + "version" : "1.0.2-cmux.9-dev.1" } } ], diff --git a/Packages/Shared/CmuxIrohTransport/Package.swift b/Packages/Shared/CmuxIrohTransport/Package.swift index c606b0ea56a6..2a39a1cc1431 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.swift +++ b/Packages/Shared/CmuxIrohTransport/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(path: "../CMUXMobileCore"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.7" + exact: "1.0.2-cmux.9-dev.1" ), ], targets: [ diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift index 5ed19e74e752..423c58c5d93b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift @@ -1,4 +1,5 @@ public import CMUXMobileCore +public import Foundation /// Operation-gated client broker used by an account-owned runtime. public struct CmxIrohBackpressuredClientBroker: @@ -95,6 +96,24 @@ public struct CmxIrohBackpressuredClientBroker: } } +extension CmxIrohBackpressuredClientBroker: CmxIrohEndpointRecordBroker { + public func fetchEndpointRecords() async throws -> [Data] { + try await discover().bindings.compactMap(\.endpointRecord) + } + + public func publishEndpointRecord(_ record: Data) async throws { + // The runtime check keeps the record surface off every existing + // `CmxIrohClientBrokerServing` conformer (test fakes included); only + // a wrapped broker that actually publishes records participates. + guard let recordBroker = broker as? any CmxIrohEndpointRecordBroker else { + throw CmxIrohTrustBrokerClientError.missingAuthentication + } + try await gate.perform(accountID: accountID, operation: .endpointRecord) { + try await recordBroker.publishEndpointRecord(record) + } + } +} + /// Operation-gated host broker used by an account-owned Mac runtime. public struct CmxIrohBackpressuredHostBroker: CmxIrohHostBrokerServing, @@ -206,3 +225,19 @@ public struct CmxIrohBackpressuredRelayPolicyBroker: CmxIrohRelayPolicyServing, } } } + +extension CmxIrohBackpressuredHostBroker: CmxIrohEndpointRecordBroker { + public func fetchEndpointRecords() async throws -> [Data] { + try await discover().bindings.compactMap(\.endpointRecord) + } + + public func publishEndpointRecord(_ record: Data) async throws { + // See the client-broker twin above for why this is a runtime check. + guard let recordBroker = broker as? any CmxIrohEndpointRecordBroker else { + throw CmxIrohTrustBrokerClientError.missingAuthentication + } + try await gate.perform(accountID: accountID, operation: .endpointRecord) { + try await recordBroker.publishEndpointRecord(record) + } + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift index 2acfccd4ec26..82a27a7c761c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift @@ -10,6 +10,7 @@ public enum CmxIrohBrokerOperation: String, Codable, CaseIterable, Hashable, Sen case relayCredential case relayPreference case revocation + case endpointRecord } /// Selects whether a trust-broker client owns its operation gate. diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift index 8ec13d9ca57a..a83b88794521 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift @@ -18,8 +18,14 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable { case pathHints = "path_hints" case directPorts = "direct_ports" case lastSeenAt = "last_seen_at" + case endpointRecord = "endpoint_record" } + /// The largest accepted decoded endpoint-record size. A pkarr + /// `SignedPacket` is bounded at 32 + 64 + 8 header bytes plus a + /// 1000-byte DNS packet; the margin absorbs codec growth. + public static let maximumEndpointRecordByteCount = 1_200 + public let bindingID: String public let deviceID: String public let appInstanceID: String @@ -37,6 +43,13 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable { public let directPorts: CmxIrohDirectPorts? public let lastSeenAt: String + /// The endpoint's own signed pkarr record, stored by the broker as an + /// opaque blob. Advisory: signature and endpoint-id verification happen + /// at resolve time (`CmxIrohEndpointRecordPolicy`, then Rust), so an + /// invalid or oversized blob decodes as nil instead of failing the + /// discovery snapshot. + public let endpointRecord: Data? + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let bindingID = try container.decode(String.self, forKey: .bindingID) @@ -92,6 +105,20 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable { self.pathHints = pathHints self.directPorts = directPorts self.lastSeenAt = lastSeenAt + endpointRecord = Self.decodedEndpointRecord( + try container.decodeIfPresent(String.self, forKey: .endpointRecord) + ) + } + + private static func decodedEndpointRecord(_ encoded: String?) -> Data? { + guard let encoded, + encoded.utf8.count <= maximumEndpointRecordByteCount * 2, + let record = Data(base64Encoded: encoded), + !record.isEmpty, + record.count <= maximumEndpointRecordByteCount else { + return nil + } + return record } public func encode(to encoder: any Encoder) throws { @@ -110,6 +137,10 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable { try container.encode(pathHints, forKey: .pathHints) try container.encodeIfPresent(directPorts, forKey: .directPorts) try container.encode(lastSeenAt, forKey: .lastSeenAt) + try container.encodeIfPresent( + endpointRecord?.base64EncodedString(), + forKey: .endpointRecord + ) } private static func isCanonicalUUID(_ value: String) -> Bool { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugAddressLookupFlag.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugAddressLookupFlag.swift new file mode 100644 index 000000000000..0f57284a14ec --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugAddressLookupFlag.swift @@ -0,0 +1,51 @@ +public import Foundation + +/// A debug-build-only opt-in for the registry-backed iroh address lookup. +/// +/// When enabled, every endpoint generation installs +/// ``CmxIrohRegistryAddressLookup`` on the endpoint builder while keeping +/// today's hint dials, so magicsock merges both path sets (app hints as +/// `Source::App`, lookup results as `Source::AddressLookup`) and the two +/// resolve paths can be compared safely. The flag is read at each endpoint +/// bind. Release builds compile the mechanism away entirely, so the default +/// build behavior is byte-identical to a build without this code. +public enum CmxIrohDebugAddressLookupFlag { + /// The environment variable consulted first, and the `UserDefaults` key + /// consulted second. A `-CMUX_IROH_ADDRESS_LOOKUP 1` launch argument + /// populates the defaults key on iOS builds whose launch environment + /// cannot carry variables. + public static let key = "CMUX_IROH_ADDRESS_LOOKUP" + + /// Whether the address lookup service should be installed at bind. + public static func isEnabled() -> Bool { + #if DEBUG + isEnabled(rawValue: rawValue()) + #else + false + #endif + } + + #if DEBUG + /// Reads the raw flag value, preferring the process environment. + static func rawValue( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> String? { + if let fromEnvironment = environment[key], !fromEnvironment.isEmpty { + return fromEnvironment + } + return defaults.string(forKey: key) + } + + /// Interprets the raw value; anything but an explicit opt-in stays off. + static func isEnabled(rawValue: String?) -> Bool { + guard let rawValue else { return false } + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "on", "yes": + return true + default: + return false + } + } + #endif +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordCache.swift new file mode 100644 index 000000000000..6b532e458fe3 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordCache.swift @@ -0,0 +1,69 @@ +public import Foundation + +/// A bounded in-memory store of signed endpoint records by endpoint id. +/// +/// Records arrive from the endpoint's own publish callback, from broker +/// discovery fetches, and (later) from DO push fan-out. The cache stores raw +/// signed-packet bytes; acceptance policy (signature, freshness, relay +/// allowlist) is applied by the reader at resolve time, so a policy change +/// never requires a cache flush. +public actor CmxIrohEndpointRecordCache { + /// One cached signed record. + public struct Entry: Equatable, Sendable { + /// The exact signed-packet bytes. + public let blob: Data + /// The record's signing time, used for newest-wins replacement. + public let signedAt: Date + /// When this cache stored the record. + public let storedAt: Date + } + + /// The maximum number of endpoint ids retained. + public static let defaultCapacity = 64 + + private let capacity: Int + private var entries: [String: Entry] = [:] + private var insertionOrder: [String] = [] + + /// Creates a cache bounded to `capacity` endpoint ids. + public init(capacity: Int = CmxIrohEndpointRecordCache.defaultCapacity) { + self.capacity = max(1, capacity) + } + + /// Stores a record, keeping the newest signing time per endpoint id. + /// + /// - Returns: Whether the record was stored (false when an equal-or-newer + /// record for the same endpoint is already cached). + @discardableResult + public func store( + blob: Data, + endpointID: String, + signedAt: Date, + now: Date = Date() + ) -> Bool { + let key = endpointID.lowercased() + if let existing = entries[key], existing.signedAt >= signedAt { + return false + } + if entries[key] == nil { + insertionOrder.append(key) + if insertionOrder.count > capacity { + let evicted = insertionOrder.removeFirst() + entries[evicted] = nil + } + } + entries[key] = Entry(blob: blob, signedAt: signedAt, storedAt: now) + return true + } + + /// Returns the cached record for an endpoint id, if any. + public func entry(for endpointID: String) -> Entry? { + entries[endpointID.lowercased()] + } + + /// Removes every cached record. + public func removeAll() { + entries.removeAll() + insertionOrder.removeAll() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift new file mode 100644 index 000000000000..afbc3c6729bf --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift @@ -0,0 +1,98 @@ +public import Foundation +public import IrohLib + +/// A parsed, signature-verified endpoint record accepted by local policy. +public struct CmxIrohVerifiedEndpointRecord: Equatable, Sendable { + /// Canonical 64-character lowercase hex endpoint id that signed the record. + public let endpointID: String + /// Relay URLs named by the record, in record order. + public let relayURLs: [String] + /// Direct `ip:port` addresses named by the record, in record order. + public let directAddresses: [String] + /// The record's signing time. + public let signedAt: Date + /// The exact signed-packet bytes, suitable for handing back to iroh. + public let blob: Data +} + +/// Local acceptance policy for pkarr endpoint records. +/// +/// Rust verifies the ed25519 signature and the endpoint-id match again before +/// magicsock merges a record, so this policy is not the integrity boundary. +/// It owns what Rust deliberately does not decide for us: record freshness +/// (bounded `signedAt` age) and the managed-relay allowlist, mirroring the +/// catalog/saved-set trust rule the broker applies in `web/services/relay/report.ts`. +/// A record naming any relay outside the allowlist is dropped whole, because +/// the signature covers the full packet and a partial rewrite would break it. +public enum CmxIrohEndpointRecordPolicy { + /// The maximum accepted age of a record's signing time. Matches the + /// broker-hint freshness ceiling (`CmxIrohPathHint.maximumPrivateHintTTL`). + public static let maximumRecordAge: TimeInterval = 60 * 60 + + /// Tolerated forward clock skew on a record's signing time. + public static let maximumFutureSkew: TimeInterval = 5 * 60 + + /// Parses and verifies one record blob, applying local policy. + /// + /// - Parameters: + /// - blob: The pkarr signed-packet bytes. + /// - endpointID: The canonical hex endpoint id the caller asked for, + /// or nil to accept the record's own signer (publish side). + /// - allowedRelayURLs: Exact relay origins permitted by the active + /// managed catalog, custom profile, or debug override. + /// - now: The evaluation time. + /// - Returns: The verified record, or nil when the blob is malformed, + /// badly signed, stale, for another endpoint, or names a relay outside + /// the allowlist. + public static func acceptableRecord( + blob: Data, + endpointID: String?, + allowedRelayURLs: Set, + now: Date = Date() + ) -> CmxIrohVerifiedEndpointRecord? { + guard let summary = try? parseEndpointRecord(bytes: blob) else { + return nil + } + let recordEndpointID = Self.canonicalEndpointID(summary.endpointId) + if let endpointID, recordEndpointID != endpointID.lowercased() { + return nil + } + let signedAt = Date( + timeIntervalSince1970: TimeInterval(summary.lastUpdated) / 1_000_000 + ) + guard signedAt <= now.addingTimeInterval(maximumFutureSkew), + signedAt >= now.addingTimeInterval(-maximumRecordAge) else { + return nil + } + guard summary.relayUrls.allSatisfy({ + Self.isAllowedRelayURL($0, allowedRelayURLs: allowedRelayURLs) + }) else { + return nil + } + return CmxIrohVerifiedEndpointRecord( + endpointID: recordEndpointID, + relayURLs: summary.relayUrls, + directAddresses: summary.directAddrs, + signedAt: signedAt, + blob: blob + ) + } + + /// The canonical lowercase-hex form of an endpoint id. + public static func canonicalEndpointID(_ endpointID: EndpointId) -> String { + endpointID.toBytes().map { String(format: "%02x", $0) }.joined() + } + + /// Exact-origin allowlist match, tolerating one trailing slash the same + /// way `CmxIrohLibEndpoint.endpointAddresses` treats hint relay URLs. + static func isAllowedRelayURL( + _ url: String, + allowedRelayURLs: Set + ) -> Bool { + if allowedRelayURLs.contains(url) { return true } + if url.hasSuffix("/") { + return allowedRelayURLs.contains(String(url.dropLast())) + } + return allowedRelayURLs.contains(url + "/") + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift index 8ed0cd118224..4cfb9d47e0d0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift @@ -1,18 +1,27 @@ public import CMUXMobileCore import Foundation -import IrohLib +public import IrohLib /// Production endpoint factory using the forked Iroh Swift bindings. public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { private let transportVerificationMode: CmxIrohTransportVerificationMode + private let addressLookup: (any AddressLookupService)? /// Creates an endpoint factory with an optional debug transport constraint. /// - /// - Parameter transportVerificationMode: The path class the endpoint may use. + /// - Parameters: + /// - transportVerificationMode: The path class the endpoint may use. + /// - addressLookup: An optional custom discovery service installed on + /// every endpoint this factory binds. Nil (the default) leaves the + /// bind options byte-identical to a build without the lookup; hint + /// dials are unaffected either way (magicsock merges lookup results + /// as `Source::AddressLookup` next to `Source::App` hints). public init( - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic + transportVerificationMode: CmxIrohTransportVerificationMode = .automatic, + addressLookup: (any AddressLookupService)? = nil ) { self.transportVerificationMode = transportVerificationMode + self.addressLookup = addressLookup } public func bind( @@ -55,7 +64,8 @@ public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { configuration: configuration, socketAddress: socketAddress, relayMap: relayMap, - transportVerificationMode: transportVerificationMode + transportVerificationMode: transportVerificationMode, + addressLookup: addressLookup ) return try await Endpoint.bind(options: options) } @@ -64,7 +74,8 @@ public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { configuration: CmxIrohEndpointConfiguration, socketAddress: String?, relayMap: RelayMap, - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic + transportVerificationMode: CmxIrohTransportVerificationMode = .automatic, + addressLookup: (any AddressLookupService)? = nil ) -> EndpointOptions { EndpointOptions( preset: presetMinimal(), @@ -74,6 +85,7 @@ public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { relayMode: transportVerificationMode == .directOnly ? RelayMode.disabled() : RelayMode.custom(map: relayMap), + addressLookup: addressLookup, portMappingEnabled: false, deferNatTraversalUntilAuthorized: true, initialMaxConcurrentBiStreams: 0, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift new file mode 100644 index 000000000000..d75ada9161a0 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift @@ -0,0 +1,289 @@ +import CMUXMobileCore +public import Foundation +public import IrohLib +import os + +/// Re-exposes the iroh-ffi foreign trait so app layers can pass a lookup +/// through factory seams without importing IrohLib themselves. +public typealias CmxIrohAddressLookupServing = AddressLookupService + +/// Broker access needed by the registry address lookup. +/// +/// The fetch side returns every signed endpoint record the broker currently +/// serves for this account (opaque pkarr signed-packet blobs). The publish +/// side uploads this endpoint's own signed record. Both ride the existing +/// authenticated trust-broker transport; no credential crosses the FFI. +public protocol CmxIrohEndpointRecordBroker: Sendable { + /// Fetches every stored endpoint record for the account. + func fetchEndpointRecords() async throws -> [Data] + + /// Uploads this endpoint's own signed record. + func publishEndpointRecord(_ record: Data) async throws +} + +/// Diagnostic mirror of the lookup's install state and last outcomes, +/// printed by the `iroh-diag` socket verb. +public struct CmxIrohAddressLookupDiagnostics: Equatable, Sendable { + /// Where a resolve answer came from, or why it produced nothing. + public enum ResolveSource: String, Equatable, Sendable { + case recordCache = "record cache" + case persistedCache = "persisted cache" + case brokerFetch = "broker fetch" + case noResults = "no results" + case fetchCoolingDown = "fetch cooling down" + case fetchFailedTransient = "fetch failed (transient)" + case fetchFailedTrust = "fetch failed (non-transient)" + } + + /// The terminal state of one publish callback. + public enum PublishResult: String, Equatable, Sendable { + case published + case rejectedRecord = "rejected record" + case uploadFailed = "upload failed" + } + + /// One completed resolve, keyed by a shortened endpoint id. + public struct ResolveOutcome: Equatable, Sendable { + public let endpointIDPrefix: String + public let source: ResolveSource + public let recordCount: Int + public let at: Date + } + + /// One completed publish callback. + public struct PublishOutcome: Equatable, Sendable { + public let result: PublishResult + public let recordByteCount: Int + public let at: Date + } + + /// When the lookup instance was created. + public let installedAt: Date + /// Completed resolve callbacks. + public private(set) var resolveCount: Int + /// Completed publish callbacks. + public private(set) var publishCount: Int + /// The most recent resolve outcome. + public private(set) var lastResolve: ResolveOutcome? + /// The most recent publish outcome. + public private(set) var lastPublish: PublishOutcome? + + init(installedAt: Date) { + self.installedAt = installedAt + resolveCount = 0 + publishCount = 0 + } + + mutating func recordResolve(_ outcome: ResolveOutcome) { + resolveCount += 1 + lastResolve = outcome + } + + mutating func recordPublish(_ outcome: PublishOutcome) { + publishCount += 1 + lastPublish = outcome + } +} + +/// The registry-backed implementation of the iroh `AddressLookupService` +/// foreign trait (manaflow-ai/iroh-ffi `v1.0.2-cmux.9` surface). +/// +/// Resolve answers from, in order: the in-memory record cache (zero network), +/// an injected persisted-record source (offline/binding caches), then at most +/// one bounded broker fetch. The fetch is single-flight (concurrent resolves +/// join it) and cools down after failures using the codified trust-broker +/// transient taxonomy: transient failures back off on the foreground client +/// schedule, non-transient (trust) failures fail closed on the slower host +/// schedule. Records are accepted only through +/// ``CmxIrohEndpointRecordPolicy`` (Rust re-verifies signatures afterwards). +/// +/// Publish verifies the endpoint's own record, stores it in the record cache, +/// and uploads it to the trust broker as an opaque blob. +public final class CmxIrohRegistryAddressLookup: AddressLookupService { + /// Fetch-side coordination owned by one actor: single-flight join, + /// failure counting, and the taxonomy-gated cooldown clock. + private actor FetchState { + private let broker: any CmxIrohEndpointRecordBroker + private let transientSchedule: CmxIrohRetrySchedule + private let nonTransientSchedule: CmxIrohRetrySchedule + private let jitter: @Sendable () -> Double + private var inFlight: Task<[Data], any Error>? + private var failureCount = 0 + private var nextFetchAllowedAt = Date.distantPast + + init( + broker: any CmxIrohEndpointRecordBroker, + transientSchedule: CmxIrohRetrySchedule, + nonTransientSchedule: CmxIrohRetrySchedule, + jitter: @escaping @Sendable () -> Double + ) { + self.broker = broker + self.transientSchedule = transientSchedule + self.nonTransientSchedule = nonTransientSchedule + self.jitter = jitter + } + + enum FetchDisposition { + case fetched([Data]) + case coolingDown(until: Date) + case failed(transient: Bool, error: any Error) + } + + /// Publishes through the same broker the fetch side uses, so tests + /// observing broker traffic see a single serialized client. + func publish(record: Data) async throws { + try await broker.publishEndpointRecord(record) + } + + func fetchOnce(now: Date) async -> FetchDisposition { + if let inFlight { + do { + return .fetched(try await inFlight.value) + } catch { + // The joiner reports the shared failure; the owner already + // advanced the cooldown clock. + return .failed( + transient: CmxIrohTrustBrokerClientError + .preservesVerifiedStateDuringRefresh(error), + error: error + ) + } + } + guard now >= nextFetchAllowedAt else { + return .coolingDown(until: nextFetchAllowedAt) + } + let broker = broker + let task = Task { try await broker.fetchEndpointRecords() } + inFlight = task + defer { inFlight = nil } + do { + let records = try await task.value + failureCount = 0 + nextFetchAllowedAt = .distantPast + return .fetched(records) + } catch { + let transient = CmxIrohTrustBrokerClientError + .preservesVerifiedStateDuringRefresh(error) + let schedule = transient ? transientSchedule : nonTransientSchedule + let delay = schedule.delay( + failureCount: failureCount, + retryAfterSeconds: + (error as? any CmxRetryAfterProviding)?.retryAfterSeconds, + jitterUnitInterval: jitter() + ) + failureCount += 1 + nextFetchAllowedAt = now.addingTimeInterval(delay) + return .failed(transient: transient, error: error) + } + } + } + + private let recordCache: CmxIrohEndpointRecordCache + private let persistedRecords: @Sendable (String) async -> [Data] + private let allowedRelayURLs: @Sendable () async -> Set + private let fetchState: FetchState + private let dateProvider: @Sendable () -> Date + private let diagnostics: OSAllocatedUnfairLock + + /// Creates the lookup service. + /// + /// - Parameters: + /// - broker: Authenticated record fetch/publish transport. + /// - allowedRelayURLs: The exact relay origins currently permitted + /// (managed catalog, custom profile, or debug override), read per call + /// so a policy refresh applies immediately. + /// - persistedRecords: Candidate record blobs for an endpoint id from + /// persisted caches, consulted before any network fetch. + /// - recordCache: The in-memory record store. + /// - transientFetchSchedule: Backoff for transient fetch failures. + /// - nonTransientFetchSchedule: Backoff for trust (non-transient) + /// fetch failures; fail-closed floor. + /// - jitter: Deterministic-in-tests jitter source in `0...1`. + /// - dateProvider: Injectable clock. + public init( + broker: any CmxIrohEndpointRecordBroker, + allowedRelayURLs: @escaping @Sendable () async -> Set, + persistedRecords: @escaping @Sendable (String) async -> [Data] = { _ in [] }, + recordCache: CmxIrohEndpointRecordCache = CmxIrohEndpointRecordCache(), + transientFetchSchedule: CmxIrohRetrySchedule = .foregroundClient, + nonTransientFetchSchedule: CmxIrohRetrySchedule = CmxIrohRetrySchedule( + initialDelay: 300, + maximumDelay: 3_600 + ), + jitter: @escaping @Sendable () -> Double = { Double.random(in: 0...1) }, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.recordCache = recordCache + self.persistedRecords = persistedRecords + self.allowedRelayURLs = allowedRelayURLs + fetchState = FetchState( + broker: broker, + transientSchedule: transientFetchSchedule, + nonTransientSchedule: nonTransientFetchSchedule, + jitter: jitter + ) + self.dateProvider = dateProvider + diagnostics = OSAllocatedUnfairLock( + initialState: CmxIrohAddressLookupDiagnostics( + installedAt: dateProvider() + ) + ) + } + + /// A thread-safe snapshot for the `iroh-diag` socket verb. Never hops to + /// the main actor, so it stays readable while the app is wedged. + public func diagnosticsSnapshot() -> CmxIrohAddressLookupDiagnostics { + diagnostics.withLock { $0 } + } + + // MARK: - AddressLookupService + + public func resolve(endpointId: EndpointId) async throws -> [Data] { + // Red commit: contract only. The registry-backed resolve (record + // cache, persisted cache, one bounded broker fetch) lands in the + // follow-up commit; this stub answers nothing so the contract tests + // pin the intended behavior first. + let key = CmxIrohEndpointRecordPolicy.canonicalEndpointID(endpointId) + _ = await allowedRelayURLs() + recordResolve( + prefix: String(key.prefix(10)), + source: .noResults, + count: 0 + ) + return [] + } + + public func publish(record: Data) async throws { + // Red commit: contract only. + recordPublish(result: .rejectedRecord, byteCount: record.count) + throw CallbackError.Error + } + + // MARK: - Private + + private func recordResolve( + prefix: String, + source: CmxIrohAddressLookupDiagnostics.ResolveSource, + count: Int + ) { + let outcome = CmxIrohAddressLookupDiagnostics.ResolveOutcome( + endpointIDPrefix: prefix, + source: source, + recordCount: count, + at: dateProvider() + ) + diagnostics.withLock { $0.recordResolve(outcome) } + } + + private func recordPublish( + result: CmxIrohAddressLookupDiagnostics.PublishResult, + byteCount: Int + ) { + let outcome = CmxIrohAddressLookupDiagnostics.PublishOutcome( + result: result, + recordByteCount: byteCount, + at: dateProvider() + ) + diagnostics.withLock { $0.recordPublish(outcome) } + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift new file mode 100644 index 000000000000..0c405ff72ce6 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// Adapts the authenticated trust-broker client to the record broker the +/// address lookup consumes. Fetch rides the discovery snapshot (and its +/// backpressure gate); publish requires the retained binding authorization, +/// so a pre-registration publish fails typed instead of dialing unauthenticated. +extension CmxIrohTrustBrokerClient: CmxIrohEndpointRecordBroker { + public func fetchEndpointRecords() async throws -> [Data] { + try await discover().bindings.compactMap(\.endpointRecord) + } + + public func publishEndpointRecord(_ record: Data) async throws { + guard let bindingID = await bindingAuthorizationID() else { + throw CmxIrohTrustBrokerClientError.missingAuthentication + } + try await publishEndpointRecord(bindingID: bindingID, record: record) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index a9999f351931..189e202d7e4c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -230,6 +230,13 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } private struct BindingRequest: Encodable { let bindingId: String } + private struct PublishEndpointRecordRequest: Encodable { + let bindingId: String + let record: String + } + private struct PublishEndpointRecordResponse: Decodable, Sendable { + let published: Bool + } private struct RelayPolicyBootstrapResponse: Decodable, Sendable { let policy: String let preference: CmxIrohAccountRelayConfiguration @@ -476,6 +483,29 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } /// Revokes the caller's own binding. + /// Uploads this endpoint's own signed pkarr record as an opaque blob + /// attached to its active binding. The broker checks write admission + /// (binding-request proof, size, key match); readers re-verify the + /// record signature themselves, so broker storage stays untrusted. + public func publishEndpointRecord(bindingID: String, record: Data) async throws { + guard !record.isEmpty, + record.count <= CmxIrohBrokerBinding.maximumEndpointRecordByteCount else { + throw CmxIrohTrustBrokerClientError.invalidResponse + } + let response: PublishEndpointRecordResponse = try await send( + path: "api/devices/iroh/endpoint-record", + method: "POST", + body: PublishEndpointRecordRequest( + bindingId: bindingID, + record: record.base64EncodedString() + ), + operation: .endpointRecord + ) + guard response.published else { + throw CmxIrohTrustBrokerClientError.invalidResponse + } + } + public func revoke(bindingID: String) async throws { let response: RevokeResponse = try await send( path: "api/devices/iroh", diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugAddressLookupFlagTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugAddressLookupFlagTests.swift new file mode 100644 index 000000000000..9db7fbf7b5d1 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugAddressLookupFlagTests.swift @@ -0,0 +1,96 @@ +import Foundation +import IrohLib +import Testing +@testable import CmuxIrohTransport + +@Suite +struct CmxIrohDebugAddressLookupFlagTests { + @Test("flag defaults to off") + func defaultsToOff() { + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: nil)) + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "")) + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "0")) + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "false")) + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "off")) + #expect(!CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "banana")) + } + + @Test("explicit opt-ins enable the flag") + func explicitOptInsEnable() { + #expect(CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "1")) + #expect(CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "true")) + #expect(CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: "on")) + #expect(CmxIrohDebugAddressLookupFlag.isEnabled(rawValue: " YES ")) + } + + @Test("environment value wins over defaults") + func environmentWins() { + let defaults = UserDefaults( + suiteName: "CmxIrohDebugAddressLookupFlagTests" + )! + defaults.set("1", forKey: CmxIrohDebugAddressLookupFlag.key) + defer { defaults.removeObject(forKey: CmxIrohDebugAddressLookupFlag.key) } + + #expect(CmxIrohDebugAddressLookupFlag.rawValue( + environment: [CmxIrohDebugAddressLookupFlag.key: "0"], + defaults: defaults + ) == "0") + #expect(CmxIrohDebugAddressLookupFlag.rawValue( + environment: [:], + defaults: defaults + ) == "1") + } + + @Test("flag off binds with byte-identical endpoint options (no lookup)") + func flagOffLeavesEndpointOptionsUnchanged() throws { + let configuration = CmxIrohEndpointConfiguration( + secretKey: try CmxIrohSecretKey(bytes: SecretKey.generate().toBytes()), + alpns: [Data("cmux/mobile/1".utf8)], + relayProfile: try CmxIrohEndpointRelayProfile(managedRelayURLs: []) + ) + + let withoutLookup = CmxIrohLibEndpointFactory.endpointOptions( + configuration: configuration, + socketAddress: nil, + relayMap: RelayMap.empty() + ) + #expect(withoutLookup.addressLookup == nil) + + let broker = NoopRecordBroker() + let lookup = CmxIrohRegistryAddressLookup( + broker: broker, + allowedRelayURLs: { [] } + ) + let withLookup = CmxIrohLibEndpointFactory.endpointOptions( + configuration: configuration, + socketAddress: nil, + relayMap: RelayMap.empty(), + addressLookup: lookup + ) + #expect(withLookup.addressLookup === lookup) + + // The remaining options are identical either way: the lookup slot is + // the only difference between flag-on and flag-off binds. + #expect(withoutLookup.bindAddr == withLookup.bindAddr) + #expect(withoutLookup.secretKey == withLookup.secretKey) + #expect(withoutLookup.alpns == withLookup.alpns) + #expect(withoutLookup.portMappingEnabled == withLookup.portMappingEnabled) + #expect( + withoutLookup.deferNatTraversalUntilAuthorized + == withLookup.deferNatTraversalUntilAuthorized + ) + #expect( + withoutLookup.initialMaxConcurrentBiStreams + == withLookup.initialMaxConcurrentBiStreams + ) + #expect( + withoutLookup.initialMaxConcurrentUniStreams + == withLookup.initialMaxConcurrentUniStreams + ) + } +} + +private struct NoopRecordBroker: CmxIrohEndpointRecordBroker { + func fetchEndpointRecords() async throws -> [Data] { [] } + func publishEndpointRecord(_: Data) async throws {} +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryAddressLookupTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryAddressLookupTests.swift new file mode 100644 index 000000000000..0c879d1b71cf --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryAddressLookupTests.swift @@ -0,0 +1,331 @@ +import Foundation +import IrohLib +import Testing +@testable import CmuxIrohTransport + +/// Records broker traffic and injects per-call fetch/publish outcomes. +private actor TestEndpointRecordBroker: CmxIrohEndpointRecordBroker { + private(set) var fetchCount = 0 + private(set) var publishedRecords: [Data] = [] + private var fetchRecords: [Data] = [] + private var fetchError: (any Error)? + private var publishError: (any Error)? + + func setFetchRecords(_ records: [Data]) { fetchRecords = records } + func setFetchError(_ error: (any Error)?) { fetchError = error } + func setPublishError(_ error: (any Error)?) { publishError = error } + + func fetchEndpointRecords() async throws -> [Data] { + fetchCount += 1 + if let fetchError { throw fetchError } + return fetchRecords + } + + func publishEndpointRecord(_ record: Data) async throws { + if let publishError { throw publishError } + publishedRecords.append(record) + } +} + +private struct RecordFixture { + let secretKey: SecretKey + let endpointID: EndpointId + let endpointIDHex: String + let record: Data + let relayURL: String + + init(relayURL: String = "https://relay.example.com/") throws { + secretKey = SecretKey.generate() + endpointID = secretKey.public() + endpointIDHex = CmxIrohEndpointRecordPolicy.canonicalEndpointID(endpointID) + self.relayURL = relayURL + record = try signEndpointRecord( + secretKey: secretKey, + relayUrls: [relayURL], + directAddrs: ["192.168.10.20:4433"], + ttlSeconds: 30 + ) + } +} + +@Suite +struct CmxIrohRegistryAddressLookupTests { + private static let allowedRelays: Set = ["https://relay.example.com/"] + + private func makeLookup( + broker: TestEndpointRecordBroker, + cache: CmxIrohEndpointRecordCache = CmxIrohEndpointRecordCache(), + allowedRelayURLs: Set = allowedRelays, + persistedRecords: @escaping @Sendable (String) async -> [Data] = { _ in [] }, + now: @escaping @Sendable () -> Date = { Date() } + ) -> CmxIrohRegistryAddressLookup { + CmxIrohRegistryAddressLookup( + broker: broker, + allowedRelayURLs: { allowedRelayURLs }, + persistedRecords: persistedRecords, + recordCache: cache, + jitter: { 0 }, + dateProvider: now + ) + } + + @Test("cached record resolves with zero network traffic") + func cachedRecordResolvesWithZeroNetwork() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + let cache = CmxIrohEndpointRecordCache() + await cache.store( + blob: fixture.record, + endpointID: fixture.endpointIDHex, + signedAt: Date() + ) + let lookup = makeLookup(broker: broker, cache: cache) + + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records == [fixture.record]) + #expect(await broker.fetchCount == 0) + let diagnostics = lookup.diagnosticsSnapshot() + #expect(diagnostics.lastResolve?.source == .recordCache) + #expect(diagnostics.resolveCount == 1) + } + + @Test("persisted cache answers before any broker fetch") + func persistedCacheAnswersBeforeBrokerFetch() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + let record = fixture.record + let lookup = makeLookup( + broker: broker, + persistedRecords: { _ in [record] } + ) + + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records == [fixture.record]) + #expect(await broker.fetchCount == 0) + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .persistedCache) + } + + @Test("cache miss fetches once, then serves later resolves from cache") + func brokerFetchHappensOnceThenCaches() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchRecords([fixture.record]) + let lookup = makeLookup(broker: broker) + + let first = try await lookup.resolve(endpointId: fixture.endpointID) + let second = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(first == [fixture.record]) + #expect(second == [fixture.record]) + #expect(await broker.fetchCount == 1) + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .recordCache) + } + + @Test("transient broker failure throws, then cools down without refetching") + func transientFailureCoolsDown() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchError(CmxIrohTrustBrokerClientError.connectivity) + let lookup = makeLookup(broker: broker) + + await #expect(throws: CallbackError.self) { + _ = try await lookup.resolve(endpointId: fixture.endpointID) + } + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .fetchFailedTransient) + + // The cooldown window rejects an immediate second fetch: the resolve + // returns no results instead of dialing the broker again. + await broker.setFetchError(nil) + await broker.setFetchRecords([fixture.record]) + let cooled = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(cooled.isEmpty) + #expect(await broker.fetchCount == 1) + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .fetchCoolingDown) + } + + @Test("non-transient broker failure fails closed with the slow schedule") + func nonTransientFailureFailsClosed() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchError(CmxIrohTrustBrokerClientError.invalidResponse) + let lookup = makeLookup(broker: broker) + + await #expect(throws: CallbackError.self) { + _ = try await lookup.resolve(endpointId: fixture.endpointID) + } + + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .fetchFailedTrust) + #expect(await broker.fetchCount == 1) + } + + @Test("cooldown expiry allows exactly one new fetch") + func cooldownExpiryAllowsNewFetch() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchError(CmxIrohTrustBrokerClientError.connectivity) + let clock = TestClock() + let lookup = makeLookup(broker: broker, now: { clock.now() }) + + await #expect(throws: CallbackError.self) { + _ = try await lookup.resolve(endpointId: fixture.endpointID) + } + + // .foregroundClient first delay with zero jitter is bounded by its + // floor; advancing well past the cap re-arms the fetch. + clock.advance(by: 120) + await broker.setFetchError(nil) + await broker.setFetchRecords([fixture.record]) + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records == [fixture.record]) + #expect(await broker.fetchCount == 2) + } + + @Test("records naming relays outside the allowlist are dropped whole") + func relayAllowlistFiltersRecords() async throws { + let fixture = try RecordFixture(relayURL: "https://relay.evil.example/") + let broker = TestEndpointRecordBroker() + await broker.setFetchRecords([fixture.record]) + let lookup = makeLookup(broker: broker) + + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records.isEmpty) + #expect(lookup.diagnosticsSnapshot().lastResolve?.source == .noResults) + } + + @Test("allowlist matching tolerates one trailing slash") + func relayAllowlistToleratesTrailingSlash() async throws { + // Records canonicalize relay URLs with a trailing slash; the policy + // set may store the origin without one. + let fixture = try RecordFixture(relayURL: "https://relay.example.com/") + let broker = TestEndpointRecordBroker() + await broker.setFetchRecords([fixture.record]) + let lookup = makeLookup( + broker: broker, + allowedRelayURLs: ["https://relay.example.com"] + ) + + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records == [fixture.record]) + } + + @Test("stale records are rejected by the freshness window") + func staleRecordsAreRejected() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchRecords([fixture.record]) + let farFuture = Date().addingTimeInterval( + CmxIrohEndpointRecordPolicy.maximumRecordAge * 2 + ) + let lookup = makeLookup(broker: broker, now: { farFuture }) + + let records = try await lookup.resolve(endpointId: fixture.endpointID) + + #expect(records.isEmpty) + } + + @Test("resolve rejects a valid record signed by a different endpoint") + func resolveRejectsWrongEndpointRecord() async throws { + let requested = try RecordFixture() + let other = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setFetchRecords([other.record]) + let lookup = makeLookup(broker: broker) + + let records = try await lookup.resolve(endpointId: requested.endpointID) + + // The other endpoint's record is cached for its own id but never + // answers the requested endpoint. + #expect(records.isEmpty) + let cached = try await lookup.resolve(endpointId: other.endpointID) + #expect(cached == [other.record]) + #expect(await broker.fetchCount == 1) + } + + @Test("publish uploads the record and primes the resolve cache") + func publishUploadsAndCaches() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + let lookup = makeLookup(broker: broker) + + try await lookup.publish(record: fixture.record) + + #expect(await broker.publishedRecords == [fixture.record]) + let diagnostics = lookup.diagnosticsSnapshot() + #expect(diagnostics.lastPublish?.result == .published) + #expect(diagnostics.publishCount == 1) + + // The published record now resolves with zero further broker calls. + let records = try await lookup.resolve(endpointId: fixture.endpointID) + #expect(records == [fixture.record]) + #expect(await broker.fetchCount == 0) + } + + @Test("publish rejects a malformed record without posting") + func publishRejectsMalformedRecord() async throws { + let broker = TestEndpointRecordBroker() + let lookup = makeLookup(broker: broker) + + await #expect(throws: CallbackError.self) { + try await lookup.publish(record: Data([0x00, 0x01, 0x02])) + } + + #expect(await broker.publishedRecords.isEmpty) + #expect(lookup.diagnosticsSnapshot().lastPublish?.result == .rejectedRecord) + } + + @Test("publish upload failure surfaces and records the outcome") + func publishUploadFailureSurfaces() async throws { + let fixture = try RecordFixture() + let broker = TestEndpointRecordBroker() + await broker.setPublishError(CmxIrohTrustBrokerClientError.connectivity) + let lookup = makeLookup(broker: broker) + + await #expect(throws: CallbackError.self) { + try await lookup.publish(record: fixture.record) + } + + #expect(lookup.diagnosticsSnapshot().lastPublish?.result == .uploadFailed) + } + + @Test("sign/parse round trip verifies and a tampered record fails") + func recordRoundTripAndTamperDetection() throws { + let fixture = try RecordFixture() + + let summary = try parseEndpointRecord(bytes: fixture.record) + #expect( + CmxIrohEndpointRecordPolicy.canonicalEndpointID(summary.endpointId) + == fixture.endpointIDHex + ) + #expect(summary.relayUrls == [fixture.relayURL]) + + var tampered = fixture.record + tampered[tampered.count - 1] ^= 0xFF + #expect(throws: (any Error).self) { + _ = try parseEndpointRecord(bytes: tampered) + } + } +} + +/// Deterministic manual clock for cooldown tests. +private final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date() + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return current + } + + func advance(by interval: TimeInterval) { + lock.lock() + defer { lock.unlock() } + current = current.addingTimeInterval(interval) + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift index b14622aa595c..8c7a29353944 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift @@ -246,9 +246,23 @@ extension MobileHostIrohRuntime { let credentialRepository = brokerCredentials let hostPolicyCache = hostPolicies let lanPublisher = lanPublisher + // Debug-flagged custom discovery A/B (default OFF): the endpoint + // builder gets the registry address lookup while hint dials remain + // untouched, so magicsock merges lookup records (`Source::AddressLookup`) + // next to app hints (`Source::App`). Flag OFF binds with byte-identical + // endpoint options (nil lookup is uniffi's default). + let addressLookup: CmxIrohRegistryAddressLookup? = + CmxIrohDebugAddressLookupFlag.isEnabled() + ? CmxIrohRegistryAddressLookup( + broker: broker, + allowedRelayURLs: { Self.addressLookupAllowedRelayURLs() } + ) + : nil + Self.publishAddressLookupDiagMirror(addressLookup) let hostRuntime = CmxIrohHostRuntime( factory: CmxIrohLibEndpointFactory( - transportVerificationMode: transportVerificationMode + transportVerificationMode: transportVerificationMode, + addressLookup: addressLookup ), broker: broker, configuration: configuration, diff --git a/Sources/Mobile/MobileHostIrohRuntime+AddressLookupDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+AddressLookupDiag.swift new file mode 100644 index 000000000000..82dc35b61eb6 --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+AddressLookupDiag.swift @@ -0,0 +1,84 @@ +import CmuxIrohTransport +import Foundation +import os + +/// Address-lookup lines for the `iroh_diag` socket verb. +/// +/// Same design as `MobileHostIrohRuntime+RelayDiag.swift`: a nonisolated lock +/// mirror written synchronously at installation, read without any main-actor +/// hop so the verb keeps working while the main thread is wedged. Endpoint-id +/// prefixes appear only in the local debug socket output, never in the +/// privacy-safe `DiagnosticLog` export. +extension MobileHostIrohRuntime { + /// The installed lookup instance (nil when the flag is off or the host + /// runtime is inactive). The instance itself owns its outcome counters + /// behind its own lock, so this mirror only tracks installation. + private nonisolated static let addressLookupMirror = + OSAllocatedUnfairLock(initialState: nil) + + /// The single write funnel, called where the host activation decides + /// whether to install the lookup, before the endpoint binds. + static func publishAddressLookupDiagMirror( + _ lookup: CmxIrohRegistryAddressLookup? + ) { + addressLookupMirror.withLock { $0 = lookup } + } + + nonisolated static func currentAddressLookup() -> CmxIrohRegistryAddressLookup? { + addressLookupMirror.withLock { $0 } + } + + /// The exact relay origins the address lookup may accept in resolved + /// records: the debug override while active (every profile installation + /// funnel substitutes it), otherwise the most recently installed + /// effective relay policy, read through the same mirror the relay diag + /// section prints. Mirrors the hint-dial filter in + /// `CmxIrohLibEndpoint.endpointAddresses`. + nonisolated static func addressLookupAllowedRelayURLs() -> Set { + if let overrideProfile = CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL { + return [overrideProfile] + } + return Set(currentRelayDiagState()?.relayURLs ?? []) + } + + /// The address-lookup section appended to `iroh_diag` output. + nonisolated static func addressLookupDiagReportText() -> String { + addressLookupDiagReport( + diagnostics: currentAddressLookup()?.diagnosticsSnapshot() + ) + } + + nonisolated static func addressLookupDiagReport( + diagnostics: CmxIrohAddressLookupDiagnostics? + ) -> String { + var lines = ["Address lookup (\(CmxIrohDebugAddressLookupFlag.key))"] + guard let diagnostics else { + lines.append("Installed: no") + return lines.joined(separator: "\n") + } + lines.append("Installed: yes (since \(iso8601(diagnostics.installedAt)))") + if let resolve = diagnostics.lastResolve { + lines.append( + "Last resolve: \(resolve.endpointIDPrefix)… -> " + + "\(resolve.source.rawValue), \(resolve.recordCount) record(s) " + + "at \(iso8601(resolve.at)) (\(diagnostics.resolveCount) total)" + ) + } else { + lines.append("Last resolve: none (\(diagnostics.resolveCount) total)") + } + if let publish = diagnostics.lastPublish { + lines.append( + "Last publish: \(publish.result.rawValue), " + + "\(publish.recordByteCount) bytes at \(iso8601(publish.at)) " + + "(\(diagnostics.publishCount) total)" + ) + } else { + lines.append("Last publish: none (\(diagnostics.publishCount) total)") + } + return lines.joined(separator: "\n") + } + + private nonisolated static func iso8601(_ date: Date) -> String { + date.formatted(.iso8601) + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index fd4ae3ff76d1..12bfcc0c3130 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -290,6 +290,7 @@ final class MobileHostIrohRuntime { || targetAccountID == nil { let previousRuntime = runtime runtime = nil + Self.publishAddressLookupDiagMirror(nil) clearIrohRoutePublication(revision: revision) selectedPathObservationTask?.cancel() selectedPathObservationTask = nil diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 01f88e048798..d292a24da0b5 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11716,7 +11716,8 @@ class TerminalController { // Appended outside the report so relay URLs never enter the // privacy-safe DiagnosticLog pipeline; the mirror read is serialized // and main-actor-free. - return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n" + return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n\n" + + MobileHostIrohRuntime.addressLookupDiagReportText() + "\n" } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7c3b23ab46d4..eed609dff070 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1447,6 +1447,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */; }; C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */; }; 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */; }; + 1B0B099A0000000000000002 /* MobileHostIrohRuntime+AddressLookupDiag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B099A0000000000000001 /* MobileHostIrohRuntime+AddressLookupDiag.swift */; }; C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */; }; 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */; }; 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */; }; @@ -4275,6 +4276,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohApplicationLaneRouter.swift; sourceTree = ""; }; C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohAuthObserver.swift; sourceTree = ""; }; 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Activation.swift"; sourceTree = ""; }; + 1B0B099A0000000000000001 /* MobileHostIrohRuntime+AddressLookupDiag.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+AddressLookupDiag.swift"; sourceTree = ""; }; C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Lifecycle.swift"; sourceTree = ""; }; 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+RelayDiag.swift"; sourceTree = ""; }; 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+SettingsControl.swift"; sourceTree = ""; }; @@ -5973,6 +5975,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */, 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */, 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */, + 1B0B099A0000000000000001 /* MobileHostIrohRuntime+AddressLookupDiag.swift */, 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */, 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */, C1A070000000000000000011 /* MobileHostAuthorizationSupport.swift */, @@ -9949,6 +9952,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */, C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */, 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */, + 1B0B099A0000000000000002 /* MobileHostIrohRuntime+AddressLookupDiag.swift in Sources */, C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */, 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */, 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */, diff --git a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 340db7bc00ff..10cf8ed66dd2 100644 --- a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "20f0e67cc3cb5179e816ef45b7a7ec5c8c58b0e0", - "version" : "1.0.2-cmux.7" + "revision" : "e74f6ec46c3bd037a4059aa43f67822f62615fc5", + "version" : "1.0.2-cmux.9-dev.1" } }, { diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c7c7ad93d38..e96719a90fce 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "20f0e67cc3cb5179e816ef45b7a7ec5c8c58b0e0", - "version" : "1.0.2-cmux.7" + "revision" : "e74f6ec46c3bd037a4059aa43f67822f62615fc5", + "version" : "1.0.2-cmux.9-dev.1" } }, { diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index fc802d2cd79b..7624495269cf 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "20f0e67cc3cb5179e816ef45b7a7ec5c8c58b0e0", - "version" : "1.0.2-cmux.7" + "revision" : "e74f6ec46c3bd037a4059aa43f67822f62615fc5", + "version" : "1.0.2-cmux.9-dev.1" } }, { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index a2aeb60cb139..63c9ab7341e8 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -178,7 +178,10 @@ public final class MobileIrohRuntimeComposition: private let networkPathSnapshotComposer: CmxIrohNetworkPathSnapshotComposer private let relayPolicyTrustRoot: CmxIrohRelayPolicyTrustRoot? private let endpointFactoryProvider: - @MainActor (CmxIrohTransportVerificationMode) -> any CmxIrohEndpointFactory + @MainActor ( + CmxIrohTransportVerificationMode, + (any CmxIrohAddressLookupServing)? + ) -> any CmxIrohEndpointFactory private var transportVerificationMode: CmxIrohTransportVerificationMode private let automaticRelayCredentialRefreshEnabled: Bool /// The app defaults handle, retained under its existing DEBUG-era name. @@ -396,8 +399,11 @@ public final class MobileIrohRuntimeComposition: endpointFactory: CmxIrohLibEndpointFactory( transportVerificationMode: transportVerificationMode ), - endpointFactoryProvider: { mode in - CmxIrohLibEndpointFactory(transportVerificationMode: mode) + endpointFactoryProvider: { mode, addressLookup in + CmxIrohLibEndpointFactory( + transportVerificationMode: mode, + addressLookup: addressLookup + ) }, transportVerificationMode: transportVerificationMode, automaticRelayCredentialRefreshEnabled: automaticRelayCredentialRefreshEnabled, @@ -460,7 +466,10 @@ public final class MobileIrohRuntimeComposition: relayPolicyTrustRoot: CmxIrohRelayPolicyTrustRoot? = nil, endpointFactory: any CmxIrohEndpointFactory, endpointFactoryProvider: ( - @MainActor (CmxIrohTransportVerificationMode) -> any CmxIrohEndpointFactory + @MainActor ( + CmxIrohTransportVerificationMode, + (any CmxIrohAddressLookupServing)? + ) -> any CmxIrohEndpointFactory )? = nil, transportVerificationMode: CmxIrohTransportVerificationMode = .automatic, automaticRelayCredentialRefreshEnabled: Bool = true, @@ -500,7 +509,7 @@ public final class MobileIrohRuntimeComposition: self.customPrivatePaths = customPrivatePaths self.networkPathSnapshotComposer = networkPathSnapshotComposer self.relayPolicyTrustRoot = relayPolicyTrustRoot - self.endpointFactoryProvider = endpointFactoryProvider ?? { _ in endpointFactory } + self.endpointFactoryProvider = endpointFactoryProvider ?? { _, _ in endpointFactory } self.transportVerificationMode = transportVerificationMode self.automaticRelayCredentialRefreshEnabled = automaticRelayCredentialRefreshEnabled self.debugDefaults = debugDefaults @@ -1902,8 +1911,32 @@ public final class MobileIrohRuntimeComposition: let customPrivatePaths = customPrivatePaths let networkPathSnapshotComposer = networkPathSnapshotComposer let platformNetworkPathSnapshot = networkPathSnapshot + // Debug-flagged custom discovery A/B (default OFF): the endpoint + // builder gets the registry address lookup while hint dials remain + // untouched (magicsock merges `Source::AddressLookup` records next to + // `Source::App` hints). Flag OFF passes a nil lookup, which leaves the + // bind options byte-identical. The relay allowlist mirrors the + // hint-dial filter: debug override first, then the endpoint + // generation's own profile (frozen per generation, like hint dials). + let addressLookup: CmxIrohRegistryAddressLookup? + if CmxIrohDebugAddressLookupFlag.isEnabled() { + let allowlistProfile = endpointRelayProfile + let allowlistManaged = managedRelayURLs + addressLookup = CmxIrohRegistryAddressLookup( + broker: broker, + allowedRelayURLs: { + if let overrideURL = + CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL { + return [overrideURL] + } + return allowlistProfile?.allowedRelayURLs ?? allowlistManaged + } + ) + } else { + addressLookup = nil + } let runtime = try CmxIrohClientRuntime( - factory: endpointFactoryProvider(transportVerificationMode), + factory: endpointFactoryProvider(transportVerificationMode, addressLookup), broker: broker, configuration: configuration, pendingRevocations: pendingRevocations, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index 164af9185a9b..0753aa9cbd2a 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -1528,7 +1528,7 @@ private struct MobileIrohSignOutFixture { secureStore: offlineStore ), endpointFactory: endpointFactory, - endpointFactoryProvider: { mode in + endpointFactoryProvider: { mode, _ in endpointFactoryModes.record(mode) return endpointFactory }, diff --git a/web/app/api/devices/iroh/endpoint-record/route.ts b/web/app/api/devices/iroh/endpoint-record/route.ts new file mode 100644 index 000000000000..c6808fde4071 --- /dev/null +++ b/web/app/api/devices/iroh/endpoint-record/route.ts @@ -0,0 +1,6 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "publish_record"); +} diff --git a/web/db/migrations/20260827010000_iroh_endpoint_records/migration.sql b/web/db/migrations/20260827010000_iroh_endpoint_records/migration.sql new file mode 100644 index 000000000000..dab4e9ee1f56 --- /dev/null +++ b/web/db/migrations/20260827010000_iroh_endpoint_records/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "endpoint_record" text; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "endpoint_record_updated_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_endpoint_record_check" + CHECK ("endpoint_record" IS NULL OR ("endpoint_record" ~ '^[A-Za-z0-9+/]+={0,2}$' AND length("endpoint_record") <= 1600)); +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_endpoint_record_updated_check" + CHECK ("endpoint_record" IS NULL OR "endpoint_record_updated_at" IS NOT NULL); diff --git a/web/db/schema.ts b/web/db/schema.ts index 70c747d58e4c..400ab41fa8f4 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -990,6 +990,14 @@ export const irohEndpointBindings = pgTable( // order. Cleared by a matching detach report and by revocation. relayAttachedUrl: text("relay_attached_url"), relayAttachReportedAt: timestamp("relay_attach_reported_at", { withTimezone: true }), + // The endpoint's own signed pkarr record (base64), stored as an OPAQUE + // blob via POST /api/devices/iroh/endpoint-record. Write admission checks + // the binding-request proof and that the record's embedded public key + // equals this binding's endpoint id; readers verify the ed25519 record + // signature themselves (in the app's Rust layer), so this storage is + // untrusted by design and may be served from any cache or replica. + endpointRecord: text("endpoint_record"), + endpointRecordUpdatedAt: timestamp("endpoint_record_updated_at", { withTimezone: true }), deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), @@ -1017,6 +1025,19 @@ export const irohEndpointBindings = pgTable( "iroh_endpoint_bindings_relay_attach_reported_check", sql`${table.relayAttachedUrl} is null or ${table.relayAttachReportedAt} is not null`, ), + // A pkarr SignedPacket is bounded (32+64+8 header bytes plus a <=1000 + // byte DNS packet); 1600 base64 chars covers 1200 decoded bytes. The + // length bound lives outside the regex because Postgres caps regex + // repetition counts at 255. + check( + "iroh_endpoint_bindings_endpoint_record_check", + sql`${table.endpointRecord} is null or (${table.endpointRecord} ~ '^[A-Za-z0-9+/]+={0,2}$' and length(${table.endpointRecord}) <= 1600)`, + ), + // A stored record always carries the timestamp that set it. + check( + "iroh_endpoint_bindings_endpoint_record_updated_check", + sql`${table.endpointRecord} is null or ${table.endpointRecordUpdatedAt} is not null`, + ), uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") .on(table.endpointId) .where(sql`${table.revokedAt} is null`), diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 1e054add274a..2d1132afb637 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -241,6 +241,51 @@ export function parseRevokeBindingBody(value: unknown): { return result; } +/// A pkarr SignedPacket is 32 (public key) + 64 (signature) + 8 (timestamp) +/// header bytes plus a bounded DNS packet (<= 1000 bytes upstream). +export const IROH_ENDPOINT_RECORD_MIN_BYTES = 105; +export const IROH_ENDPOINT_RECORD_MAX_BYTES = 1_200; + +export function parsePublishEndpointRecordBody(value: unknown): { + readonly bindingId: string; + readonly record: string; + readonly recordEndpointId: string; +} { + const body = record(value); + const bindingId = uuid(body.bindingId, "invalid_binding_id"); + if ( + typeof body.record !== "string" + || !/^[A-Za-z0-9+/]{1,1600}={0,2}$/.test(body.record) + ) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_record" }); + } + let decoded: Buffer; + try { + decoded = Buffer.from(body.record, "base64"); + } catch { + throw new IrohInvalidInputError({ code: "invalid_endpoint_record" }); + } + if ( + decoded.length < IROH_ENDPOINT_RECORD_MIN_BYTES + || decoded.length > IROH_ENDPOINT_RECORD_MAX_BYTES + || decoded.toString("base64").replace(/=+$/, "") !== body.record.replace(/=+$/, "") + ) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_record" }); + } + // The record's signing public key leads the serialized packet. Write + // admission requires it to match the authenticated binding's endpoint id; + // the ed25519 signature itself is verified by readers (the app's Rust + // layer), never trusted from this storage. + const recordEndpointId = decoded.subarray(0, 32).toString("hex"); + const result = { + bindingId, + record: decoded.toString("base64"), + recordEndpointId, + } as const; + rejectUnknownKeys(body, ["bindingId", "record"]); + return result; +} + export function parsePairGrantRequest(value: unknown): { readonly initiatorBindingId: string; readonly acceptorBindingId: string; diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 327a85446a57..b203ed1930f2 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -148,6 +148,13 @@ export type IrohRepositoryShape = { readonly intent?: "self" | "forget_mac" | "revoke_stale"; readonly now: Date; }) => Effect.Effect; + readonly publishEndpointRecord: (input: { + readonly userId: string; + readonly bindingId: string; + readonly endpointId: string; + readonly record: string; + readonly now: Date; + }) => Effect.Effect<{ readonly published: boolean }, RepositoryError>; readonly pruneExpiredState: (input: { readonly userId: string; readonly now: Date; @@ -782,6 +789,34 @@ function makeLiveRepository(): IrohRepositoryShape { }, ), + // Stores the endpoint's own signed record as an opaque blob on its + // active binding. No account-revision bump: peers pull records on demand + // through discovery, and an address change already refreshes hints + // through registration, so record writes must not add revision churn. + publishEndpointRecord: (input) => repositoryEffect( + "publish_endpoint_record", + async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + const [updated] = await tx + .update(irohEndpointBindings) + .set({ + endpointRecord: input.record, + endpointRecordUpdatedAt: input.now, + updatedAt: input.now, + }) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + eq(irohEndpointBindings.endpointId, input.endpointId), + isNull(irohEndpointBindings.revokedAt), + )) + .returning({ id: irohEndpointBindings.id }); + return { published: updated !== undefined }; + }); + }, + ), + revokeBinding: (input) => repositoryEffect("revoke_binding", async () => { return await cloudDb().transaction(async (tx) => { await assertIrohUserMutationAllowed(tx, input.userId); @@ -1144,6 +1179,10 @@ async function revokeActiveBindings( pathHintsNextExpiry: null, relayAttachedUrl: null, relayAttachReportedAt: null, + // The signed record names addresses; revocation wipes it with the + // same retention posture as path hints and the live relay attach. + endpointRecord: null, + endpointRecordUpdatedAt: null, updatedAt: input.now, }) .where(and( diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index d540a090c8dc..fd57aca4e363 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -23,7 +23,8 @@ export type IrohRouteOperation = | "discover" | "endpoint_attestation" | "revoke" - | "pair_grant"; + | "pair_grant" + | "publish_record"; type RouteDependencies = { readonly verify?: typeof verifyRequest; @@ -220,6 +221,8 @@ function invoke( return broker.revoke(userId, body, undefined, clientNamespace, bindingProof); case "pair_grant": return broker.issuePairGrant(userId, body, undefined, clientNamespace, bindingProof); + case "publish_record": + return broker.publishEndpointRecord(userId, body, undefined, clientNamespace, bindingProof); } } @@ -333,7 +336,9 @@ async function readBoundedJson(request: Request): Promise< } function successStatus(operation: IrohRouteOperation): number { - return operation === "discover" || operation === "revoke" ? 200 : 201; + return operation === "discover" || operation === "revoke" || operation === "publish_record" + ? 200 + : 201; } function expectedErrorResponse(error: ReturnType & object): Response { diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 118f43868b89..fb7d3e63e04f 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -45,6 +45,7 @@ import { assertChallengeMatchesPayload, decodeRegistrationPayload, parseBindingIdBody, + parsePublishEndpointRecordBody, parseRevokeBindingBody, parseChallengeRequest, parseIrohPathHint, @@ -124,6 +125,13 @@ export type IrohTrustBrokerShape = { clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; + readonly publishEndpointRecord: ( + userId: string, + raw: unknown, + now?: Date, + clientNamespace?: string, + bindingProof?: IrohBindingRequestProof, + ) => Effect.Effect; readonly issuePairGrant: ( userId: string, raw: unknown, @@ -495,6 +503,46 @@ export function makeIrohTrustBroker( }; }), + // Stores the caller's own signed pkarr record on its binding. Write + // admission only: the binding-request proof authenticates the caller, + // and the record's embedded public key must equal the caller's endpoint + // id. The ed25519 record signature is verified by every reader, so this + // storage stays untrusted (any cache or replica may serve it). + publishEndpointRecord: ( + userId, + raw, + now = new Date(), + clientNamespace = "legacy", + bindingProof, + ) => Effect.gen(function* () { + const request = yield* parseEffect(() => parsePublishEndpointRecordBody(raw)); + const caller = yield* authorizeBinding(userId, bindingProof, clientNamespace, now); + // Unlike legacy read paths, record publication always requires the + // binding-request proof: an account credential alone must not be able + // to overwrite another device's published addresses. + if (!caller || caller.id !== request.bindingId) { + return yield* Effect.fail( + new IrohForbiddenError({ code: "binding_request_proof_required" }), + ); + } + if (caller.endpointId !== request.recordEndpointId) { + return yield* Effect.fail( + new IrohForbiddenError({ code: "endpoint_record_key_mismatch" }), + ); + } + const result = yield* repository.publishEndpointRecord({ + userId, + bindingId: request.bindingId, + endpointId: request.recordEndpointId, + record: request.record, + now, + }); + if (!result.published) { + return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + } + return { published: true }; + }), + issuePairGrant: ( userId, raw, @@ -695,6 +743,12 @@ function publicBinding( }), path_hints: bindingPathHints(binding, now, savedCustomRelayURLs), last_seen_at: binding.lastSeenAt.toISOString(), + // Opaque signed pkarr record; readers verify its ed25519 signature + // themselves, so publication needs no server-side laundering beyond the + // write-admission checks in publishEndpointRecord. + ...(binding.endpointRecord === null + ? {} + : { endpoint_record: binding.endpointRecord }), }; } diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 0d7d721d58e8..c15de10fc601 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -672,6 +672,116 @@ describe("Iroh trust broker database behavior", () => { expect(stored?.nextExpiry).toEqual(directExpiry); }); + dbTest("stores, guards, and wipes the published endpoint record", async () => { + const repo = requiredRepository(); + const userId = "user-endpoint-record"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "50".repeat(32); + const nonceHash = "51".repeat(32); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId, + deviceUuid: deviceId, + appInstanceId, + clientNamespace: "legacy", + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: "52".repeat(32), + nonceHash, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + const registered = await Effect.runPromise(repo.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash, + payload: { + route_contract_version: 1, + deviceId, + appInstanceId, + clientNamespace: "legacy", + tag: "stable", + platform: "mac", + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + }, + now: NOW, + })); + const bindingId = registered.binding.id; + const record = Buffer.concat([ + Buffer.from(endpointId, "hex"), + Buffer.alloc(200, 0xab), + ]).toString("base64"); + + // A mismatched endpoint id publishes nothing (the row filter fails). + const mismatched = await Effect.runPromise(repo.publishEndpointRecord({ + userId, + bindingId, + endpointId: "51".repeat(32), + record, + now: NOW, + })); + expect(mismatched.published).toBe(false); + + const published = await Effect.runPromise(repo.publishEndpointRecord({ + userId, + bindingId, + endpointId, + record, + now: NOW, + })); + expect(published.published).toBe(true); + + const [stored] = await requiredSql()>` + select + endpoint_record as "record", + endpoint_record_updated_at as "updatedAt" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + expect(stored?.record).toBe(record); + expect(stored?.updatedAt).toEqual(NOW); + + // The CHECK constraint refuses non-base64 payloads even on direct SQL. + await expect(requiredSql()` + update iroh_endpoint_bindings + set endpoint_record = 'not base64!!' + where id = ${bindingId} + `).rejects.toThrow(/endpoint_record_check/); + + // Revocation wipes the record with the same posture as path hints. + await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId, + now: new Date(NOW.getTime() + 1_000), + })); + const [afterRevoke] = await requiredSql()>` + select endpoint_record as "record" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + expect(afterRevoke?.record).toBeNull(); + + // A revoked binding accepts no further records. + const afterRevokePublish = await Effect.runPromise(repo.publishEndpointRecord({ + userId, + bindingId, + endpointId, + record, + now: new Date(NOW.getTime() + 2_000), + })); + expect(afterRevokePublish.published).toBe(false); + }); + dbTest("persists, updates, and clears family-specific direct ports", async () => { const repo = requiredRepository(); const userId = "user-direct-ports"; diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 568f26d2b23d..bf316b9ec0fc 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -328,11 +328,13 @@ describe("Iroh route boundary", () => { issueEndpointAttestation: namespaced, revoke: namespaced, issuePairGrant: namespaced, + publishEndpointRecord: namespaced, }); const operations = [ "endpoint_attestation", "revoke", "pair_grant", + "publish_record", ] as const; for (const operation of operations) { const base = authedPost("/api/devices/iroh", {}); @@ -349,11 +351,13 @@ describe("Iroh route boundary", () => { operation, { verify: async () => USER, broker: namespacedBroker }, ); - expect(response.status).toBe(operation === "revoke" ? 200 : 201); + expect(response.status).toBe( + operation === "revoke" || operation === "publish_record" ? 200 : 201, + ); } - expect(received).toEqual(Array(3).fill("dev.cmux.app.demo")); + expect(received).toEqual(Array(4).fill("dev.cmux.app.demo")); expect(receivedBindingIDs).toEqual( - Array(3).fill("123e4567-e89b-42d3-a456-426614174000"), + Array(4).fill("123e4567-e89b-42d3-a456-426614174000"), ); }); @@ -413,6 +417,7 @@ function broker(overrides: Partial = {}): IrohTrustBrokerS issueEndpointAttestation: unavailable, revoke: unavailable, issuePairGrant: unavailable, + publishEndpointRecord: unavailable, ...overrides, }; } diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 3fd9e588462d..9fcddd6fb79a 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -1905,6 +1905,143 @@ describe("Iroh discovery and grants", () => { }); }); +describe("Iroh endpoint records", () => { + const RECORD_PATH = "api/devices/iroh/endpoint-record"; + + function recordFor(endpointIdHex: string, payloadBytes = 200): string { + // A stand-in for a pkarr SignedPacket: the signing public key leads the + // serialized bytes; the broker never verifies the signature (readers + // do), so the remainder only needs to satisfy the size bounds. + const record = Buffer.concat([ + Buffer.from(endpointIdHex, "hex"), + Buffer.alloc(payloadBytes, 0xab), + ]); + return record.toString("base64"); + } + + async function registeredFixture() { + const fixture = makeFixture(); + const registered = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )) as { binding: { binding_id: string } }; + return { fixture, bindingId: registered.binding.binding_id }; + } + + test("publishes a record and serves it through discovery", async () => { + const { fixture, bindingId } = await registeredFixture(); + const record = recordFor(fixture.endpointId); + const body = { bindingId, record }; + + const published = await Effect.runPromise(fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(bindingId, "POST", RECORD_PATH, body), + )); + expect(published).toEqual({ published: true }); + + const snapshot = await Effect.runPromise( + fixture.broker.discover(USER_A, NOW), + ) as { bindings: ReadonlyArray> }; + expect(snapshot.bindings[0]?.endpoint_record).toBe(record); + }); + + test("requires the binding-request proof", async () => { + const { fixture, bindingId } = await registeredFixture(); + const body = { bindingId, record: recordFor(fixture.endpointId) }; + + await expectEffectFailure( + fixture.broker.publishEndpointRecord(USER_A, body, NOW, "legacy"), + "IrohForbiddenError", + ); + }); + + test("rejects a record whose key does not match the caller's endpoint", async () => { + const { fixture, bindingId } = await registeredFixture(); + const foreignKey = randomUUID().replaceAll("-", "").repeat(2); + const body = { bindingId, record: recordFor(foreignKey) }; + + await expectEffectFailure( + fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(bindingId, "POST", RECORD_PATH, body), + ), + "IrohForbiddenError", + ); + + const snapshot = await Effect.runPromise( + fixture.broker.discover(USER_A, NOW), + ) as { bindings: ReadonlyArray> }; + expect(snapshot.bindings[0]?.endpoint_record).toBeUndefined(); + }); + + test("rejects a proof from a different binding", async () => { + const { fixture, bindingId } = await registeredFixture(); + const body = { bindingId, record: recordFor(fixture.endpointId) }; + + await expectEffectFailure( + fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(randomUUID(), "POST", RECORD_PATH, body), + ), + "IrohNotFoundError", + ); + }); + + test("rejects malformed and oversized records", async () => { + const { fixture, bindingId } = await registeredFixture(); + + for (const record of [ + "not base64!!", + Buffer.alloc(32, 1).toString("base64"), + Buffer.alloc(4_000, 1).toString("base64"), + ]) { + const body = { bindingId, record }; + await expectEffectFailure( + fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(bindingId, "POST", RECORD_PATH, body), + ), + "IrohInvalidInputError", + ); + } + }); + + test("revocation wipes the stored record", async () => { + const { fixture, bindingId } = await registeredFixture(); + const body = { bindingId, record: recordFor(fixture.endpointId) }; + await Effect.runPromise(fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(bindingId, "POST", RECORD_PATH, body), + )); + + await Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId }, + new Date(NOW.getTime() + 1_000), + )); + + const row = fixture.repository.bindings.find((binding) => binding.id === bindingId); + expect(row?.revokedAt).not.toBeNull(); + expect(row?.endpointRecord).toBeNull(); + }); +}); + type MutableBinding = IrohBindingRecord & { userId: string; directPortV4: number | null; @@ -2201,6 +2338,21 @@ class MemoryRepository implements IrohRepositoryShape { row.userId === userId && row.endpointId === endpointId && !row.revokedAt) ?? null); } + publishEndpointRecord( + input: Parameters[0], + ) { + const bindingRow = this.bindings.find((row) => + row.id === input.bindingId + && row.userId === input.userId + && row.endpointId === input.endpointId + && !row.revokedAt); + if (!bindingRow) return Effect.succeed({ published: false }); + bindingRow.endpointRecord = input.record; + bindingRow.endpointRecordUpdatedAt = input.now; + bindingRow.updatedAt = input.now; + return Effect.succeed({ published: true }); + } + revokeBinding(input: Parameters[0]) { const row = this.bindings.find((candidate) => candidate.id === input.bindingId && candidate.userId === input.userId); @@ -2262,6 +2414,8 @@ class MemoryRepository implements IrohRepositoryShape { if (row.revokedAt) return unchanged(true); row.revokedAt = input.now; row.revokedReason = "user_requested"; + row.endpointRecord = null; + row.endpointRecordUpdatedAt = null; this.lanGenerations.set(input.userId, (this.lanGenerations.get(input.userId) ?? 1) + 1); return Effect.succeed({ revoked: true, @@ -2529,6 +2683,8 @@ function binding(overrides: Partial = {}): MutableBinding { pathHintsNextExpiry: null, relayAttachedUrl: null, relayAttachReportedAt: null, + endpointRecord: null, + endpointRecordUpdatedAt: null, deviceLimitOverrideUsed: false, lastSeenAt: now, registeredAt: now, From ec2df4fb3ee960f03de5c073bcbb00894cec96b9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:43:12 -0700 Subject: [PATCH 63/71] iroh: registry-backed AddressLookupService resolve/publish (green) resolve: in-memory record cache first (zero network), injected persisted-record source second, then at most one broker fetch through the discovery snapshot. The fetch is single-flight (concurrent resolves join the in-flight task) and cools down after failures on the transient-taxonomy split: preservesVerifiedStateDuringRefresh failures back off on the foreground-client schedule, trust failures fail closed on a 5m-floor schedule. Every well-formed fetched record is cached by its own endpoint id; only allowlist-clean, fresh (1h window, 5m skew), requested-id records are answered. Rust re-verifies signature and id before magicsock merges them as Source::AddressLookup. publish: verifies the endpoint's own record through the same policy, primes the resolve cache, and uploads the blob via the authenticated endpoint-record route; outcomes land in the iroh-diag mirror. --- .../CmxIrohRegistryAddressLookup.swift | 107 +++++++++++++++--- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift index d75ada9161a0..e686f4cd4685 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift @@ -239,24 +239,103 @@ public final class CmxIrohRegistryAddressLookup: AddressLookupService { // MARK: - AddressLookupService public func resolve(endpointId: EndpointId) async throws -> [Data] { - // Red commit: contract only. The registry-backed resolve (record - // cache, persisted cache, one bounded broker fetch) lands in the - // follow-up commit; this stub answers nothing so the contract tests - // pin the intended behavior first. let key = CmxIrohEndpointRecordPolicy.canonicalEndpointID(endpointId) - _ = await allowedRelayURLs() - recordResolve( - prefix: String(key.prefix(10)), - source: .noResults, - count: 0 - ) - return [] + let prefix = String(key.prefix(10)) + let allowed = await allowedRelayURLs() + + if let entry = await recordCache.entry(for: key), + let verified = CmxIrohEndpointRecordPolicy.acceptableRecord( + blob: entry.blob, + endpointID: key, + allowedRelayURLs: allowed, + now: dateProvider() + ) { + recordResolve(prefix: prefix, source: .recordCache, count: 1) + return [verified.blob] + } + + for blob in await persistedRecords(key) { + guard let verified = CmxIrohEndpointRecordPolicy.acceptableRecord( + blob: blob, + endpointID: key, + allowedRelayURLs: allowed, + now: dateProvider() + ) else { continue } + await recordCache.store( + blob: verified.blob, + endpointID: verified.endpointID, + signedAt: verified.signedAt, + now: dateProvider() + ) + recordResolve(prefix: prefix, source: .persistedCache, count: 1) + return [verified.blob] + } + + switch await fetchState.fetchOnce(now: dateProvider()) { + case let .fetched(blobs): + var accepted: [Data] = [] + for blob in blobs { + // Cache every well-formed fetched record (policy re-applies at + // read), answer with the ones for the requested endpoint. + guard let verified = CmxIrohEndpointRecordPolicy.acceptableRecord( + blob: blob, + endpointID: nil, + allowedRelayURLs: allowed, + now: dateProvider() + ) else { continue } + await recordCache.store( + blob: verified.blob, + endpointID: verified.endpointID, + signedAt: verified.signedAt, + now: dateProvider() + ) + if verified.endpointID == key { + accepted.append(verified.blob) + } + } + recordResolve( + prefix: prefix, + source: accepted.isEmpty ? .noResults : .brokerFetch, + count: accepted.count + ) + return accepted + case .coolingDown: + recordResolve(prefix: prefix, source: .fetchCoolingDown, count: 0) + return [] + case let .failed(transient, _): + recordResolve( + prefix: prefix, + source: transient ? .fetchFailedTransient : .fetchFailedTrust, + count: 0 + ) + throw CallbackError.Error + } } public func publish(record: Data) async throws { - // Red commit: contract only. - recordPublish(result: .rejectedRecord, byteCount: record.count) - throw CallbackError.Error + let allowed = await allowedRelayURLs() + guard let verified = CmxIrohEndpointRecordPolicy.acceptableRecord( + blob: record, + endpointID: nil, + allowedRelayURLs: allowed, + now: dateProvider() + ) else { + recordPublish(result: .rejectedRecord, byteCount: record.count) + throw CallbackError.Error + } + await recordCache.store( + blob: verified.blob, + endpointID: verified.endpointID, + signedAt: verified.signedAt, + now: dateProvider() + ) + do { + try await fetchState.publish(record: record) + recordPublish(result: .published, byteCount: record.count) + } catch { + recordPublish(result: .uploadFailed, byteCount: record.count) + throw CallbackError.Error + } } // MARK: - Private From 2e6a7f262a180f2b8c3644b605fb893c0cf275df Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:53:51 -0700 Subject: [PATCH 64/71] feat: client cache-first activation and warm cache-first dials Warm client activation (verified cached broker binding + verified offline route record) now resolves its start policy entirely from device-only caches: zero blocking broker rounds, with the authenticated registration refresh scheduled immediately behind activation (requiresDiscovery). The refresh keeps the existing fail-closed taxonomy: a non-transient rejection or authoritative discovery that drops the binding tears the runtime down and wipes the cached policy, mirroring the Mac host's cache-first activation (#10737). A broker floor (cooldown) no longer blocks a cache-first start; a cache miss still rethrows it. #10857's invariant is untouched: a fresh endpoint (no cached binding) still withholds managed relays until its registration is acknowledged, and keeps today's blocking ordering. Warm dials: CmxIrohRegistryContextProvider serves a dial from the verified offline record (grants re-verified against the stored key set) when it covers the exact tuple, arming one shared background discovery refresh behind the dial. Staleness evidence (#10739/#10865) composes unchanged: a marked-stale peer bypasses every cached source, and a background refresh that proves the cached target vanished marks the peer stale so the next dial rebuilds from fresh discovery. Rejections still never FALL BACK to cache; the reworked provider tests pin that with an explicit staleness mark so they exercise the fresh-discovery path. relay-only (relayOnly transport verification mode) activation now restores the verified cached policy for a warm client exactly like automatic mode (F3 structural proposal), refreshing immediately after activation; a fresh install, or the account whose previous cache-first relay-only activation failed (dead cached fleet), keeps the blocking refresh. --- .../CmxIrohClientRuntime+Policy.swift | 58 +++++++ .../CmxIrohClientRuntime.swift | 73 +++++++-- .../CmxIrohRegistryContextProvider.swift | 142 ++++++++++++++++++ .../CmxIrohClientRuntimeCacheFirstTests.swift | 4 +- .../CmxIrohClientRuntimeTests.swift | 28 +++- ...ohRegistryContextProviderPolicyTests.swift | 30 +++- .../MobileIrohRuntimeComposition.swift | 100 ++++++++++-- .../MobileIrohRelayOnlyCacheFirstTests.swift | 49 ++++++ 8 files changed, 446 insertions(+), 38 deletions(-) create mode 100644 ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRelayOnlyCacheFirstTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift index a08fe5a41b60..2e15a2c3c0b6 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift @@ -2,6 +2,64 @@ public import CMUXMobileCore public import Foundation extension CmxIrohClientRuntime { + /// Returns a start policy resolved entirely from the verified device-only + /// caches, so a warm launch activates with ZERO blocking broker rounds. + /// + /// It requires BOTH persisted proofs: the cached broker binding (the + /// broker has acknowledged this exact endpoint, so managed relays stayed + /// installed at bind, cmux#10857) and the offline route record whose pair + /// grants re-verify against the stored key set. `start()` then arms the + /// immediate registration refresh, whose non-transient rejection tears + /// this activation down — the same fail-closed semantics as the Mac + /// host's cache-first activation (cmux#10737). Any mismatch is a silent + /// cache miss and activation falls back to the authenticated resolve. + func cachedStartPolicy( + expectedEndpointID: CmxIrohPeerIdentity + ) async -> ResolvedPolicy? { + guard let cachedBinding = configuration.cachedBinding, + offlinePolicyCache != nil, + !managedRelayURLs.isEmpty else { return nil } + // Re-verify the LIVE endpoint address so a replaced driver cannot + // inherit the prior generation's broker tuple (same guard as the + // authenticated resolve). + guard let liveAddress = try? await connectivityEngine.endpointAddress(), + liveAddress.identity == expectedEndpointID else { return nil } + guard let expectation = try? CmxIrohLocalBindingExpectation( + deviceID: configuration.deviceID, + appInstanceID: configuration.appInstanceID, + clientNamespace: configuration.clientNamespace, + tag: configuration.tag, + platform: .ios, + endpointID: expectedEndpointID, + identityGeneration: configuration.identity.generation, + pairingEnabled: false, + capabilities: configuration.capabilities + ), let offlineExpectation = try? CmxIrohClientOfflinePolicyExpectation( + accountID: configuration.accountID, + localBindingExpectation: expectation, + managedRelayURLs: managedRelayURLs + ) else { return nil } + guard let cached = try? await offlineBootstrap( + expectation: offlineExpectation, + confirmedLocalBinding: nil + ), + CmxIrohBrokerBindingMetadata(binding: cached.localBinding) + == cachedBinding else { + return nil + } + var policy = ResolvedPolicy( + registration: nil, + discovery: nil, + binding: cached.localBinding, + expectation: expectation, + offlineExpectation: offlineExpectation, + cachedTargetBindings: cached.targetBindings, + cachedLANRendezvous: cached.lanRendezvous + ) + policy.activatedFromCache = true + return policy + } + func resolvePolicy( expectedEndpointID: CmxIrohPeerIdentity, revision: UInt64, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift index 79641a1f3349..244d1c248cbd 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift @@ -34,6 +34,15 @@ public actor CmxIrohClientRuntime { let offlineExpectation: CmxIrohClientOfflinePolicyExpectation? let cachedTargetBindings: [CmxIrohBrokerBinding] let cachedLANRendezvous: CmxIrohLANRendezvous? + /// True only for the warm-start fast path that resolved this policy + /// from the verified offline caches WITHOUT any broker round. It arms + /// the immediate post-activation registration refresh, whose + /// non-transient rejection tears the runtime down (the same + /// fail-closed semantics as the Mac host's cache-first activation, + /// cmux#10737). The connectivity-fallback path (broker unreachable) + /// deliberately leaves this false: re-requesting a broker that just + /// failed adds nothing, and network-change events own that retry. + var activatedFromCache = false } private struct ConnectivityReconciliationOperation { @@ -485,11 +494,28 @@ public actor CmxIrohClientRuntime { do { await startSupervisorObservation(revision: revision) let cachedDiscoveryTask: Task? + var brokerPreflightFailure: (any Error)? if configuration.cachedBinding != nil { - try await preparePolicyResolution(revision: revision) - cachedDiscoveryTask = Task { [weak self] in - guard let self else { return nil } - return try? await self.prefetchAuthoritativeDiscovery() + // A broker floor (cooldown, backpressure) must not block a + // cache-first activation. Remember the failure: a cache miss + // below rethrows it, preserving the previous ordering. + do { + try await preparePolicyResolution(revision: revision) + } catch let error as CmxIrohClientRuntimeError + where error == .superseded { + throw error + } catch is CancellationError { + throw CancellationError() + } catch { + brokerPreflightFailure = error + } + if brokerPreflightFailure == nil { + cachedDiscoveryTask = Task { [weak self] in + guard let self else { return nil } + return try? await self.prefetchAuthoritativeDiscovery() + } + } else { + cachedDiscoveryTask = nil } } else { cachedDiscoveryTask = nil @@ -502,12 +528,21 @@ public actor CmxIrohClientRuntime { endpointSnapshot.endpointGeneration != nil else { throw CmxIrohClientRuntimeError.invalidLocalBinding } - let policy = try await resolvePolicy( - expectedEndpointID: endpointID, - revision: revision, - prefetchedDiscovery: await cachedDiscoveryTask?.value, - brokerPreparationComplete: cachedDiscoveryTask != nil - ) + let policy: ResolvedPolicy + if let cachedStart = await cachedStartPolicy( + expectedEndpointID: endpointID + ) { + policy = cachedStart + } else if let brokerPreflightFailure { + throw brokerPreflightFailure + } else { + policy = try await resolvePolicy( + expectedEndpointID: endpointID, + revision: revision, + prefetchedDiscovery: await cachedDiscoveryTask?.value, + brokerPreparationComplete: cachedDiscoveryTask != nil + ) + } try requireCurrent(revision) try await install(policy: policy, revision: revision) if withholdsManagedRelaysUntilRegistered { @@ -551,13 +586,27 @@ public actor CmxIrohClientRuntime { } else if let lanRendezvous = policy.cachedLANRendezvous { await handleCachedBindings(policy.cachedTargetBindings, lanRendezvous) } - if policy.registration == nil, policy.discovery != nil { + if policy.registration == nil, + policy.discovery != nil || policy.activatedFromCache { registrationRefreshPending = true + // The cache-first activation never read the broker, so the + // immediate refresh must fetch authoritative discovery (a + // signed registration when publication is due). Its failure + // taxonomy is the fail-closed authority: a non-transient + // rejection tears this activation down. + registrationRefreshPendingRequiresDiscovery = + registrationRefreshPendingRequiresDiscovery + || policy.activatedFromCache } registrationRefreshEnabled = true if registrationRefreshPending { registrationRefreshPending = false - scheduleRegistrationRefresh(revision: revision) + let requiresDiscovery = registrationRefreshPendingRequiresDiscovery + registrationRefreshPendingRequiresDiscovery = false + scheduleRegistrationRefresh( + revision: revision, + requiresDiscovery: requiresDiscovery + ) } } catch { guard lifecyclePhase == .starting, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index 527934d61d6a..784038bcb88e 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -49,6 +49,9 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { /// instead of issuing their own request, so a reconnect burst costs one /// broker call and the backpressure gate sees no storm. private var sharedDiscoveryTask: Task? + /// The one in-flight refresh armed behind a cache-first dial, so a burst + /// of warm dials converges the route cache once instead of per dial. + private var cacheFirstRefreshTask: Task? /// Creates a public-route provider from the generation-less seam. public init( @@ -182,6 +185,24 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { identity: targetIdentity, deviceID: request.expectedPeerDeviceID ) + // Cache-first warm dial: when the verified offline record covers this + // exact tuple (its pair grant re-verifies against the stored key set), + // dial from it immediately and converge the route cache BEHIND the + // dial. Staleness evidence above bypasses this entirely, a failed dial + // marks the peer stale (cmux#10739/#10865), and the background refresh + // marks a vanished target stale so the next dial rebuilds from a fresh + // snapshot. A broker's authoritative rejection therefore still fails + // the tuple closed as soon as any fresh snapshot is consulted; it is + // never masked longer than one bounded cached dial. + if !requiresFreshDiscovery, + let cacheFirst = try await cacheFirstContext( + for: request, + targetIdentity: targetIdentity, + routeHints: routeHints, + at: clock + ) { + return cacheFirst + } var usedFreshDiscovery = false let discovery: CmxIrohDiscoveryResponse if !requiresFreshDiscovery, let verified = takeVerifiedDiscovery(at: clock) { @@ -414,6 +435,127 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } } + /// Builds a dial context purely from the verified offline record, or + /// returns nil (a cache miss, an unusable cached plan, or no offline + /// policy at all) so the caller continues with the authoritative resolve. + private func cacheFirstContext( + for request: CmxByteTransportRequest, + targetIdentity: CmxIrohPeerIdentity, + routeHints: [CmxIrohPathHint], + at clock: Date + ) async throws -> CmxIrohClientContext? { + guard offlinePolicy != nil else { return nil } + // A still-fresh verified snapshot (startup or refresh response) is + // stronger evidence than the stored record alone: confirm the cached + // tuple against it without consuming the one-shot window and without + // any broker round. It stays armed for the non-cached path on a miss. + let confirming = peekVerifiedDiscovery(at: clock) + let cached: CmxIrohCachedClientPolicy? + do { + cached = try await cachedPolicy( + for: request, + confirmedDiscovery: confirming, + at: clock + ) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + guard let cached else { return nil } + let resolved: CmxIrohClientContext + do { + resolved = try await context( + targetBinding: cached.targetBinding, + routeHints: routeHints, + directOnly: request.irohDirectOnlyDialCandidates, + pairGrantToken: cached.pairGrant.grant, + at: clock + ) + } catch is CancellationError { + throw CancellationError() + } catch { + // The cached route material cannot produce a dialable plan (all + // hints expired). Resolve authoritatively instead of failing. + return nil + } + guard !Self.dialPlanIsEmpty(resolved.dialPlan) else { return nil } + rememberCachedLANAuthority( + cached, + bindings: confirming?.bindings + ) + if confirming == nil { + scheduleCacheFirstRefresh(for: request, targetIdentity: targetIdentity) + } + return resolved + } + + /// Arms one background discovery refresh behind a cache-first dial. + private func scheduleCacheFirstRefresh( + for request: CmxByteTransportRequest, + targetIdentity: CmxIrohPeerIdentity + ) { + guard cacheFirstRefreshTask == nil else { return } + cacheFirstRefreshTask = Task { [weak self] in + await self?.runCacheFirstRefresh( + for: request, + targetIdentity: targetIdentity + ) + } + } + + private func runCacheFirstRefresh( + for request: CmxByteTransportRequest, + targetIdentity: CmxIrohPeerIdentity + ) async { + defer { cacheFirstRefreshTask = nil } + let fresh: CmxIrohDiscoveryResponse + do { + fresh = try await sharedDiscover( + surface: DiagnosticCorrelation().handle( + for: targetIdentity.endpointID + ) + ) + } catch { + // Transient failures leave the staleness machinery in charge: a + // failed dial on the cached plan marks the peer stale and the + // next dial refetches once the broker recovers. + return + } + // Re-validate and prune the stored record against the fresh snapshot. + // A vanished or replaced target marks the peer stale so the NEXT dial + // rebuilds from fresh discovery instead of redialing the corpse. + let confirmed: CmxIrohCachedClientPolicy? + do { + confirmed = try await cachedPolicy( + for: request, + confirmedDiscovery: fresh, + at: now() + ) + } catch { + confirmed = nil + } + if confirmed == nil { + markDiscoveryStale( + identity: targetIdentity, + deviceID: request.expectedPeerDeviceID + ) + } else { + replaceLANAuthorities(with: fresh) + } + } + + /// Reads the reusable verified snapshot without consuming its one-shot + /// window, for callers that only need it as confirming evidence. + private func peekVerifiedDiscovery(at clock: Date) -> CmxIrohDiscoveryResponse? { + guard let snapshot = verifiedDiscoverySnapshot else { return nil } + let age = clock.timeIntervalSince(snapshot.verifiedAt) + guard age >= 0, age <= Self.maximumVerifiedDiscoveryReuseAge else { + return nil + } + return snapshot.response + } + /// Consumes the startup or refresh response once, preventing an immediate /// duplicate broker lookup while bounding the revocation visibility delay. private func takeVerifiedDiscovery(at clock: Date) -> CmxIrohDiscoveryResponse? { diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift index d14bb4d8daab..ee4e703fbfbb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift @@ -108,7 +108,9 @@ struct CmxIrohClientRuntimeCacheFirstTests { await broker.waitUntilRegistrationCount(1) var failedClosed = false for _ in 0 ..< 50_000 { - if await runtime.snapshot().state == .failed { + if await runtime.snapshot().state == .failed, + await recorder.observedPolicyInvalidationCount() == 1, + await seed.store.recordCount() == 0 { failedClosed = true break } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index b352c406e5fa..50279896b755 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -261,15 +261,16 @@ struct CmxIrohClientRuntimeTests { managedRelayURLs: [fixture.relayURL], cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) ) + let broker = TestRevisionedClientBroker( + binding: localBinding, + discoveries: [rejectedRevision, rejectedRevision], + registrationRevision: 2 + ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [ TestIrohEndpoint(identity: fixture.initiator.endpointID), ]), - broker: TestRevisionedClientBroker( - binding: localBinding, - discoveries: [rejectedRevision], - registrationError: .connectivity - ), + broker: broker, configuration: configuration, pendingRevocations: CmxIrohPendingRevocationOutbox( secureStore: TestSecureCredentialStore() @@ -278,9 +279,22 @@ struct CmxIrohClientRuntimeTests { now: { fixture.now } ) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { - try await runtime.start() + // The warm caches activate immediately (cache-first). The immediate + // authenticated refresh then reads live discovery, which no longer + // lists this binding: that authoritative evidence must tear the + // activation down instead of being masked by the stale offline + // authority. + try await runtime.start() + await broker.waitUntilRegistrationCount(1) + var failedClosed = false + for _ in 0 ..< 50_000 { + if await runtime.snapshot().state == .failed { + failedClosed = true + break + } + await Task.yield() } + #expect(failedClosed) } @Test diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift index ae617b096acb..22ff01cf2f4a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift @@ -309,7 +309,6 @@ extension CmxIrohRegistryContextProviderTests { for testCase in cases { let seeded = try await seededOfflinePolicy(fixture: fixture) - let readsBeforeDial = await seeded.store.readCount() let broker = TestIrohRegistryBroker( discovery: testCase.discovery, pairGrantResponses: [], @@ -324,6 +323,12 @@ extension CmxIrohRegistryContextProviderTests { offlinePolicy: seeded.policy, now: { fixture.now } ) + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try testIrohDialPlan(publicPaths: []), + failure: .timedOut + ) + let readsBeforeDial = await seeded.store.readCount() do { _ = try await provider.context(for: fixture.request(hints: [])) @@ -362,7 +367,6 @@ extension CmxIrohRegistryContextProviderTests { func pairGrantUnauthorizedNeverConsultsOfflinePolicy() async throws { let fixture = try RegistryFixture() let seeded = try await seededOfflinePolicy(fixture: fixture) - let readsBeforeDial = await seeded.store.readCount() let rejection = CmxIrohTrustBrokerClientError.rejected( statusCode: 401, code: "unauthorized" @@ -381,6 +385,14 @@ extension CmxIrohRegistryContextProviderTests { offlinePolicy: seeded.policy, now: { fixture.now } ) + // Staleness evidence forces the fresh-discovery path (a cache-first + // dial would be served before any grant mint could be rejected). + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try testIrohDialPlan(publicPaths: []), + failure: .timedOut + ) + let readsBeforeDial = await seeded.store.readCount() await #expect(throws: rejection) { try await provider.context(for: fixture.request(hints: [])) @@ -409,7 +421,6 @@ extension CmxIrohRegistryContextProviderTests { for: expectation, now: fixture.now ) - let readsBeforeDial = await store.readCount() let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], @@ -431,6 +442,12 @@ extension CmxIrohRegistryContextProviderTests { ), now: { fixture.now } ) + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try testIrohDialPlan(publicPaths: []), + failure: .timedOut + ) + let readsBeforeDial = await store.readCount() await #expect(throws: CmxIrohTrustBrokerClientError.rejected( statusCode: 401, @@ -463,7 +480,6 @@ extension CmxIrohRegistryContextProviderTests { TestRegistryBrokerFailure.tls, TestRegistryBrokerFailure.decode, ] { - let readsBeforeDial = await store.readCount() let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], @@ -482,6 +498,12 @@ extension CmxIrohRegistryContextProviderTests { ), now: { fixture.now } ) + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try testIrohDialPlan(publicPaths: []), + failure: .timedOut + ) + let readsBeforeDial = await store.readCount() do { _ = try await provider.context(for: fixture.request(hints: [])) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index a2aeb60cb139..d4f9ce81371d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -254,6 +254,15 @@ public final class MobileIrohRuntimeComposition: private var lastKnownBindingTag: String? private var lastKnownBindingID: String? private var lifecycleRevision: UInt64 = 0 + /// True while the in-flight activation adopted the relay-only cache-first + /// policy source; consumed by the reconcile failure path below. + private var relayOnlyActivationUsedCachedPolicy = false + /// The account whose last relay-only cache-first activation FAILED. That + /// account's next activation falls back to the blocking policy refresh, + /// so a stale cached fleet (every cached relay dead) cannot trap + /// relay-only activation in a cache-restore retry loop. Cleared by the + /// next successful activation. + private var relayOnlyCacheFirstFailureAccountID: String? private var signOutPhase = SignOutPhase.idle private var signOutObservedAuthClear = false private var signOutAuthRevisionAtPreparation: UInt64? @@ -1677,10 +1686,20 @@ public final class MobileIrohRuntimeComposition: do { try await activate(accountID: targetAccountID, revision: revision) clearActivationRetryBackoff() + relayOnlyActivationUsedCachedPolicy = false + relayOnlyCacheFirstFailureAccountID = nil return .ready } catch is CancellationError { return .inactive } catch { + if relayOnlyActivationUsedCachedPolicy { + // The cache-first policy could not carry this activation + // (for example every cached relay is gone, so the relay-only + // readiness barrier timed out). Retry with the blocking live + // refresh instead of restoring the same dead catalog. + relayOnlyCacheFirstFailureAccountID = targetAccountID + relayOnlyActivationUsedCachedPolicy = false + } diagnosticLog?.record(DiagnosticEvent( .endpointFailed, a: DiagnosticTransportKind.iroh.rawValue, @@ -1726,8 +1745,32 @@ public final class MobileIrohRuntimeComposition: activationFailureKind = nil } + /// Whether a relay-only activation may restore the verified cached policy + /// instead of blocking on the live refresh. Requires the verified cached + /// broker binding (the same proof that keeps managed relays installed at + /// bind), and backs off to the blocking refresh for the account whose + /// previous cache-first activation failed, so a dead cached fleet cannot + /// trap relay-only activation in a restore loop. + nonisolated static func shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: Bool, + accountID: String, + cacheFirstFailureAccountID: String? + ) -> Bool { + hasVerifiedCachedBinding && cacheFirstFailureAccountID != accountID + } + + /// A restored policy supports relay-only activation only when it yields + /// relays the endpoint can actually dial; an unavailable or empty restore + /// falls back to the blocking refresh. + nonisolated static func relayOnlyRestoredPolicyIsUsable( + _ policy: CmxIrohEffectiveRelayPolicy + ) -> Bool { + !policy.endpointRelayProfile.activeRelays.isEmpty + } + private func activate(accountID: String, revision: UInt64) async throws { guard let auth else { throw CmxIrohClientRuntimeError.inactive } + relayOnlyActivationUsedCachedPolicy = false // Resolve the durable device id BEFORE any iroh identity exists. The // device-id resolver's continuity probe treats a device-local iroh // identity as proof the install continues on this hardware; creating @@ -1840,25 +1883,54 @@ public final class MobileIrohRuntimeComposition: ) relayPolicyNeedsImmediateRefresh = true } else { - diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) - do { - effective = try await service.refresh( - accountID: accountID, - trustRoot: relayPolicyTrustRoot, - now: now() - ) - diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) - } catch { - diagnosticLog?.record(DiagnosticEvent( - .relayPolicyRefreshFailed, - b: Self.diagnosticFailureKind(for: error).rawValue - )) - effective = await service.restore( + // Relay-only historically blocked activation on this live + // policy refresh (F3 decomposition: the largest fixed cost of + // a warm cold start). A warm client — verified cached binding + // AND a restored policy with usable relays — activates on the + // verified cached policy exactly like automatic mode; the + // immediate refresh scheduled below fails closed per the + // shared taxonomy. A FRESH endpoint (no verified cached + // binding) keeps the blocking refresh: it withholds managed + // relays until its registration is acknowledged (#10857) and + // relay-only cannot become active without a current fleet. + var restored: CmxIrohEffectiveRelayPolicy? + if Self.shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: bindingMatches, + accountID: accountID, + cacheFirstFailureAccountID: relayOnlyCacheFirstFailureAccountID + ) { + restored = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, now: now() ) + } + if let restored, + Self.relayOnlyRestoredPolicyIsUsable(restored) { + effective = restored relayPolicyNeedsImmediateRefresh = true + relayOnlyActivationUsedCachedPolicy = true + } else { + diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) + do { + effective = try await service.refresh( + accountID: accountID, + trustRoot: relayPolicyTrustRoot, + now: now() + ) + diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) + } catch { + diagnosticLog?.record(DiagnosticEvent( + .relayPolicyRefreshFailed, + b: Self.diagnosticFailureKind(for: error).rawValue + )) + effective = await service.restore( + accountID: accountID, + trustRoot: relayPolicyTrustRoot, + now: now() + ) + relayPolicyNeedsImmediateRefresh = true + } } } endpointRelayProfile = effective.endpointRelayProfile diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRelayOnlyCacheFirstTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRelayOnlyCacheFirstTests.swift new file mode 100644 index 000000000000..f7ee99ed30d6 --- /dev/null +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRelayOnlyCacheFirstTests.swift @@ -0,0 +1,49 @@ +import CmuxIrohTransport +import Foundation +import Testing +@testable import cmuxFeature + +/// Relay-only activation policy source: a warm client (verified cached broker +/// binding + a restored policy with usable relays) activates on the verified +/// cached policy exactly like automatic mode, with the authenticated refresh +/// running immediately behind activation. A fresh install, or the account +/// whose previous cache-first activation failed, keeps the blocking refresh. +@MainActor +struct MobileIrohRelayOnlyCacheFirstTests { + @Test + func warmClientAttemptsCacheFirstActivation() { + #expect(MobileIrohRuntimeComposition + .shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: true, + accountID: "account-a", + cacheFirstFailureAccountID: nil + )) + } + + @Test + func freshEndpointKeepsTheBlockingPolicyRefresh() { + #expect(!MobileIrohRuntimeComposition + .shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: false, + accountID: "account-a", + cacheFirstFailureAccountID: nil + )) + } + + @Test + func failedCacheFirstActivationFallsBackToBlockingRefreshForThatAccount() { + #expect(!MobileIrohRuntimeComposition + .shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: true, + accountID: "account-a", + cacheFirstFailureAccountID: "account-a" + )) + // Another account never inherits the failure backoff. + #expect(MobileIrohRuntimeComposition + .shouldAttemptRelayOnlyCacheFirstActivation( + hasVerifiedCachedBinding: true, + accountID: "account-b", + cacheFirstFailureAccountID: "account-a" + )) + } +} From 6ebb06eba058be6f01fe82da7b151caa3d3ad5f0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:54:07 -0700 Subject: [PATCH 65/71] test: red tests for one-round self-proof registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit challenge + register are two serialized POSTs whose only cryptographic requirement is a fresh, one-use nonce in the signed transcript. A self-contained proof (client nonce + signed unix-seconds timestamp, transcript cmux/iroh/device-registration/v2) provides the same guarantees the broker already accepts for every timestamp-signed binding request (±5-minute freshness), plus server-side one-use nonce consumption. Red across three suites: web unit tests (one-round register, replay rejection, freshness window, tamper rejection, scoped projection), DB behavior tests (atomic register + dedupe, challenge_superseded ordering across mixed one-round and two-step flows), and the Swift broker client (one HTTP round, old-server and skewed-clock fallbacks to the two-step flow, authoritative rejections never retried). --- ...xIrohTrustBrokerClientSelfProofTests.swift | 196 ++++++++++++++++ web/tests/iroh-db-behavior.test.ts | 120 ++++++++++ web/tests/iroh-trust-broker.test.ts | 219 ++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientSelfProofTests.swift diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientSelfProofTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientSelfProofTests.swift new file mode 100644 index 000000000000..94752bbf3369 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientSelfProofTests.swift @@ -0,0 +1,196 @@ +import CryptoKit +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Single-round registration: the client proves live key possession with a +/// self-contained proof (client nonce + issuedAt in the signed transcript), +/// collapsing the challenge+register pair into ONE broker round. Deployed +/// two-step brokers keep working through an explicit fallback. +@Suite +struct CmxIrohTrustBrokerClientSelfProofTests { + @Test + func registrationCompletesInOneBrokerRound() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json(status: 201, body: Self.registrationResponse), + ]) + let client = try makeClient(transport: transport) + let signer = try registrationSigner() + let prepared = try signer.prepare(payload: registrationPayload()) + + let response = try await client.register(prepared: prepared, signer: signer) + + #expect(response.binding.tag == "stable") + let requests = await transport.requests() + #expect(requests.compactMap { $0.url?.path } == [ + "/api/devices/iroh/register", + ]) + let bodyData = try #require(requests.first?.httpBody) + let body = try #require( + JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + ) + #expect(body["challengeId"] == nil) + let issuedAt = try #require(body["issuedAt"] as? Int64) + let nonce = try #require(body["nonce"] as? String) + let signature = try #require(body["signature"] as? String) + // The proof must verify over the exact v2 wire transcript. + let transcript = Data( + "cmux/iroh/device-registration/v2\n\(issuedAt)\n\(nonce)\n\(prepared.payloadSHA256)" + .utf8 + ) + let secret = Data((0 ..< 32).map(UInt8.init)) + let publicKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: secret + ).publicKey + #expect(publicKey.isValidSignature( + try Self.decodeBase64URL(signature), + for: transcript + )) + // The nonce is 32 client-generated random bytes. + #expect(try Self.decodeBase64URL(nonce).count == 32) + // Freshness comes from the signed timestamp. + #expect(abs(Date().timeIntervalSince1970 - TimeInterval(issuedAt)) < 60) + } + + @Test + func oldServerWithoutSelfProofFallsBackToTwoStepRegistration() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json(status: 400, body: #"{"error":"invalid_challenge_id"}"#), + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + .json(status: 201, body: Self.registrationResponse), + ]) + let client = try makeClient(transport: transport) + let signer = try registrationSigner() + let prepared = try signer.prepare(payload: registrationPayload()) + + let response = try await client.register(prepared: prepared, signer: signer) + + #expect(response.binding.tag == "stable") + #expect(await transport.requests().compactMap { $0.url?.path } == [ + "/api/devices/iroh/register", + "/api/devices/iroh/challenge", + "/api/devices/iroh/register", + ]) + } + + @Test + func skewedClientClockFallsBackToTwoStepRegistration() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json(status: 403, body: #"{"error":"self_proof_expired"}"#), + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + .json(status: 201, body: Self.registrationResponse), + ]) + let client = try makeClient(transport: transport) + let signer = try registrationSigner() + let prepared = try signer.prepare(payload: registrationPayload()) + + let response = try await client.register(prepared: prepared, signer: signer) + + #expect(response.binding.tag == "stable") + #expect(await transport.requests().compactMap { $0.url?.path } == [ + "/api/devices/iroh/register", + "/api/devices/iroh/challenge", + "/api/devices/iroh/register", + ]) + } + + @Test + func authoritativeRegistrationRejectionDoesNotRetryAsTwoStep() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json(status: 403, body: #"{"error":"client_namespace_mismatch"}"#), + ]) + let client = try makeClient(transport: transport) + let signer = try registrationSigner() + let prepared = try signer.prepare(payload: registrationPayload()) + + await #expect(throws: CmxIrohTrustBrokerClientError.rejected( + statusCode: 403, + code: "client_namespace_mismatch" + )) { + try await client.register(prepared: prepared, signer: signer) + } + #expect(await transport.requests().count == 1) + } + + // MARK: - Support + + private func makeClient( + transport: RecordingBrokerTransport, + discoveryScope: CmxConnectivityDiscoveryScope? = nil + ) throws -> CmxIrohTrustBrokerClient { + try CmxIrohTrustBrokerClient( + baseURL: #require(URL(string: "https://cmux.example")), + tokenSource: Self.tokenSource, + clientNamespace: "dev.cmux.app.internal", + discoveryScope: discoveryScope, + transport: transport + ) + } + + private func registrationSigner() throws -> CmxIrohRegistrationSigner { + let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))) + return try CmxIrohRegistrationSigner( + identity: CmxIrohIdentityMaterial(secretKey: secret, generation: 1), + endpointID: Self.endpointID + ) + } + + private func registrationPayload() throws -> CmxIrohRegistrationPayload { + try CmxIrohRegistrationPayload( + deviceID: "123e4567-e89b-42d3-a456-426614174001", + appInstanceID: "123e4567-e89b-42d3-a456-426614174002", + tag: "stable", + platform: .ios, + endpointID: Self.endpointID, + identityGeneration: 1, + pairingEnabled: false, + capabilities: ["control"], + pathHints: [], + now: Date(timeIntervalSince1970: 1_782_000_000) + ) + } + + private static func decodeBase64URL(_ value: String) throws -> Data { + let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) + let base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + padding + return try #require(Data(base64Encoded: base64)) + } + + private static let tokenSource = CmxIrohBrokerTokenSource( + credentialPair: { + CmxIrohBrokerCredentials(accessToken: "access", refreshToken: "refresh") + } + ) + private static let endpointID = + "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" + + private static let registrationResponse = """ + { + "binding": { + "binding_id": "123e4567-e89b-42d3-a456-426614174010", + "device_id": "123e4567-e89b-42d3-a456-426614174001", + "app_instance_id": "123e4567-e89b-42d3-a456-426614174002", + "tag": "stable", + "platform": "ios", + "display_name": null, + "endpoint_id": "\(endpointID)", + "identity_generation": 1, + "pairing_enabled": false, + "capabilities": ["control"], + "path_hints": [], + "last_seen_at": "2026-07-10T00:00:00.000Z" + }, + "relay": { + "status": "unavailable" + } + } + """ +} diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 0d7d721d58e8..9fc1989b8aa3 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -395,6 +395,126 @@ describe("Iroh trust broker database behavior", () => { expect(pathHints).toEqual([]); }); + dbTest("registers a self-contained proof atomically and dedupes its nonce", async () => { + const repo = requiredRepository(); + const userId = "user-self-proof"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "40".repeat(32); + const nonceHash = "41".repeat(32); + const input = { + userId, + nonceHash, + payloadSha256: "46".repeat(32), + payload: { + route_contract_version: 1 as const, + deviceId, + appInstanceId, + clientNamespace: "legacy", + tag: "stable", + platform: "mac" as const, + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + }, + now: NOW, + dedupeExpiresAt: new Date(NOW.getTime() + 10 * 60 * 1_000), + }; + + const first = await Effect.runPromise(repo.registerWithSelfProof(input)); + expect(first.created).toBe(true); + + // The one-use dedupe record persists as a consumed challenge row. + const [{ bindings, consumed }] = await requiredSql()>` + select + (select count(*)::text from iroh_endpoint_bindings) as bindings, + (select count(*)::text from iroh_registration_challenges + where consumed_at is not null) as consumed + `; + expect({ bindings, consumed }).toEqual({ bindings: "1", consumed: "1" }); + + // An identical nonce can never land twice. + const replay = await Effect.runPromiseExit(repo.registerWithSelfProof({ + ...input, + now: new Date(NOW.getTime() + 1_000), + })); + expect(replay._tag).toBe("Failure"); + const replayError = replay._tag === "Failure" + ? Option.getOrUndefined(Cause.failureOption(replay.cause)) + : undefined; + expect(replayError).toMatchObject({ + _tag: "IrohConflictError", + code: "self_proof_replayed", + }); + }); + + dbTest("a challenge minted before a self-proof registration cannot land after it", async () => { + const repo = requiredRepository(); + const userId = "user-self-proof-ordering"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "42".repeat(32); + const payload = { + route_contract_version: 1 as const, + deviceId, + appInstanceId, + clientNamespace: "legacy", + tag: "stable", + platform: "mac" as const, + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + }; + const staleNonceHash = "43".repeat(32); + const staleChallenge = await Effect.runPromise(repo.issueChallenge({ + userId, + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: "44".repeat(32), + nonceHash: staleNonceHash, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + + const selfProof = await Effect.runPromise(repo.registerWithSelfProof({ + userId, + nonceHash: "45".repeat(32), + payloadSha256: "47".repeat(32), + payload, + now: new Date(NOW.getTime() + 1_000), + dedupeExpiresAt: new Date(NOW.getTime() + 11 * 60 * 1_000), + })); + expect(selfProof.created).toBe(true); + + // The self-proof advanced the slot's registration high-water mark; the + // older challenge lost the race and must not overwrite it. + const late = await Effect.runPromiseExit(repo.consumeChallengeAndRegister({ + userId, + challengeId: staleChallenge.id, + nonceHash: staleNonceHash, + payload, + now: new Date(NOW.getTime() + 2_000), + })); + expect(late._tag).toBe("Failure"); + const lateError = late._tag === "Failure" + ? Option.getOrUndefined(Cause.failureOption(late.cause)) + : undefined; + expect(lateError).toMatchObject({ + _tag: "IrohConflictError", + code: "challenge_superseded", + }); + }); + dbTest("adopts legacy and tag-only Mac bindings into the bundle namespace", async () => { const repo = requiredRepository(); const userId = "user-legacy-namespace-adoption"; diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 3fd9e588462d..1b6a6c1dc7ee 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -501,6 +501,126 @@ describe("Iroh trust broker registration", () => { ); }); + test("registers a self-contained proof in one broker round", async () => { + const fixture = makeFixture(); + const request = fixture.selfProofRegistration(); + const result = await Effect.runPromise( + fixture.broker.register(USER_A, request, NOW), + ) as { + revision: number; + binding: { endpoint_id: string }; + relay: { status: string }; + discovery_complete: boolean; + discovery: { revision: number; bindings: Array<{ binding_id: string }> }; + }; + + expect(result.binding.endpoint_id).toBe(fixture.endpointId); + expect(result.relay.status).toBe("unavailable"); + expect(result.discovery.revision).toBe(result.revision); + expect(result.discovery_complete).toBe(true); + expect(fixture.repository.bindings).toHaveLength(1); + // No prior challenge round happened; the proof itself minted the one-use + // consumed dedupe record. + expect(fixture.repository.challenges).toHaveLength(1); + expect(fixture.repository.challenges[0]?.consumedAt).not.toBeNull(); + }); + + test("self-proof registration refreshes an existing slot in place", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )); + const slotId = fixture.repository.bindings[0]!.id; + + const refreshed = await Effect.runPromise(fixture.broker.register( + USER_A, + fixture.selfProofRegistration({ + issuedAtSeconds: Math.floor(NOW.getTime() / 1_000) + 1, + }), + new Date(NOW.getTime() + 1_000), + )) as { binding: { binding_id: string } }; + + expect(refreshed.binding.binding_id).toBe(slotId); + expect(fixture.repository.bindings).toHaveLength(1); + }); + + test("rejects a replayed self-proof nonce", async () => { + const fixture = makeFixture(); + const request = fixture.selfProofRegistration(); + await Effect.runPromise(fixture.broker.register(USER_A, request, NOW)); + await expectEffectFailure( + fixture.broker.register(USER_A, request, new Date(NOW.getTime() + 1_000)), + "IrohConflictError", + ); + }); + + test("rejects a self-proof outside the freshness window", async () => { + const fixture = makeFixture(); + const skewSeconds = 6 * 60; + await expectEffectFailure( + fixture.broker.register( + USER_A, + fixture.selfProofRegistration({ + issuedAtSeconds: Math.floor(NOW.getTime() / 1_000) - skewSeconds, + }), + NOW, + ), + "IrohForbiddenError", + ); + await expectEffectFailure( + fixture.broker.register( + USER_A, + fixture.selfProofRegistration({ + issuedAtSeconds: Math.floor(NOW.getTime() / 1_000) + skewSeconds, + }), + NOW, + ), + "IrohForbiddenError", + ); + expect(fixture.repository.bindings).toHaveLength(0); + }); + + test("rejects a self-proof whose signature does not cover the sent transcript", async () => { + const fixture = makeFixture(); + const request = fixture.selfProofRegistration(); + // Any post-signature mutation of the signed fields must fail: the + // timestamp here, which also guards freshness. + const tampered = { ...request, issuedAt: request.issuedAt + 1 }; + await expectEffectFailure( + fixture.broker.register(USER_A, tampered, NOW), + "IrohForbiddenError", + ); + expect(fixture.repository.bindings).toHaveLength(0); + expect(fixture.repository.challenges).toHaveLength(0); + }); + + test("self-proof registration returns the scoped discovery projection", async () => { + const fixture = makeFixture(); + const request = fixture.selfProofRegistration({ + platform: "ios", + discoveryScope: { + local_binding: { + device_id: fixture.deviceId, + app_instance_id: fixture.appInstanceId, + tag: "stable", + platform: "ios", + }, + peer_bindings: { platform: "mac", pairing_enabled: true }, + }, + }); + const result = await Effect.runPromise( + fixture.broker.register(USER_A, request, NOW), + ) as { + discovery_scope_complete?: boolean; + discovery_complete: boolean; + discovery: { bindings: Array<{ binding_id: string }> }; + }; + expect(result.discovery_scope_complete).toBe(true); + expect(result.discovery_complete).toBe(false); + }); + test("rejects expired and replayed challenges", async () => { const expired = makeFixture(); await expectEffectFailure( @@ -2104,6 +2224,51 @@ class MemoryRepository implements IrohRepositoryShape { }); } + registerWithSelfProof( + input: Parameters[0], + ) { + if (this.challenges.some((row) => + row.userId === input.userId && row.nonceHash === input.nonceHash)) { + return Effect.fail(new IrohConflictError({ code: "self_proof_replayed" })); + } + // Mirror the strict per-slot monotonic mint time so the + // challenge_superseded high-water gate stays exact across mixed flows. + const priorCreatedAt = this.challenges + .filter((row) => + row.userId === input.userId + && row.deviceUuid === input.payload.deviceId + && row.clientNamespace === input.payload.clientNamespace + && row.tag === input.payload.tag) + .map((row) => row.createdAt.getTime()) + .reduce((left, right) => Math.max(left, right), 0); + const createdAt = input.now.getTime() <= priorCreatedAt + ? new Date(priorCreatedAt + 1) + : input.now; + const challenge: IrohChallengeRecord = { + id: randomUUID(), + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + clientNamespace: input.payload.clientNamespace, + tag: input.payload.tag, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt, + expiresAt: input.dedupeExpiresAt, + consumedAt: null, + }; + this.challenges.push(challenge); + return this.consumeChallengeAndRegister({ + userId: input.userId, + challengeId: challenge.id, + nonceHash: input.nonceHash, + payload: input.payload, + now: input.now, + }); + } + discoveryPage(input: Parameters[0]) { return Effect.promise(async () => { await this.beforeDiscoverySnapshot?.(); @@ -2494,6 +2659,60 @@ function makeFixture(options: { }), endpointKeys.privateKey).toString("base64url"), }; }, + selfProofRegistration( + options2: { + platform?: "mac" | "ios"; + issuedAtSeconds?: number; + nonce?: string; + clientNamespace?: string; + discoveryScope?: unknown; + } = {}, + ) { + const payload: IrohRegistrationPayload = { + route_contract_version: 1, + deviceId, + appInstanceId, + clientNamespace: options2.clientNamespace + ?? options.registrationClientNamespace ?? "legacy", + tag: "stable", + platform: options2.platform ?? "mac", + displayName: "Test Mac", + endpointId, + identityGeneration, + pairingEnabled: true, + capabilities: ["terminal", "artifacts"], + pathHints: options.registrationPathHints ?? [{ + kind: "direct_address", + value: "8.8.8.8:4433", + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:45:00.000Z", + }], + }; + const payloadBytes = Buffer.from(JSON.stringify(payload)); + const issuedAtSeconds = options2.issuedAtSeconds + ?? Math.floor(NOW.getTime() / 1_000); + const nonce = options2.nonce + ?? Buffer.from(randomUUID().replaceAll("-", "").padEnd(64, "a"), "hex") + .toString("base64url"); + // The exact wire transcript, constructed independently of the + // production helper so a transcript drift fails this test. + const transcript = Buffer.from( + `cmux/iroh/device-registration/v2\n${issuedAtSeconds}\n${nonce}\n${sha256(payloadBytes)}`, + "utf8", + ); + return { + issuedAt: issuedAtSeconds, + nonce, + payload: payloadBytes.toString("base64url"), + signature: sign(null, transcript, endpointKeys.privateKey) + .toString("base64url"), + ...(options2.discoveryScope === undefined + ? {} + : { discoveryScope: options2.discoveryScope }), + }; + }, }; } From d0e047ea8ff53cfd958a133a05fee64722b5247c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:54:28 -0700 Subject: [PATCH 66/71] feat: collapse challenge+register into one broker round with a self-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: /api/devices/iroh/register accepts a body without challengeId carrying issuedAt (unix seconds) and a client-chosen 32-byte nonce; the Ed25519 signature covers cmux/iroh/device-registration/v2\n{issuedAt}\n {nonce}\n{payloadSha256}. Freshness is the same ±5-minute window verifyBindingRequestSignature already grants timestamp-signed binding requests; one-use comes from registerWithSelfProof, which atomically mints the challenge row already consumed (per-user select under the challenge advisory lock, global nonce_hash unique index as backstop) and applies the IDENTICAL slot registration via the extracted applyChallengeRegistration, so adoption, reincarnation, revision advance, and the challenge_superseded mint-time high-water gate cannot diverge between the flows. The dedupe row outlives the proof's whole acceptance window. The two-step wire contract is byte-for-byte unchanged for deployed clients. Client: register(prepared:signer:) sends the one-round proof first and falls back to the interactive challenge flow exactly when the two-step can repair the rejection: an older broker's parse rejection of the proof shape (400 invalid_challenge_id / unknown_field) or a client clock outside the freshness window (403 self_proof_expired). Every other verdict propagates. Randomness failure degrades to the two-step flow, whose entropy is the server nonce. Cold registration drops from 2 serialized broker rounds to 1. --- .../CmxIrohRegisterRequest.swift | 42 +- .../CmxIrohRegistrationSigner.swift | 31 +- .../CmxIrohTrustBrokerClient.swift | 40 +- .../CmxIrohTrustBrokerClientTests.swift | 18 +- web/services/iroh/crypto.ts | 41 + web/services/iroh/model.ts | 42 +- web/services/iroh/repository.ts | 1127 +++++++++-------- web/services/iroh/trustBroker.ts | 107 +- 8 files changed, 904 insertions(+), 544 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift index 659d5d2ce9d7..9b0132926501 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift @@ -1,8 +1,13 @@ -/// Signed second leg of endpoint registration. +/// Signed registration request: either the second leg of the two-step +/// challenge flow (`challengeId` present) or a one-round self-contained +/// proof (`issuedAt` present). Optional fields are omitted from the encoded +/// body, so older brokers see the exact historical two-step wire shape. public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable { - /// One-use challenge UUID. - public let challengeId: String - /// Broker nonce copied verbatim from the challenge. + /// One-use challenge UUID (two-step flow only). + public let challengeId: String? + /// Signed proof timestamp in unix seconds (self-proof flow only). + public let issuedAt: Int64? + /// The challenge nonce, or the client-chosen one-use self-proof nonce. public let nonce: String /// Base64url-encoded canonical payload bytes. public let payload: String @@ -19,6 +24,22 @@ public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable { discoveryScope: CmxConnectivityDiscoveryScope? = nil ) { challengeId = challengeID + issuedAt = nil + self.nonce = nonce + self.payload = payload + self.signature = signature + self.discoveryScope = discoveryScope + } + + init( + issuedAt: Int64, + nonce: String, + payload: String, + signature: String, + discoveryScope: CmxConnectivityDiscoveryScope? = nil + ) { + challengeId = nil + self.issuedAt = issuedAt self.nonce = nonce self.payload = payload self.signature = signature @@ -26,8 +47,17 @@ public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable { } func including(discoveryScope: CmxConnectivityDiscoveryScope?) -> Self { - Self( - challengeID: challengeId, + if let challengeId { + return Self( + challengeID: challengeId, + nonce: nonce, + payload: payload, + signature: signature, + discoveryScope: discoveryScope + ) + } + return Self( + issuedAt: issuedAt ?? 0, nonce: nonce, payload: payload, signature: signature, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift index 08751dff0443..4a730b4bba3d 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift @@ -1,5 +1,5 @@ import CryptoKit -import Foundation +public import Foundation import IrohLib /// Builds the two-leg registration proof using the Iroh EndpointID key. @@ -72,6 +72,35 @@ public struct CmxIrohRegistrationSigner: Sendable { ) } + /// Signs a one-round self-contained registration proof. + /// + /// The server-minted challenge nonce is replaced by a caller-supplied + /// one-use random nonce plus the signed timestamp; the broker enforces + /// the same bounded freshness window it grants timestamp-signed binding + /// requests and consumes the nonce exactly once. + public func signSelfProof( + prepared: CmxIrohPreparedRegistration, + nonce: Data, + issuedAt: Int64 + ) throws -> CmxIrohRegisterRequest { + guard prepared.endpointID == endpointID, + nonce.count == 32, + issuedAt > 0 else { + throw CmxIrohRegistrationError.invalidChallenge + } + let encodedNonce = Self.base64URL(nonce) + let transcript = Data( + "cmux/iroh/device-registration/v2\n\(issuedAt)\n\(encodedNonce)\n\(prepared.payloadSHA256)".utf8 + ) + let signature = signingKey.sign(message: transcript).toBytes() + return CmxIrohRegisterRequest( + issuedAt: issuedAt, + nonce: encodedNonce, + payload: prepared.encodedPayload, + signature: Self.base64URL(signature) + ) + } + /// Signs one authenticated broker request with the registered endpoint key. func signBrokerRequest( bindingID: String, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index a9999f351931..72e9226cf219 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -258,6 +258,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { private let clientNamespace: String private var bindingAuthorization: CmxIrohBindingRequestAuthorization? private let discoveryScope: CmxConnectivityDiscoveryScope? + private let randomness: any CmxIrohRandomByteGenerating = + CmxIrohSystemRandomByteGenerator() /// Creates a client that rejects cleartext non-loopback API origins. public init( @@ -351,7 +353,10 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } } - /// Runs the challenge and signed registration legs without regenerating payload bytes. + /// Registers in ONE broker round with a self-contained proof, falling + /// back to the two-leg challenge flow for brokers that do not accept it + /// (and for a client clock outside the broker's freshness window), all + /// without regenerating payload bytes. public func register( prepared: CmxIrohPreparedRegistration, signer: CmxIrohRegistrationSigner @@ -359,6 +364,24 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { let response: CmxIrohRegistrationResponse = try await withBackpressure( operation: .registration ) { + // Randomness failure is not a broker verdict: degrade to the + // two-leg flow, whose entropy is the server-minted nonce. + let selfProof = try? signer.signSelfProof( + prepared: prepared, + nonce: self.randomness.randomBytes(count: 32), + issuedAt: Int64(Date().timeIntervalSince1970) + ) + if let selfProof { + do { + return try await self.registerUngated(selfProof) + } catch let error as CmxIrohTrustBrokerClientError + where Self.retriesRegistrationAsTwoStep(error) { + // An older broker rejects the proof shape at parse + // (missing challengeId / unknown field), and a broker may + // reject this client's clock skew; both are repaired by + // the interactive challenge, never by retrying the proof. + } + } let challenge: CmxIrohChallengeResponse = try await self.sendUngated( path: "api/devices/iroh/challenge", method: "POST", @@ -375,6 +398,21 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { return response } + /// Rejections that the two-leg challenge flow can repair: an older + /// broker's parse rejection of the self-proof shape, or a client clock + /// outside the proof freshness window. Every other verdict is + /// authoritative for the registration itself and propagates. + private static func retriesRegistrationAsTwoStep( + _ error: CmxIrohTrustBrokerClientError + ) -> Bool { + guard case let .rejected(statusCode, code) = error else { return false } + if statusCode == 400, + code == "invalid_challenge_id" || code == "unknown_field" { + return true + } + return statusCode == 403 && code == "self_proof_expired" + } + /// Discovers account bindings visible to this client's exact build namespace. public func discover() async throws -> CmxIrohDiscoveryResponse { try await withBackpressure(operation: .discovery) { diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift index ed268f43971a..f852c2d86f97 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift @@ -125,8 +125,11 @@ struct CmxIrohTrustBrokerClientTests { } @Test - func combinedRegistrationUsesOneGateForBothHTTPLegs() async throws { + func combinedRegistrationUsesOneGateForAllHTTPLegs() async throws { + // An older broker rejects the one-round proof, so this registration + // spans three HTTP legs; all of them ride one backpressure grant. let transport = RecordingBrokerTransport(responses: [ + .json(status: 400, body: #"{"error":"invalid_challenge_id"}"#), .json( status: 201, body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# @@ -142,6 +145,7 @@ struct CmxIrohTrustBrokerClientTests { #expect(response.binding.tag == "stable") #expect(response.discoveryComplete == nil) #expect(await transport.requests().compactMap { $0.url?.path } == [ + "/api/devices/iroh/register", "/api/devices/iroh/challenge", "/api/devices/iroh/register", ]) @@ -150,10 +154,6 @@ struct CmxIrohTrustBrokerClientTests { @Test func postRegistrationRequestsCarryExactBindingProof() async throws { let transport = RecordingBrokerTransport(responses: [ - .json( - status: 201, - body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# - ), .json(status: 201, body: Self.registrationResponse), .json(status: 200, body: Self.discoveryResponse), ]) @@ -259,7 +259,10 @@ struct CmxIrohTrustBrokerClientTests { @Test func scopedRegistrationFallsBackWithoutRegeneratingSignedPayload() async throws { + // An old broker rejects the one-round proof, then rejects the scoped + // v1 registration; both fallbacks reuse the identical signed payload. let transport = RecordingBrokerTransport(responses: [ + .json(status: 400, body: #"{"error":"invalid_challenge_id"}"#), .json( status: 201, body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# @@ -278,12 +281,13 @@ struct CmxIrohTrustBrokerClientTests { let requests = await transport.requests() #expect(requests.compactMap { $0.url?.path } == [ + "/api/devices/iroh/register", "/api/devices/iroh/challenge", "/api/devices/iroh/register", "/api/devices/iroh/register", ]) - let scopedBody = try #require(requests[1].httpBody) - let fallbackBody = try #require(requests[2].httpBody) + let scopedBody = try #require(requests[2].httpBody) + let fallbackBody = try #require(requests[3].httpBody) var scopedObject = try #require( JSONSerialization.jsonObject(with: scopedBody) as? [String: Any] ) diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index 7ebbd427ad8c..5bb651b3c56f 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -125,6 +125,47 @@ export function verifyEndpointRegistrationSignature(input: { if (!valid) throw new IrohForbiddenError({ code: "invalid_registration_signature" }); } +/** + * One-round registration transcript. The server-minted challenge nonce is + * replaced by a client-chosen nonce plus a signed timestamp: the server + * enforces the same ±5-minute freshness window it already grants + * timestamp-signed binding requests, and consumes the nonce exactly once + * (`registerWithSelfProof`), so an observed proof can never be replayed and a + * stale signature can never register outside the skew window. + */ +export function selfProofRegistrationTranscript(input: { + readonly issuedAtSeconds: number; + readonly nonce: string; + readonly payloadSha256: string; +}): Uint8Array { + return Buffer.from( + `cmux/iroh/device-registration/v2\n${input.issuedAtSeconds}\n${input.nonce}\n${input.payloadSha256}`, + "utf8", + ); +} + +export function verifySelfProofRegistrationSignature(input: { + readonly endpointId: string; + readonly issuedAtSeconds: number; + readonly nonce: string; + readonly payloadSha256: string; + readonly signature: string; +}): void { + const publicKey = endpointPublicKey(input.endpointId); + const signature = decodeCanonicalBase64url( + input.signature, + 64, + "invalid_registration_signature", + ); + const valid = verify( + null, + selfProofRegistrationTranscript(input), + publicKey, + signature, + ); + if (!valid) throw new IrohForbiddenError({ code: "invalid_registration_signature" }); +} + export type IrohBindingRequestProof = { readonly bindingId: string; readonly method: string; diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 1e054add274a..6c14eec0549a 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -61,13 +61,21 @@ export type IrohChallengeRequest = Pick< }; export type IrohRegisterRequest = { - readonly challengeId: string; + /** Present for the two-step challenge flow; absent for a self-proof. */ + readonly challengeId?: string; + /** Present (unix seconds) for the one-round self-contained proof. */ + readonly issuedAtSeconds?: number; readonly nonce: string; readonly payload: string; readonly signature: string; readonly discoveryScope?: IrohDiscoveryScope; }; +/** Accepted clock skew for a self-contained registration proof; the same + * window `verifyBindingRequestSignature` grants timestamp-signed binding + * requests. */ +export const IROH_SELF_PROOF_MAX_SKEW_MS = IROH_CHALLENGE_LIFETIME_MS; + export function parseChallengeRequest(value: unknown): IrohChallengeRequest { const body = record(value); const parsed: IrohChallengeRequest = { @@ -93,6 +101,28 @@ export function parseChallengeRequest(value: unknown): IrohChallengeRequest { export function parseRegisterRequest(value: unknown): IrohRegisterRequest { const body = record(value); + // A body without a challengeId is the one-round self-contained proof: + // the client-chosen nonce and signed timestamp replace the minted + // challenge. A body with one keeps the exact two-step wire contract. + if (body.challengeId === undefined) { + const parsed = { + issuedAtSeconds: unixSeconds(body.issuedAt, "invalid_issued_at"), + nonce: base64url(body.nonce, 32, "invalid_nonce"), + payload: boundedString(body.payload, 1, 48_000, "invalid_payload"), + signature: base64url(body.signature, 64, "invalid_signature"), + ...(body.discoveryScope === undefined + ? {} + : { discoveryScope: parseIrohDiscoveryScope(body.discoveryScope) }), + }; + rejectUnknownKeys(body, [ + "issuedAt", + "nonce", + "payload", + "signature", + "discoveryScope", + ]); + return parsed; + } const parsed = { challengeId: uuid(body.challengeId, "invalid_challenge_id"), nonce: base64url(body.nonce, 32, "invalid_nonce"), @@ -617,6 +647,16 @@ function positiveInteger(value: unknown, code: string): number { return value as number; } +function unixSeconds(value: unknown, code: string): number { + if ( + !Number.isSafeInteger(value) + || (value as number) < 1 + // Bounded to the year ~4147 so arithmetic in milliseconds stays safe. + || (value as number) > 68_719_476_735 + ) throw new IrohInvalidInputError({ code }); + return value as number; +} + function udpPort(value: unknown): number { if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) { throw new IrohInvalidInputError({ code: "invalid_direct_ports" }); diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 327a85446a57..d37d499ad603 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -100,6 +100,24 @@ export type IrohRepositoryShape = { readonly payload: IrohRegistrationPayload; readonly now: Date; }) => Effect.Effect; + /** + * Registers in ONE round from a self-contained proof: mints the challenge + * row already consumed (the one-use nonce dedupe record) and applies the + * identical slot registration logic in the same transaction. The synthetic + * row's mint time uses the same strict per-slot monotonic order as + * `issueChallenge`, so the `challenge_superseded` high-water gate keeps + * ordering exact across mixed one-round and two-step registrations. + */ + readonly registerWithSelfProof: (input: { + readonly userId: string; + readonly nonceHash: string; + readonly payloadSha256: string; + readonly payload: IrohRegistrationPayload; + readonly now: Date; + /** How long the consumed dedupe row must outlive pruning; must cover the + * proof acceptance window. */ + readonly dedupeExpiresAt: Date; + }) => Effect.Effect; readonly discoveryPage: (input: { readonly userId: string; readonly clientNamespace?: string; @@ -193,35 +211,12 @@ function makeLiveRepository(): IrohRepositoryShape { return await db.transaction(async (tx) => { await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); - // The register gate rejects a challenge whose createdAt is strictly - // below the slot's registeredAt high-water mark. Both are millisecond - // wall clocks, so two serialized mints can carry EQUAL timestamps; a - // delayed older challenge that ties the mark passes the `<` gate and - // can land after a newer one, reversing the order the gate enforces. - // Fix at the source: make challenge mint time a strict total order per - // slot. registeredAt is only ever stamped from a challenge's createdAt - // (insert, reincarnation, and heartbeat paths alike), so if each new - // challenge is strictly newer than every prior challenge for its slot, - // the strict `<` gate is exact. All mints for a user serialize under - // the per-user challenge advisory lock above, so this read cannot race - // another mint for the same slot. - const [priorChallenge] = await tx - .select({ createdAt: irohRegistrationChallenges.createdAt }) - .from(irohRegistrationChallenges) - .where(and( - eq(irohRegistrationChallenges.userId, input.userId), - eq(irohRegistrationChallenges.deviceUuid, input.deviceUuid), - eq( - irohRegistrationChallenges.clientNamespace, - input.clientNamespace ?? "legacy", - ), - eq(irohRegistrationChallenges.tag, input.tag), - )) - .orderBy(desc(irohRegistrationChallenges.createdAt)) - .limit(1); - const createdAt = priorChallenge && input.now <= priorChallenge.createdAt - ? new Date(priorChallenge.createdAt.getTime() + 1) - : input.now; + const createdAt = await strictlyMonotonicChallengeMintTime(tx, { + userId: input.userId, + deviceUuid: input.deviceUuid, + clientNamespace: input.clientNamespace ?? "legacy", + tag: input.tag, + }, input.now); const [challenge] = await tx .insert(irohRegistrationChallenges) .values({ @@ -258,11 +253,8 @@ function makeLiveRepository(): IrohRepositoryShape { consumeChallengeAndRegister: (input) => repositoryEffect("register_binding", async () => { const db = cloudDb(); return await db.transaction(async (tx) => { - const accountPrivatePathHints = [...input.payload.pathHints]; await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${input.payload.endpointId}`}, 0))`); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:slot:${input.userId}:${input.payload.clientNamespace}:${input.payload.deviceId}:${input.payload.tag}`}, 0))`); + await acquireRegistrationSlotLocks(tx, input.userId, input.payload); const [challenge] = await tx .select() .from(irohRegistrationChallenges) @@ -276,399 +268,216 @@ function makeLiveRepository(): IrohRepositoryShape { if (challenge.consumedAt) throw new IrohConflictError({ code: "challenge_replayed" }); if (challenge.expiresAt <= input.now) throw new IrohForbiddenError({ code: "challenge_expired" }); if (challenge.nonceHash !== input.nonceHash) throw new IrohForbiddenError({ code: "invalid_challenge_nonce" }); + return await applyChallengeRegistration(tx, challenge, { + userId: input.userId, + payload: input.payload, + now: input.now, + }); + }); + }), - // The binding slot is keyed on (user, client namespace, device, tag). - // A reinstall, a - // sign-out/in, or a key rotation reuses the same slot and overwrites it - // in place (newest authenticated registration wins), preserving the row - // id so existing pair grants keep resolving. There is no generation gate: - // a reinstall resets identity_generation to 1, and gating on it would - // reintroduce the wedge that stranded a computer behind its own past self. - let [existingSlot] = await tx - .select() - .from(irohEndpointBindings) + registerWithSelfProof: (input) => repositoryEffect("register_binding", async () => { + const db = cloudDb(); + return await db.transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + // The per-user challenge advisory lock serializes mint-time + // computation exactly like issueChallenge; the slot locks then follow + // in the same global order every registration path uses. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); + await acquireRegistrationSlotLocks(tx, input.userId, input.payload); + // One-use dedupe: a nonce that ever landed for this account cannot + // land again. The global unique index on nonce_hash backstops this + // read across accounts. + const [priorNonce] = await tx + .select({ id: irohRegistrationChallenges.id }) + .from(irohRegistrationChallenges) .where(and( - eq(irohEndpointBindings.userId, input.userId), - eq(irohEndpointBindings.clientNamespace, input.payload.clientNamespace), - eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), - eq(irohEndpointBindings.tag, input.payload.tag), - isNull(irohEndpointBindings.revokedAt), + eq(irohRegistrationChallenges.userId, input.userId), + eq(irohRegistrationChallenges.nonceHash, input.nonceHash), )) - .for("update") .limit(1); + if (priorNonce) throw new IrohConflictError({ code: "self_proof_replayed" }); + const createdAt = await strictlyMonotonicChallengeMintTime(tx, { + userId: input.userId, + deviceUuid: input.payload.deviceId, + clientNamespace: input.payload.clientNamespace, + tag: input.payload.tag, + }, input.now); + let challenge: IrohChallengeRecord | undefined; + try { + [challenge] = await tx + .insert(irohRegistrationChallenges) + .values({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + clientNamespace: input.payload.clientNamespace, + tag: input.payload.tag, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt, + expiresAt: input.dedupeExpiresAt, + consumedAt: input.now, + }) + .returning(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new IrohConflictError({ code: "self_proof_replayed" }); + } + throw error; + } + if (!challenge) throw new Error("self-proof challenge insert returned no row"); + return await applyChallengeRegistration(tx, challenge, { + userId: input.userId, + payload: input.payload, + now: input.now, + }); + }); + }), - // Older rows either predate app namespaces or identify a Mac by tag - // alone. The app's endpoint identity lives in its exact signed Keychain - // access group, so a registration that proves the same endpoint, - // device, tag, and platform can atomically adopt only its own row. A - // sibling bundle cannot read that endpoint secret and cannot claim it. - if ( - !existingSlot - && input.payload.clientNamespace !== "legacy" - ) { - const adoptableNamespaces = input.payload.platform === "mac" - && input.payload.clientNamespace.startsWith("mac:") - ? ["legacy", `mac:${input.payload.tag}`] - : ["legacy"]; - const [legacySlot] = await tx + discoveryPage: (input) => repositoryEffect("discovery_page", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [existingState] = await tx + .select({ + generation: irohAccountSecurityStates.lanDiscoveryGeneration, + revision: irohAccountSecurityStates.routeRevision, + }) + .from(irohAccountSecurityStates) + .where(eq(irohAccountSecurityStates.userId, input.userId)) + .limit(1); + const [insertedState] = existingState + ? [] + : await tx + .insert(irohAccountSecurityStates) + .values({ + userId: input.userId, + lanDiscoveryGeneration: 1, + routeRevision: 0, + createdAt: input.now, + updatedAt: input.now, + }) + .returning({ + generation: irohAccountSecurityStates.lanDiscoveryGeneration, + revision: irohAccountSecurityStates.routeRevision, + }); + const state = existingState ?? insertedState; + if (!state) throw new Error("account security state returned no row"); + if (input.cursor && input.cursor.generation !== state.generation) { + throw new IrohConflictError({ code: "discovery_cursor_stale" }); + } + const clientNamespace = input.clientNamespace ?? "legacy"; + const [caller] = input.callerBindingId && input.callerPlatform + ? await tx .select() .from(irohEndpointBindings) .where(and( + eq(irohEndpointBindings.id, input.callerBindingId), eq(irohEndpointBindings.userId, input.userId), - inArray( - irohEndpointBindings.clientNamespace, - adoptableNamespaces, - ), - eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), - eq(irohEndpointBindings.tag, input.payload.tag), - eq(irohEndpointBindings.endpointId, input.payload.endpointId), - eq(irohEndpointBindings.platform, input.payload.platform), + eq(irohEndpointBindings.platform, input.callerPlatform), + eq(irohEndpointBindings.clientNamespace, clientNamespace), isNull(irohEndpointBindings.revokedAt), )) - .for("update") - .limit(1); - if (legacySlot) { - const [adoptedSlot] = await tx - .update(irohEndpointBindings) - .set({ - clientNamespace: input.payload.clientNamespace, - updatedAt: input.now, - }) - .where(and( - eq(irohEndpointBindings.id, legacySlot.id), - inArray( - irohEndpointBindings.clientNamespace, - adoptableNamespaces, - ), - isNull(irohEndpointBindings.revokedAt), - )) - .returning(); - if (!adoptedSlot) { - throw new Error("legacy binding adoption returned no row"); - } - existingSlot = adoptedSlot; + .limit(1) + : []; + const visibleRows: IrohBindingRecord[] = []; + let scanAfter = input.cursor?.afterBindingId; + const scanPageSize = Math.max(input.pageSize + 1, 256); + while (visibleRows.length <= input.pageSize) { + const rows = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + scanAfter + ? gt(irohEndpointBindings.id, scanAfter) + : undefined, + )) + .orderBy(asc(irohEndpointBindings.id)) + .limit(scanPageSize); + for (const binding of rows) { + const visible = caller + ? binding.id === caller.id || ( + caller.platform === "ios" + ? canIOSBindingUseMac(caller, binding) + : canIOSBindingUseMac(binding, caller) + ) + : clientNamespace === "legacy" + || binding.clientNamespace === clientNamespace; + if (visible) visibleRows.push(binding); + if (visibleRows.length > input.pageSize) break; } + if (visibleRows.length > input.pageSize || rows.length < scanPageSize) break; + scanAfter = rows.at(-1)?.id; + if (!scanAfter) break; } + const bindings = visibleRows.slice(0, input.pageSize); + const last = bindings.at(-1); + return { + bindings, + lanDiscoveryGeneration: state.generation, + accountRevision: state.revision, + nextCursor: visibleRows.length > input.pageSize && last + ? { + generation: state.generation, + afterBindingId: last.id, + } + : null, + }; + }); + }), - // Reject a stale challenge minted before the slot's current registration. - // Challenges resolve under the slot advisory lock, so two registrations - // for one slot serialize; without this gate an older challenge that lost - // the race (issued before the row's last registeredAt) could still land - // second and overwrite — or reincarnate away — the newer incarnation, - // reintroducing an out-of-order wedge. A live heartbeat's own challenge is - // always newer than the row it refreshes, so it passes; only a delayed or - // replayed older challenge trips this. registeredAt is the mint time of - // the newest challenge that has landed: every applied registration — - // insert, reincarnation, AND in-place heartbeat — stamps it to its own - // challenge.createdAt, so it is a monotonic high-water mark. (If a - // heartbeat left registeredAt frozen at the original insert, two reversed - // heartbeats would both clear this gate and the older one would clobber - // the newer refresh.) - if (existingSlot && challenge.createdAt < existingSlot.registeredAt) { - throw new IrohConflictError({ code: "challenge_superseded" }); - } - - // The endpoint id is a global cryptographic identity: no OTHER live - // binding may claim it. Self is excluded so a slot can rotate its own key. - const [endpointOwner] = await tx - .select({ id: irohEndpointBindings.id }) - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.endpointId, input.payload.endpointId), - isNull(irohEndpointBindings.revokedAt), - existingSlot ? ne(irohEndpointBindings.id, existingSlot.id) : undefined, - )) - .for("update") - .limit(1); - if (endpointOwner) throw new IrohConflictError({ code: "endpoint_already_bound" }); - - // A heartbeat/refresh of the live incarnation: every field that a peer - // signs into a PairGrantPeer and exact-matches at admission is unchanged - // (endpoint id, platform, identity generation). Update in place. The - // binding id is stable and no peer's admission view of this endpoint - // changes, so there is no ABA hazard and existing pair grants keep - // resolving against the same id. If any signed field diverged, we must - // NOT overwrite it on the live id: a still-valid grant signed against the - // old field would then mismatch this current binding, and the host would - // record this id in its permanent denial set — the ABA wedge. Any such - // divergence falls through to the reincarnation path and mints a fresh id. - if ( - existingSlot - && existingSlot.endpointId === input.payload.endpointId - && existingSlot.platform === input.payload.platform - && existingSlot.identityGeneration === input.payload.identityGeneration - ) { - const [updated] = await tx - .update(irohEndpointBindings) - .set({ - appInstanceId: input.payload.appInstanceId, - platform: input.payload.platform, - identityGeneration: input.payload.identityGeneration, - displayName: input.payload.displayName ?? null, - pairingEnabled: input.payload.pairingEnabled, - capabilities: [...input.payload.capabilities], - directPortV4: input.payload.directPorts?.ipv4 ?? null, - directPortV6: input.payload.directPorts?.ipv6 ?? null, - pathHints: accountPrivatePathHints, - pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), - lastSeenAt: input.now, - updatedAt: input.now, - // Advance the slot's registration high-water mark to this - // challenge's mint time so a later-landing OLDER heartbeat is - // rejected by the staleness gate instead of overwriting this - // refresh. The gate above guarantees challenge.createdAt >= - // existingSlot.registeredAt, so this only ever moves forward. - registeredAt: challenge.createdAt, - }) - .where(eq(irohEndpointBindings.id, existingSlot.id)) - .returning(); - await tx - .update(irohRegistrationChallenges) - .set({ consumedAt: input.now }) - .where(eq(irohRegistrationChallenges.id, challenge.id)); - if (!updated) throw new Error("binding update returned no row"); - const accountRevision = await advanceRouteRevision(tx, input.userId, input.now); - return { binding: updated, created: false, accountRevision }; - } - - // A NEW incarnation on an existing slot: the endpoint key rotated (a - // reinstall, a sign-out/in, or an explicit key rotation). Reusing the old - // binding id would let a peer host that already denied the OLD endpoint - // tuple permanently deny this row too — the ABA wedge that strands a - // computer behind its own past self, since a host's denial set is keyed - // on binding id, not endpoint id. So mint a NEW binding id and fully - // retire the old one through the shared revoke path: it marks the retired - // binding's pair grants revoked and rotates the account's LAN discovery - // generation so the displaced install can no longer derive rendezvous - // aliases. The rotation forces a re-pair regardless — the client's held - // grant JWS names the now-dead endpoint id and generation, so it can - // never be admitted against the new incarnation — which is why the old - // issuance rows are revoked (audit-accurate) rather than reassigned onto - // the new id. - if (existingSlot) { - await revokeActiveBindings(tx, { - userId: input.userId, - bindingIds: [existingSlot.id], - now: input.now, - reason: "slot_reincarnated", - }); - } - - const [binding] = await tx - .insert(irohEndpointBindings) - .values({ - userId: input.userId, - deviceUuid: input.payload.deviceId, - appInstanceId: input.payload.appInstanceId, - clientNamespace: input.payload.clientNamespace, - tag: input.payload.tag, - platform: input.payload.platform, - displayName: input.payload.displayName ?? null, - endpointId: input.payload.endpointId, - identityGeneration: input.payload.identityGeneration, - pairingEnabled: input.payload.pairingEnabled, - capabilities: [...input.payload.capabilities], - directPortV4: input.payload.directPorts?.ipv4 ?? null, - directPortV6: input.payload.directPorts?.ipv6 ?? null, - pathHints: accountPrivatePathHints, - pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), - lastSeenAt: input.now, - // Seed the slot's registration high-water mark from this challenge's - // MINT time, not the register-request landing time. Two challenges - // can be outstanding for a slot that does not exist yet; if an older - // one lands first and stamps its later landing time here, the - // staleness gate above would reject a genuinely newer outstanding - // challenge (its mint time falls below the landing time) and strand - // the older registration. Mint time keeps registeredAt a true, - // ordering-consistent high-water mark across insert, reincarnation, - // and heartbeat alike. - registeredAt: challenge.createdAt, - updatedAt: input.now, - }) - .returning(); - if (!binding) throw new Error("binding insert returned no row"); - - // No grant carry-over: iroh_pair_grant_issuances is an audit-only ledger - // of compact JWS tokens that were returned once and name the OLD binding - // id, endpoint, and generation. Reassigning the foreign key cannot rewrite - // a client's held token or carry authorization; it would only make the JTI - // audit point at a binding it was never signed for. The retired slot's live - // grants were already marked revoked by revokeActiveBindings above. - - if (!existingSlot) { - // A new active row changes the set traversed by discovery. Rotate the - // generation so a cursor cannot combine pages around the insertion. - // Reincarnation already rotates through revokeActiveBindings above. - await tx - .insert(irohAccountSecurityStates) - .values({ - userId: input.userId, - lanDiscoveryGeneration: 1, - createdAt: input.now, - updatedAt: input.now, - }) - .onConflictDoUpdate({ - target: irohAccountSecurityStates.userId, - set: { - lanDiscoveryGeneration: - sql`${irohAccountSecurityStates.lanDiscoveryGeneration} + 1`, - updatedAt: input.now, - }, - }); - } - await tx - .update(irohRegistrationChallenges) - .set({ consumedAt: input.now }) - .where(and( - eq(irohRegistrationChallenges.id, challenge.id), - isNull(irohRegistrationChallenges.consumedAt), - )); - const accountRevision = await advanceRouteRevision(tx, input.userId, input.now); - return { binding, created: true, accountRevision }; - }); - }), - - discoveryPage: (input) => repositoryEffect("discovery_page", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - const [existingState] = await tx - .select({ - generation: irohAccountSecurityStates.lanDiscoveryGeneration, - revision: irohAccountSecurityStates.routeRevision, - }) - .from(irohAccountSecurityStates) - .where(eq(irohAccountSecurityStates.userId, input.userId)) - .limit(1); - const [insertedState] = existingState - ? [] - : await tx - .insert(irohAccountSecurityStates) - .values({ - userId: input.userId, - lanDiscoveryGeneration: 1, - routeRevision: 0, - createdAt: input.now, - updatedAt: input.now, - }) - .returning({ - generation: irohAccountSecurityStates.lanDiscoveryGeneration, - revision: irohAccountSecurityStates.routeRevision, - }); - const state = existingState ?? insertedState; - if (!state) throw new Error("account security state returned no row"); - if (input.cursor && input.cursor.generation !== state.generation) { - throw new IrohConflictError({ code: "discovery_cursor_stale" }); - } - const clientNamespace = input.clientNamespace ?? "legacy"; - const [caller] = input.callerBindingId && input.callerPlatform - ? await tx - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.id, input.callerBindingId), - eq(irohEndpointBindings.userId, input.userId), - eq(irohEndpointBindings.platform, input.callerPlatform), - eq(irohEndpointBindings.clientNamespace, clientNamespace), - isNull(irohEndpointBindings.revokedAt), - )) - .limit(1) - : []; - const visibleRows: IrohBindingRecord[] = []; - let scanAfter = input.cursor?.afterBindingId; - const scanPageSize = Math.max(input.pageSize + 1, 256); - while (visibleRows.length <= input.pageSize) { - const rows = await tx - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.userId, input.userId), - isNull(irohEndpointBindings.revokedAt), - scanAfter - ? gt(irohEndpointBindings.id, scanAfter) - : undefined, - )) - .orderBy(asc(irohEndpointBindings.id)) - .limit(scanPageSize); - for (const binding of rows) { - const visible = caller - ? binding.id === caller.id || ( - caller.platform === "ios" - ? canIOSBindingUseMac(caller, binding) - : canIOSBindingUseMac(binding, caller) - ) - : clientNamespace === "legacy" - || binding.clientNamespace === clientNamespace; - if (visible) visibleRows.push(binding); - if (visibleRows.length > input.pageSize) break; - } - if (visibleRows.length > input.pageSize || rows.length < scanPageSize) break; - scanAfter = rows.at(-1)?.id; - if (!scanAfter) break; - } - const bindings = visibleRows.slice(0, input.pageSize); - const last = bindings.at(-1); - return { - bindings, - lanDiscoveryGeneration: state.generation, - accountRevision: state.revision, - nextCursor: visibleRows.length > input.pageSize && last - ? { - generation: state.generation, - afterBindingId: last.id, - } - : null, - }; - }); - }), - - discoverySnapshot: (input) => repositoryEffect("discovery_snapshot", async () => { - return await cloudDb().transaction(async (tx) => { - const clientNamespace = input.clientNamespace ?? "legacy"; - await assertIrohUserMutationAllowed(tx, input.userId); - // Registration, revocation, pruning, and this read share one account - // lock. The complete connectivity snapshot therefore observes one - // committed binding set and revision, even when public discovery spans - // several pages. - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - const [existingState] = await tx - .select({ - generation: irohAccountSecurityStates.lanDiscoveryGeneration, - revision: irohAccountSecurityStates.routeRevision, - }) - .from(irohAccountSecurityStates) - .where(eq(irohAccountSecurityStates.userId, input.userId)) - .limit(1); - const [insertedState] = existingState - ? [] - : await tx - .insert(irohAccountSecurityStates) - .values({ - userId: input.userId, - lanDiscoveryGeneration: 1, - routeRevision: 0, - createdAt: input.now, - updatedAt: input.now, - }) - .returning({ - generation: irohAccountSecurityStates.lanDiscoveryGeneration, - revision: irohAccountSecurityStates.routeRevision, - }); - const state = existingState ?? insertedState; - if (!state) throw new Error("account security state returned no row"); - const visibility = input.callerBindingId && input.callerPlatform - ? or( - eq(irohEndpointBindings.id, input.callerBindingId), - eq( - irohEndpointBindings.platform, - input.callerPlatform === "mac" ? "ios" : "mac", - ), - ) - : clientNamespace === "legacy" - ? undefined - : eq(irohEndpointBindings.clientNamespace, clientNamespace); - const bindings = await tx - .select() + discoverySnapshot: (input) => repositoryEffect("discovery_snapshot", async () => { + return await cloudDb().transaction(async (tx) => { + const clientNamespace = input.clientNamespace ?? "legacy"; + await assertIrohUserMutationAllowed(tx, input.userId); + // Registration, revocation, pruning, and this read share one account + // lock. The complete connectivity snapshot therefore observes one + // committed binding set and revision, even when public discovery spans + // several pages. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [existingState] = await tx + .select({ + generation: irohAccountSecurityStates.lanDiscoveryGeneration, + revision: irohAccountSecurityStates.routeRevision, + }) + .from(irohAccountSecurityStates) + .where(eq(irohAccountSecurityStates.userId, input.userId)) + .limit(1); + const [insertedState] = existingState + ? [] + : await tx + .insert(irohAccountSecurityStates) + .values({ + userId: input.userId, + lanDiscoveryGeneration: 1, + routeRevision: 0, + createdAt: input.now, + updatedAt: input.now, + }) + .returning({ + generation: irohAccountSecurityStates.lanDiscoveryGeneration, + revision: irohAccountSecurityStates.routeRevision, + }); + const state = existingState ?? insertedState; + if (!state) throw new Error("account security state returned no row"); + const visibility = input.callerBindingId && input.callerPlatform + ? or( + eq(irohEndpointBindings.id, input.callerBindingId), + eq( + irohEndpointBindings.platform, + input.callerPlatform === "mac" ? "ios" : "mac", + ), + ) + : clientNamespace === "legacy" + ? undefined + : eq(irohEndpointBindings.clientNamespace, clientNamespace); + const bindings = await tx + .select() .from(irohEndpointBindings) .where(and( eq(irohEndpointBindings.userId, input.userId), @@ -1567,103 +1376,425 @@ function retentionBudget( return resolved; } -function repositoryEffect( - operation: string, - run: () => Promise, -): Effect.Effect { - return Effect.tryPromise({ - try: run, - catch: (cause) => { - if (isDomainError(cause)) return cause; - const conflict = databaseConflict(cause); - return conflict ?? new IrohDatabaseError({ operation, cause: sanitizedDatabaseCause(cause) }); - }, - }); -} +function repositoryEffect( + operation: string, + run: () => Promise, +): Effect.Effect { + return Effect.tryPromise({ + try: run, + catch: (cause) => { + if (isDomainError(cause)) return cause; + const conflict = databaseConflict(cause); + return conflict ?? new IrohDatabaseError({ operation, cause: sanitizedDatabaseCause(cause) }); + }, + }); +} + +function isDomainError(error: unknown): error is + | IrohForbiddenError + | IrohNotFoundError + | IrohConflictError { + const tag = (error as { _tag?: unknown } | null)?._tag; + return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || + tag === "IrohConflictError"; +} + +function sanitizedDatabaseCause(cause: unknown): unknown { + const candidate = databaseCause(cause); + return { + code: typeof candidate?.code === "string" ? candidate.code : undefined, + name: typeof candidate?.name === "string" ? candidate.name : undefined, + }; +} + +function databaseConflict(cause: unknown): IrohConflictError | null { + const candidate = databaseCause(cause); + if (candidate?.code !== "23505") return null; + if (candidate.constraint === "iroh_endpoint_bindings_active_endpoint_unique") { + return new IrohConflictError({ code: "endpoint_already_bound" }); + } + // The slot advisory lock (pg_advisory_xact_lock on iroh:slot:user:device:tag) + // serializes registrations for one slot, so the partial unique index on + // (user, client namespace, device, tag) where revoked_at is null is + // unreachable in practice. + // Map it defensively anyway: without this branch a slot race would fall + // through to `return null` and leak a raw IrohDatabaseError as HTTP 500, + // when the correct signal is a typed 409 telling the client a concurrent + // newest-wins registration took the slot and it should retry. + if (candidate.constraint === "iroh_endpoint_bindings_active_slot_unique") { + return new IrohConflictError({ code: "slot_registration_superseded" }); + } + return null; +} + +function databaseCause(cause: unknown): { + readonly code?: unknown; + readonly name?: unknown; + readonly constraint?: unknown; +} | null { + let current = cause; + const seen = new Set(); + for (let depth = 0; depth < 5; depth += 1) { + if (!current || typeof current !== "object" || seen.has(current)) return null; + seen.add(current); + const candidate = current as { code?: unknown; name?: unknown; constraint?: unknown; cause?: unknown }; + if (typeof candidate.code === "string") return candidate; + current = candidate.cause; + } + return null; +} + +async function assertIrohUserMutationAllowed( + tx: CloudDbTransaction, + userId: string, +): Promise { + try { + await assertAccountDeletionUserMutationAllowed(tx, userId); + } catch (error) { + if (error instanceof AccountDeletionMutationBlockedError) { + throw new IrohConflictError({ code: "account_deletion_in_progress" }); + } + throw error; + } +} + +function retainedStoredHints(pathHints: readonly unknown[], now: Date): IrohPathHint[] { + return pathHints.flatMap((hint): IrohPathHint[] => { + try { + return [parseIrohPathHint(hint, now)]; + } catch { + return []; + } + }); +} + +function bindingMatchesGrantPeer(binding: IrohBindingRecord, peer: PairGrantPeer): boolean { + return binding.id === peer.bindingId && + binding.deviceUuid === peer.deviceId && + binding.tag === peer.tag && + binding.platform === peer.platform && + binding.endpointId === peer.endpointId && + binding.identityGeneration === peer.identityGeneration; +} + +/** + * Applies the slot registration for one already-validated challenge row + * inside the caller's transaction (which must hold the binding, endpoint, + * and slot advisory locks). Shared by the two-step challenge flow and the + * one-round self-proof flow so ordering, adoption, reincarnation, and + * revision semantics cannot diverge. + */ +async function applyChallengeRegistration( + tx: CloudDbTransaction, + challenge: IrohChallengeRecord, + input: { + readonly userId: string; + readonly payload: IrohRegistrationPayload; + readonly now: Date; + }, +): Promise { + const accountPrivatePathHints = [...input.payload.pathHints]; + // The binding slot is keyed on (user, client namespace, device, tag). + // A reinstall, a + // sign-out/in, or a key rotation reuses the same slot and overwrites it + // in place (newest authenticated registration wins), preserving the row + // id so existing pair grants keep resolving. There is no generation gate: + // a reinstall resets identity_generation to 1, and gating on it would + // reintroduce the wedge that stranded a computer behind its own past self. + let [existingSlot] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + eq(irohEndpointBindings.clientNamespace, input.payload.clientNamespace), + eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), + eq(irohEndpointBindings.tag, input.payload.tag), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + + // Older rows either predate app namespaces or identify a Mac by tag + // alone. The app's endpoint identity lives in its exact signed Keychain + // access group, so a registration that proves the same endpoint, + // device, tag, and platform can atomically adopt only its own row. A + // sibling bundle cannot read that endpoint secret and cannot claim it. + if ( + !existingSlot + && input.payload.clientNamespace !== "legacy" + ) { + const adoptableNamespaces = input.payload.platform === "mac" + && input.payload.clientNamespace.startsWith("mac:") + ? ["legacy", `mac:${input.payload.tag}`] + : ["legacy"]; + const [legacySlot] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + inArray( + irohEndpointBindings.clientNamespace, + adoptableNamespaces, + ), + eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), + eq(irohEndpointBindings.tag, input.payload.tag), + eq(irohEndpointBindings.endpointId, input.payload.endpointId), + eq(irohEndpointBindings.platform, input.payload.platform), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (legacySlot) { + const [adoptedSlot] = await tx + .update(irohEndpointBindings) + .set({ + clientNamespace: input.payload.clientNamespace, + updatedAt: input.now, + }) + .where(and( + eq(irohEndpointBindings.id, legacySlot.id), + inArray( + irohEndpointBindings.clientNamespace, + adoptableNamespaces, + ), + isNull(irohEndpointBindings.revokedAt), + )) + .returning(); + if (!adoptedSlot) { + throw new Error("legacy binding adoption returned no row"); + } + existingSlot = adoptedSlot; + } + } + + // Reject a stale challenge minted before the slot's current registration. + // Challenges resolve under the slot advisory lock, so two registrations + // for one slot serialize; without this gate an older challenge that lost + // the race (issued before the row's last registeredAt) could still land + // second and overwrite — or reincarnate away — the newer incarnation, + // reintroducing an out-of-order wedge. A live heartbeat's own challenge is + // always newer than the row it refreshes, so it passes; only a delayed or + // replayed older challenge trips this. registeredAt is the mint time of + // the newest challenge that has landed: every applied registration — + // insert, reincarnation, AND in-place heartbeat — stamps it to its own + // challenge.createdAt, so it is a monotonic high-water mark. (If a + // heartbeat left registeredAt frozen at the original insert, two reversed + // heartbeats would both clear this gate and the older one would clobber + // the newer refresh.) + if (existingSlot && challenge.createdAt < existingSlot.registeredAt) { + throw new IrohConflictError({ code: "challenge_superseded" }); + } + + // The endpoint id is a global cryptographic identity: no OTHER live + // binding may claim it. Self is excluded so a slot can rotate its own key. + const [endpointOwner] = await tx + .select({ id: irohEndpointBindings.id }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.endpointId, input.payload.endpointId), + isNull(irohEndpointBindings.revokedAt), + existingSlot ? ne(irohEndpointBindings.id, existingSlot.id) : undefined, + )) + .for("update") + .limit(1); + if (endpointOwner) throw new IrohConflictError({ code: "endpoint_already_bound" }); -function isDomainError(error: unknown): error is - | IrohForbiddenError - | IrohNotFoundError - | IrohConflictError { - const tag = (error as { _tag?: unknown } | null)?._tag; - return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || - tag === "IrohConflictError"; -} + // A heartbeat/refresh of the live incarnation: every field that a peer + // signs into a PairGrantPeer and exact-matches at admission is unchanged + // (endpoint id, platform, identity generation). Update in place. The + // binding id is stable and no peer's admission view of this endpoint + // changes, so there is no ABA hazard and existing pair grants keep + // resolving against the same id. If any signed field diverged, we must + // NOT overwrite it on the live id: a still-valid grant signed against the + // old field would then mismatch this current binding, and the host would + // record this id in its permanent denial set — the ABA wedge. Any such + // divergence falls through to the reincarnation path and mints a fresh id. + if ( + existingSlot + && existingSlot.endpointId === input.payload.endpointId + && existingSlot.platform === input.payload.platform + && existingSlot.identityGeneration === input.payload.identityGeneration + ) { + const [updated] = await tx + .update(irohEndpointBindings) + .set({ + appInstanceId: input.payload.appInstanceId, + platform: input.payload.platform, + identityGeneration: input.payload.identityGeneration, + displayName: input.payload.displayName ?? null, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + directPortV4: input.payload.directPorts?.ipv4 ?? null, + directPortV6: input.payload.directPorts?.ipv6 ?? null, + pathHints: accountPrivatePathHints, + pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), + lastSeenAt: input.now, + updatedAt: input.now, + // Advance the slot's registration high-water mark to this + // challenge's mint time so a later-landing OLDER heartbeat is + // rejected by the staleness gate instead of overwriting this + // refresh. The gate above guarantees challenge.createdAt >= + // existingSlot.registeredAt, so this only ever moves forward. + registeredAt: challenge.createdAt, + }) + .where(eq(irohEndpointBindings.id, existingSlot.id)) + .returning(); + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(eq(irohRegistrationChallenges.id, challenge.id)); + if (!updated) throw new Error("binding update returned no row"); + const accountRevision = await advanceRouteRevision(tx, input.userId, input.now); + return { binding: updated, created: false, accountRevision }; + } -function sanitizedDatabaseCause(cause: unknown): unknown { - const candidate = databaseCause(cause); - return { - code: typeof candidate?.code === "string" ? candidate.code : undefined, - name: typeof candidate?.name === "string" ? candidate.name : undefined, - }; -} + // A NEW incarnation on an existing slot: the endpoint key rotated (a + // reinstall, a sign-out/in, or an explicit key rotation). Reusing the old + // binding id would let a peer host that already denied the OLD endpoint + // tuple permanently deny this row too — the ABA wedge that strands a + // computer behind its own past self, since a host's denial set is keyed + // on binding id, not endpoint id. So mint a NEW binding id and fully + // retire the old one through the shared revoke path: it marks the retired + // binding's pair grants revoked and rotates the account's LAN discovery + // generation so the displaced install can no longer derive rendezvous + // aliases. The rotation forces a re-pair regardless — the client's held + // grant JWS names the now-dead endpoint id and generation, so it can + // never be admitted against the new incarnation — which is why the old + // issuance rows are revoked (audit-accurate) rather than reassigned onto + // the new id. + if (existingSlot) { + await revokeActiveBindings(tx, { + userId: input.userId, + bindingIds: [existingSlot.id], + now: input.now, + reason: "slot_reincarnated", + }); + } -function databaseConflict(cause: unknown): IrohConflictError | null { - const candidate = databaseCause(cause); - if (candidate?.code !== "23505") return null; - if (candidate.constraint === "iroh_endpoint_bindings_active_endpoint_unique") { - return new IrohConflictError({ code: "endpoint_already_bound" }); - } - // The slot advisory lock (pg_advisory_xact_lock on iroh:slot:user:device:tag) - // serializes registrations for one slot, so the partial unique index on - // (user, client namespace, device, tag) where revoked_at is null is - // unreachable in practice. - // Map it defensively anyway: without this branch a slot race would fall - // through to `return null` and leak a raw IrohDatabaseError as HTTP 500, - // when the correct signal is a typed 409 telling the client a concurrent - // newest-wins registration took the slot and it should retry. - if (candidate.constraint === "iroh_endpoint_bindings_active_slot_unique") { - return new IrohConflictError({ code: "slot_registration_superseded" }); - } - return null; -} + const [binding] = await tx + .insert(irohEndpointBindings) + .values({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + clientNamespace: input.payload.clientNamespace, + tag: input.payload.tag, + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + directPortV4: input.payload.directPorts?.ipv4 ?? null, + directPortV6: input.payload.directPorts?.ipv6 ?? null, + pathHints: accountPrivatePathHints, + pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), + lastSeenAt: input.now, + // Seed the slot's registration high-water mark from this challenge's + // MINT time, not the register-request landing time. Two challenges + // can be outstanding for a slot that does not exist yet; if an older + // one lands first and stamps its later landing time here, the + // staleness gate above would reject a genuinely newer outstanding + // challenge (its mint time falls below the landing time) and strand + // the older registration. Mint time keeps registeredAt a true, + // ordering-consistent high-water mark across insert, reincarnation, + // and heartbeat alike. + registeredAt: challenge.createdAt, + updatedAt: input.now, + }) + .returning(); + if (!binding) throw new Error("binding insert returned no row"); -function databaseCause(cause: unknown): { - readonly code?: unknown; - readonly name?: unknown; - readonly constraint?: unknown; -} | null { - let current = cause; - const seen = new Set(); - for (let depth = 0; depth < 5; depth += 1) { - if (!current || typeof current !== "object" || seen.has(current)) return null; - seen.add(current); - const candidate = current as { code?: unknown; name?: unknown; constraint?: unknown; cause?: unknown }; - if (typeof candidate.code === "string") return candidate; - current = candidate.cause; - } - return null; + // No grant carry-over: iroh_pair_grant_issuances is an audit-only ledger + // of compact JWS tokens that were returned once and name the OLD binding + // id, endpoint, and generation. Reassigning the foreign key cannot rewrite + // a client's held token or carry authorization; it would only make the JTI + // audit point at a binding it was never signed for. The retired slot's live + // grants were already marked revoked by revokeActiveBindings above. + + if (!existingSlot) { + // A new active row changes the set traversed by discovery. Rotate the + // generation so a cursor cannot combine pages around the insertion. + // Reincarnation already rotates through revokeActiveBindings above. + await tx + .insert(irohAccountSecurityStates) + .values({ + userId: input.userId, + lanDiscoveryGeneration: 1, + createdAt: input.now, + updatedAt: input.now, + }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { + lanDiscoveryGeneration: + sql`${irohAccountSecurityStates.lanDiscoveryGeneration} + 1`, + updatedAt: input.now, + }, + }); + } + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(and( + eq(irohRegistrationChallenges.id, challenge.id), + isNull(irohRegistrationChallenges.consumedAt), + )); + const accountRevision = await advanceRouteRevision(tx, input.userId, input.now); + return { binding, created: true, accountRevision }; } -async function assertIrohUserMutationAllowed( +async function acquireRegistrationSlotLocks( tx: CloudDbTransaction, userId: string, + payload: IrohRegistrationPayload, ): Promise { - try { - await assertAccountDeletionUserMutationAllowed(tx, userId); - } catch (error) { - if (error instanceof AccountDeletionMutationBlockedError) { - throw new IrohConflictError({ code: "account_deletion_in_progress" }); - } - throw error; - } + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${payload.endpointId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:slot:${userId}:${payload.clientNamespace}:${payload.deviceId}:${payload.tag}`}, 0))`); } -function retainedStoredHints(pathHints: readonly unknown[], now: Date): IrohPathHint[] { - return pathHints.flatMap((hint): IrohPathHint[] => { - try { - return [parseIrohPathHint(hint, now)]; - } catch { - return []; - } - }); +/** + * The register gate rejects a challenge whose createdAt is strictly below the + * slot's registeredAt high-water mark. Both are millisecond wall clocks, so + * two serialized mints can carry EQUAL timestamps; a delayed older challenge + * that ties the mark passes the `<` gate and can land after a newer one, + * reversing the order the gate enforces. Fix at the source: make challenge + * mint time a strict total order per slot. registeredAt is only ever stamped + * from a challenge's createdAt (insert, reincarnation, and heartbeat paths + * alike), so if each new challenge is strictly newer than every prior + * challenge for its slot, the strict `<` gate is exact. All mints for a user + * serialize under the per-user challenge advisory lock, so this read cannot + * race another mint for the same slot. + */ +async function strictlyMonotonicChallengeMintTime( + tx: CloudDbTransaction, + slot: { + readonly userId: string; + readonly deviceUuid: string; + readonly clientNamespace: string; + readonly tag: string; + }, + now: Date, +): Promise { + const [priorChallenge] = await tx + .select({ createdAt: irohRegistrationChallenges.createdAt }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, slot.userId), + eq(irohRegistrationChallenges.deviceUuid, slot.deviceUuid), + eq(irohRegistrationChallenges.clientNamespace, slot.clientNamespace), + eq(irohRegistrationChallenges.tag, slot.tag), + )) + .orderBy(desc(irohRegistrationChallenges.createdAt)) + .limit(1); + return priorChallenge && now <= priorChallenge.createdAt + ? new Date(priorChallenge.createdAt.getTime() + 1) + : now; } -function bindingMatchesGrantPeer(binding: IrohBindingRecord, peer: PairGrantPeer): boolean { - return binding.id === peer.bindingId && - binding.deviceUuid === peer.deviceId && - binding.tag === peer.tag && - binding.platform === peer.platform && - binding.endpointId === peer.endpointId && - binding.identityGeneration === peer.identityGeneration; +function isUniqueViolation(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code + ?? (error as { cause?: { code?: unknown } } | null)?.cause?.code; + return code === "23505"; } diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 118f43868b89..02593436a94e 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -15,6 +15,7 @@ import { verifyEndpointAttestation, verifyEndpointRegistrationSignature, verifyPairGrant, + verifySelfProofRegistrationSignature, type EndpointAttestationClaims, type IrohBindingRequestProof, type PairGrantClaims, @@ -37,6 +38,7 @@ import { import { IROH_ALPN, IROH_CHALLENGE_LIFETIME_MS, + IROH_SELF_PROOF_MAX_SKEW_MS, IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, IROH_ENDPOINT_ATTESTATION_SCOPE, IROH_ENDPOINT_ATTESTATION_VERSION, @@ -57,6 +59,7 @@ import { IrohRepository, IrohRepositoryLive, type IrohBindingRecord, + type IrohRegistrationCommit, type IrohRepositoryShape, } from "./repository"; import { @@ -369,17 +372,6 @@ export function makeIrohTrustBroker( new IrohForbiddenError({ code: "client_namespace_mismatch" }), ); } - const challenge = yield* repository.findChallenge(userId, request.challengeId); - if (!challenge) return yield* Effect.fail(new IrohNotFoundError({ resource: "challenge" })); - if (challenge.consumedAt) return yield* Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); - if (challenge.expiresAt <= now) return yield* Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); - if (!hashesEqual(challenge.payloadSha256, decoded.sha256)) { - return yield* Effect.fail(new IrohForbiddenError({ code: "payload_hash_mismatch" })); - } - if (!hashesEqual(challenge.nonceHash, nonceHash(request.nonce))) { - return yield* Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); - } - yield* parseEffect(() => assertChallengeMatchesPayload(challenge, decoded.payload)); if ( request.discoveryScope && !discoveryScopeMatchesRegistration( @@ -391,28 +383,83 @@ export function makeIrohTrustBroker( code: "invalid_discovery_scope", })); } - yield* parseEffect(() => verifyEndpointRegistrationSignature({ - endpointId: decoded.payload.endpointId, - challengeId: request.challengeId, - nonce: request.nonce, - payloadSha256: decoded.sha256, - signature: request.signature, - })); const relayPreference = yield* accountRelayPreference(userId); const savedCustomRelayURLs = customRelayURLs(relayPreference); - const registration = yield* repository.consumeChallengeAndRegister({ - userId, - challengeId: challenge.id, - nonceHash: nonceHash(request.nonce), - payload: { - ...decoded.payload, - pathHints: accountPrivateIrohPathHints( - decoded.payload.pathHints, - savedCustomRelayURLs, + const registrationPayload = { + ...decoded.payload, + pathHints: accountPrivateIrohPathHints( + decoded.payload.pathHints, + savedCustomRelayURLs, + ), + }; + let registration: IrohRegistrationCommit; + if (request.challengeId !== undefined) { + const challengeId = request.challengeId; + const challenge = yield* repository.findChallenge(userId, challengeId); + if (!challenge) return yield* Effect.fail(new IrohNotFoundError({ resource: "challenge" })); + if (challenge.consumedAt) return yield* Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); + if (challenge.expiresAt <= now) return yield* Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); + if (!hashesEqual(challenge.payloadSha256, decoded.sha256)) { + return yield* Effect.fail(new IrohForbiddenError({ code: "payload_hash_mismatch" })); + } + if (!hashesEqual(challenge.nonceHash, nonceHash(request.nonce))) { + return yield* Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); + } + yield* parseEffect(() => assertChallengeMatchesPayload(challenge, decoded.payload)); + yield* parseEffect(() => verifyEndpointRegistrationSignature({ + endpointId: decoded.payload.endpointId, + challengeId, + nonce: request.nonce, + payloadSha256: decoded.sha256, + signature: request.signature, + })); + registration = yield* repository.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash: nonceHash(request.nonce), + payload: registrationPayload, + now, + }); + } else { + // One-round self-contained proof: freshness comes from the signed + // timestamp (the same ±5-minute window binding-request proofs get), + // one-use comes from the atomic nonce dedupe inside + // registerWithSelfProof. + const issuedAtSeconds = request.issuedAtSeconds; + if (issuedAtSeconds === undefined) { + return yield* Effect.fail(new IrohInvalidInputError({ + code: "invalid_issued_at", + })); + } + if ( + Math.abs(now.getTime() - issuedAtSeconds * 1_000) + > IROH_SELF_PROOF_MAX_SKEW_MS + ) { + return yield* Effect.fail( + new IrohForbiddenError({ code: "self_proof_expired" }), + ); + } + yield* parseEffect(() => verifySelfProofRegistrationSignature({ + endpointId: decoded.payload.endpointId, + issuedAtSeconds, + nonce: request.nonce, + payloadSha256: decoded.sha256, + signature: request.signature, + })); + registration = yield* repository.registerWithSelfProof({ + userId, + nonceHash: nonceHash(request.nonce), + payloadSha256: decoded.sha256, + payload: registrationPayload, + now, + // The consumed dedupe row must outlive the proof's entire + // acceptance window (a future-dated issuedAt stays valid until + // issuedAt + skew), plus a boundary second. + dedupeExpiresAt: new Date( + issuedAtSeconds * 1_000 + IROH_SELF_PROOF_MAX_SKEW_MS + 1_000, ), - }, - now, - }); + }); + } // Registration never mints a relay credential: relay admission is the // relay's allow hook against the proven endpoint key, so no client From 2ea6757586274f61d1ae174a62d0544ac519c4af Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:00:15 -0700 Subject: [PATCH 67/71] fix: expose relay dialability publicly for the relay-only cache-first predicate The iOS app compile caught relayOnlyRestoredPolicyIsUsable reading the transport-internal activeRelays. Add hasDialableRelays as the public, purpose-named accessor instead of widening the raw relay list. --- .../CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift | 6 ++++++ .../Sources/cmuxFeature/MobileIrohRuntimeComposition.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift index 1081fb609306..2d97e53f947a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift @@ -19,6 +19,12 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { /// Exact relay origins accepted in peer reachability hints. public let allowedRelayURLs: Set + /// Whether this profile installs at least one dialable relay (an + /// unavailable or empty selection dials none). Composition roots use + /// this to decide whether a restored cached policy can carry a + /// relay-only activation. + public var hasDialableRelays: Bool { !activeRelays.isEmpty } + let source: Source let activeRelays: [Relay] diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index d4f9ce81371d..275ed3df30bb 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -1765,7 +1765,7 @@ public final class MobileIrohRuntimeComposition: nonisolated static func relayOnlyRestoredPolicyIsUsable( _ policy: CmxIrohEffectiveRelayPolicy ) -> Bool { - !policy.endpointRelayProfile.activeRelays.isEmpty + policy.endpointRelayProfile.hasDialableRelays } private func activate(accountID: String, revision: UInt64) async throws { From cb90da965c1f11556d54e704c9c24242d3b090b2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:11:54 -0700 Subject: [PATCH 68/71] style: review round 1 policy fixes (file-scope helper, split test fixture) cmxRegistrationRetriesAsTwoStep becomes a file-scope private helper per the package-design policy, and CacheFirstRuntimeSeed moves to its own test support file. --- .../CmxIrohTrustBrokerClient.swift | 32 +++++------ .../CacheFirstRuntimeSeed.swift | 53 +++++++++++++++++++ .../CmxIrohClientRuntimeCacheFirstTests.swift | 50 ----------------- 3 files changed, 69 insertions(+), 66 deletions(-) create mode 100644 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CacheFirstRuntimeSeed.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index 72e9226cf219..839412a993ba 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -201,6 +201,21 @@ struct CmxIrohURLSessionTransport: CmxIrohHTTPTransport { /// Authenticated client for endpoint registration, discovery, grants, and relay tokens. private struct DiscoverySnapshotChanged: Error {} +/// Rejections that the two-leg challenge flow can repair: an older broker's +/// parse rejection of the self-proof shape, or a client clock outside the +/// proof freshness window. Every other verdict is authoritative for the +/// registration itself and propagates. +private func cmxRegistrationRetriesAsTwoStep( + _ error: CmxIrohTrustBrokerClientError +) -> Bool { + guard case let .rejected(statusCode, code) = error else { return false } + if statusCode == 400, + code == "invalid_challenge_id" || code == "unknown_field" { + return true + } + return statusCode == 403 && code == "self_proof_expired" +} + public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { private struct ConnectivitySyncRequest: Encodable { let protocolVersion: Int @@ -375,7 +390,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { do { return try await self.registerUngated(selfProof) } catch let error as CmxIrohTrustBrokerClientError - where Self.retriesRegistrationAsTwoStep(error) { + where cmxRegistrationRetriesAsTwoStep(error) { // An older broker rejects the proof shape at parse // (missing challengeId / unknown field), and a broker may // reject this client's clock skew; both are repaired by @@ -398,21 +413,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { return response } - /// Rejections that the two-leg challenge flow can repair: an older - /// broker's parse rejection of the self-proof shape, or a client clock - /// outside the proof freshness window. Every other verdict is - /// authoritative for the registration itself and propagates. - private static func retriesRegistrationAsTwoStep( - _ error: CmxIrohTrustBrokerClientError - ) -> Bool { - guard case let .rejected(statusCode, code) = error else { return false } - if statusCode == 400, - code == "invalid_challenge_id" || code == "unknown_field" { - return true - } - return statusCode == 403 && code == "self_proof_expired" - } - /// Discovers account bindings visible to this client's exact build namespace. public func discover() async throws -> CmxIrohDiscoveryResponse { try await withBackpressure(operation: .discovery) { diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CacheFirstRuntimeSeed.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CacheFirstRuntimeSeed.swift new file mode 100644 index 000000000000..7d3c22fe02f9 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CacheFirstRuntimeSeed.swift @@ -0,0 +1,53 @@ +import CMUXMobileCore +import Foundation +@testable import CmuxIrohTransport + +/// A warm client's persisted state: a broker binding metadata cache plus a +/// signed offline route record whose pair grant verifies against the stored +/// key set. +struct CacheFirstRuntimeSeed { + let fixture: RegistryFixture + let localBinding: CmxIrohBrokerBinding + let targetBinding: CmxIrohBrokerBinding + let store: TestSecureCredentialStore + let cache: CmxIrohClientOfflinePolicyCache + let configuration: CmxIrohClientRuntimeConfiguration + + init(seedOfflineRecord: Bool = true) async throws { + fixture = try RegistryFixture() + let discovery = try fixture.discovery(targetHints: [], revision: 1) + localBinding = discovery.bindings[0] + targetBinding = discovery.bindings[1] + store = TestSecureCredentialStore() + cache = CmxIrohClientOfflinePolicyCache(secureStore: store) + if seedOfflineRecord { + try await cache.save( + localBinding: localBinding, + targetBinding: targetBinding, + discovery: discovery, + pairGrant: fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 3_600 + ), + for: fixture.offlineExpectation(), + now: fixture.now + ) + } + let identity = try CmxIrohIdentityMaterial( + secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), + generation: fixture.initiator.identityGeneration + ) + configuration = CmxIrohClientRuntimeConfiguration( + accountID: "account-a", + deviceID: fixture.initiator.deviceID, + appInstanceID: localBinding.appInstanceID, + clientNamespace: localBinding.clientNamespace, + tag: fixture.initiator.tag, + displayName: nil, + identity: identity, + capabilities: localBinding.capabilities, + managedRelayURLs: [fixture.relayURL], + cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) + ) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift index ee4e703fbfbb..705be881b649 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift @@ -198,53 +198,3 @@ struct CmxIrohClientRuntimeCacheFirstTests { await runtime.stop() } } - -/// A warm client's persisted state: a broker binding metadata cache plus a -/// signed offline route record whose pair grant verifies against the stored -/// key set. -struct CacheFirstRuntimeSeed { - let fixture: RegistryFixture - let localBinding: CmxIrohBrokerBinding - let targetBinding: CmxIrohBrokerBinding - let store: TestSecureCredentialStore - let cache: CmxIrohClientOfflinePolicyCache - let configuration: CmxIrohClientRuntimeConfiguration - - init(seedOfflineRecord: Bool = true) async throws { - fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: [], revision: 1) - localBinding = discovery.bindings[0] - targetBinding = discovery.bindings[1] - store = TestSecureCredentialStore() - cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - if seedOfflineRecord { - try await cache.save( - localBinding: localBinding, - targetBinding: targetBinding, - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - } - let identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), - generation: fixture.initiator.identityGeneration - ) - configuration = CmxIrohClientRuntimeConfiguration( - accountID: "account-a", - deviceID: fixture.initiator.deviceID, - appInstanceID: localBinding.appInstanceID, - clientNamespace: localBinding.clientNamespace, - tag: fixture.initiator.tag, - displayName: nil, - identity: identity, - capabilities: localBinding.capabilities, - managedRelayURLs: [fixture.relayURL], - cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) - ) - } -} From 06423130b0302f95b47db5a2d19d09e1d5288a07 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:21:25 -0700 Subject: [PATCH 69/71] fix: fence the cache-first refresh to the lifecycle that authorized it Review round 2 P1: the refresh armed behind a cache-first dial could outlive the provider context (runtime teardown, policy identity replacement) and later mutate the new context's staleness marks and LAN authorities. cancelCacheFirstRefresh() bumps a generation fence and cancels the task; runtime network teardown and updatePolicy's identity replacement both invoke it, and the refresh re-checks the generation after every suspension before touching provider state. Regression test holds the refresh's broker call, cancels, releases, and proves the late result cannot mark the peer stale. --- .../CmxIrohClientRuntime+Lifecycle.swift | 1 + .../CmxIrohRegistryContextProvider.swift | 32 ++++++++++- ...gistryContextProviderCacheFirstTests.swift | 55 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift index 3e472a6b0d06..0ea08f87e96b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift @@ -72,6 +72,7 @@ extension CmxIrohClientRuntime { registrationRefreshEnabled = false supervisorEventTask?.cancel() supervisorEventTask = nil + await registryContextProvider?.cancelCacheFirstRefresh() await contextRouter.clear() authoritativeDiscovery = nil if !preserveBinding { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index 784038bcb88e..d304242dc906 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -52,6 +52,11 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { /// The one in-flight refresh armed behind a cache-first dial, so a burst /// of warm dials converges the route cache once instead of per dial. private var cacheFirstRefreshTask: Task? + /// Lifecycle fence for the cache-first refresh: bumped whenever the + /// authorizing context changes (policy identity replacement, runtime + /// teardown), so a refresh started under an older context can never + /// mutate newer provider state. + private var cacheFirstRefreshGeneration: UInt64 = 0 /// Creates a public-route provider from the generation-less seam. public init( @@ -421,6 +426,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { authoritativeDiscovery = nil staleDiscoveryPeers.removeAll(keepingCapacity: false) staleDiscoveryDeviceIDs.removeAll(keepingCapacity: false) + cancelCacheFirstRefresh() } self.localBindingExpectation = localBindingExpectation self.managedRelayURLs = managedRelayURLs @@ -496,19 +502,37 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { targetIdentity: CmxIrohPeerIdentity ) { guard cacheFirstRefreshTask == nil else { return } + let generation = cacheFirstRefreshGeneration cacheFirstRefreshTask = Task { [weak self] in await self?.runCacheFirstRefresh( for: request, - targetIdentity: targetIdentity + targetIdentity: targetIdentity, + generation: generation ) } } + /// Cancels the refresh armed behind a cache-first dial and fences any + /// already-running one off provider state. The owning runtime calls this + /// from network teardown, and a policy identity replacement calls it from + /// ``updatePolicy``, so refresh work can never outlive the lifecycle that + /// authorized it or mutate a newer context's caches. + func cancelCacheFirstRefresh() { + cacheFirstRefreshGeneration &+= 1 + cacheFirstRefreshTask?.cancel() + cacheFirstRefreshTask = nil + } + private func runCacheFirstRefresh( for request: CmxByteTransportRequest, - targetIdentity: CmxIrohPeerIdentity + targetIdentity: CmxIrohPeerIdentity, + generation: UInt64 ) async { - defer { cacheFirstRefreshTask = nil } + defer { + if generation == cacheFirstRefreshGeneration { + cacheFirstRefreshTask = nil + } + } let fresh: CmxIrohDiscoveryResponse do { fresh = try await sharedDiscover( @@ -522,6 +546,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { // next dial refetches once the broker recovers. return } + guard generation == cacheFirstRefreshGeneration else { return } // Re-validate and prune the stored record against the fresh snapshot. // A vanished or replaced target marks the peer stale so the NEXT dial // rebuilds from fresh discovery instead of redialing the corpse. @@ -535,6 +560,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } catch { confirmed = nil } + guard generation == cacheFirstRefreshGeneration else { return } if confirmed == nil { markDiscoveryStale( identity: targetIdentity, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift index 74353c4da212..8dcacb75662e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift @@ -148,6 +148,61 @@ struct CmxIrohRegistryContextProviderCacheFirstTests { #expect(await broker.discoveryRequestCount() == 2) } + /// A refresh armed behind a cache-first dial must not outlive the + /// context that authorized it: after cancellation (runtime teardown, + /// policy identity replacement) its late result cannot mutate provider + /// state, so the cached record still serves the next dial. + @Test + func cancelledCacheFirstRefreshCannotMutateProviderState() async throws { + let fixture = try RegistryFixture() + let seeded = try await seedOfflinePolicy(fixture: fixture) + // The held snapshot would prove the target vanished, which an + // un-fenced late refresh would turn into a staleness mark. + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [], includeTarget: false), + pairGrantResponses: [] + ) + await broker.holdDiscoverCalls() + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + offlinePolicy: seeded.policy + ) + + let first = try await provider.context(for: fixture.request(hints: [])) + #expect(first.credential.pairGrantToken == seeded.grant.grant) + var refreshHeld = false + for _ in 0 ..< 50_000 { + if await broker.heldDiscoverCallCount() >= 1 { + refreshHeld = true + break + } + await Task.yield() + } + #expect(refreshHeld) + + await provider.cancelCacheFirstRefresh() + await broker.releaseHeldDiscoverCalls() + var refreshDrained = false + for _ in 0 ..< 50_000 { + if await broker.discoveryRequestCount() >= 1 { + refreshDrained = true + break + } + await Task.yield() + } + #expect(refreshDrained) + for _ in 0 ..< 2_000 { + await Task.yield() + } + + // The fenced-off result must not have marked the peer stale: the + // next dial is still served from the verified cached record. + let second = try await provider.context(for: fixture.request(hints: [])) + #expect(second.credential.pairGrantToken == seeded.grant.grant) + #expect(await broker.pairGrantRequestCount() == 0) + } + // MARK: - Support private func makeProvider( From 9b46aca3eb0b99ac6e3d6f91b554d65a1dd6fb14 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:25:05 -0700 Subject: [PATCH 70/71] review: nibble-table hex, DocC on new public surface, real-Promise rejects - canonicalEndpointID now builds hex through a pure-Swift nibble table instead of per-byte String(format:), which the pinned review flagged as the known allocation hazard on concurrent hot paths (resolve runs it per dial and per fetched record). - DocC on the new public diag outcome fields and the record-broker extension methods. - The DB behavior test wraps the CHECK-violation update in a real Promise: a lazy postgres.js query object hangs under bun's expect().rejects, which awaits the thenable more than once; the hang wedged the whole DB lane after the failing await timed out. --- .../CmxIrohEndpointRecordPolicy.swift | 14 +++++++++++++- .../CmxIrohRegistryAddressLookup.swift | 10 ++++++++++ .../CmxIrohTrustBrokerClient+EndpointRecords.swift | 3 +++ web/tests/iroh-db-behavior.test.ts | 14 +++++++++----- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift index afbc3c6729bf..241b940fc90e 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift @@ -79,8 +79,20 @@ public enum CmxIrohEndpointRecordPolicy { } /// The canonical lowercase-hex form of an endpoint id. + /// + /// Pure-Swift nibble table, not `String(format:)`: this runs on every + /// resolve and for every broker-fetched record, and per-byte Foundation + /// format calls are a known allocation hazard on concurrent hot paths. public static func canonicalEndpointID(_ endpointID: EndpointId) -> String { - endpointID.toBytes().map { String(format: "%02x", $0) }.joined() + let digits: [UInt8] = Array("0123456789abcdef".utf8) + let bytes = endpointID.toBytes() + var hex: [UInt8] = [] + hex.reserveCapacity(bytes.count * 2) + for byte in bytes { + hex.append(digits[Int(byte >> 4)]) + hex.append(digits[Int(byte & 0x0F)]) + } + return String(decoding: hex, as: UTF8.self) } /// Exact-origin allowlist match, tolerating one trailing slash the same diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift index e686f4cd4685..3c23d5fb19d4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift @@ -44,16 +44,23 @@ public struct CmxIrohAddressLookupDiagnostics: Equatable, Sendable { /// One completed resolve, keyed by a shortened endpoint id. public struct ResolveOutcome: Equatable, Sendable { + /// The first ten hex characters of the requested endpoint id. public let endpointIDPrefix: String + /// Where the answer came from, or why it produced nothing. public let source: ResolveSource + /// How many signed records were returned to iroh. public let recordCount: Int + /// When the resolve completed. public let at: Date } /// One completed publish callback. public struct PublishOutcome: Equatable, Sendable { + /// The terminal state of the callback. public let result: PublishResult + /// The size of the signed record handed to the callback. public let recordByteCount: Int + /// When the publish completed. public let at: Date } @@ -238,6 +245,8 @@ public final class CmxIrohRegistryAddressLookup: AddressLookupService { // MARK: - AddressLookupService + /// Resolves signed records for `endpointId`: record cache, then + /// persisted caches, then at most one bounded broker fetch. public func resolve(endpointId: EndpointId) async throws -> [Data] { let key = CmxIrohEndpointRecordPolicy.canonicalEndpointID(endpointId) let prefix = String(key.prefix(10)) @@ -312,6 +321,7 @@ public final class CmxIrohRegistryAddressLookup: AddressLookupService { } } + /// Verifies, caches, and uploads this endpoint's own signed record. public func publish(record: Data) async throws { let allowed = await allowedRelayURLs() guard let verified = CmxIrohEndpointRecordPolicy.acceptableRecord( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift index 0c405ff72ce6..01c858d6bc0b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift @@ -5,10 +5,13 @@ public import Foundation /// backpressure gate); publish requires the retained binding authorization, /// so a pre-registration publish fails typed instead of dialing unauthenticated. extension CmxIrohTrustBrokerClient: CmxIrohEndpointRecordBroker { + /// Fetches every stored endpoint record from the discovery snapshot. public func fetchEndpointRecords() async throws -> [Data] { try await discover().bindings.compactMap(\.endpointRecord) } + /// Uploads this endpoint's own signed record under the retained + /// binding authorization. public func publishEndpointRecord(_ record: Data) async throws { guard let bindingID = await bindingAuthorizationID() else { throw CmxIrohTrustBrokerClientError.missingAuthentication diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index c15de10fc601..93fc1ca2b6c6 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -750,11 +750,15 @@ describe("Iroh trust broker database behavior", () => { expect(stored?.updatedAt).toEqual(NOW); // The CHECK constraint refuses non-base64 payloads even on direct SQL. - await expect(requiredSql()` - update iroh_endpoint_bindings - set endpoint_record = 'not base64!!' - where id = ${bindingId} - `).rejects.toThrow(/endpoint_record_check/); + // (Wrapped in a real Promise: a lazy postgres.js query object hangs + // under expect().rejects, which awaits the thenable more than once.) + await expect((async () => { + await requiredSql()` + update iroh_endpoint_bindings + set endpoint_record = 'not base64!!' + where id = ${bindingId} + `; + })()).rejects.toThrow(/endpoint_record_check/); // Revocation wipes the record with the same posture as path hints. await Effect.runPromise(repo.revokeBinding({ From 6a3dfcf717569cef6ec2c431aa609bfd8f0e8418 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:26:19 -0700 Subject: [PATCH 71/71] merge: adapt cache-first dial tests to the optional admission credential The pairing-allowlist series made CmxIrohClientContext.credential optional (credential-less admission for established peers); the cache-first tests now unwrap it. --- .../CmxIrohRegistryContextProviderCacheFirstTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift index 8dcacb75662e..4826d3eeb032 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift @@ -31,7 +31,7 @@ struct CmxIrohRegistryContextProviderCacheFirstTests { let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.credential.pairGrantToken == seeded.grant.grant) + #expect(context.credential?.pairGrantToken == seeded.grant.grant) #expect(await broker.pairGrantRequestCount() == 0) } @@ -52,7 +52,7 @@ struct CmxIrohRegistryContextProviderCacheFirstTests { ) let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.credential.pairGrantToken == seeded.grant.grant) + #expect(context.credential?.pairGrantToken == seeded.grant.grant) var refreshed = false for _ in 0 ..< 50_000 { @@ -170,7 +170,7 @@ struct CmxIrohRegistryContextProviderCacheFirstTests { ) let first = try await provider.context(for: fixture.request(hints: [])) - #expect(first.credential.pairGrantToken == seeded.grant.grant) + #expect(first.credential?.pairGrantToken == seeded.grant.grant) var refreshHeld = false for _ in 0 ..< 50_000 { if await broker.heldDiscoverCallCount() >= 1 { @@ -199,7 +199,7 @@ struct CmxIrohRegistryContextProviderCacheFirstTests { // The fenced-off result must not have marked the peer stale: the // next dial is still served from the verified cached record. let second = try await provider.context(for: fixture.request(hints: [])) - #expect(second.credential.pairGrantToken == seeded.grant.grant) + #expect(second.credential?.pairGrantToken == seeded.grant.grant) #expect(await broker.pairGrantRequestCount() == 0) }