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/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/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index 784a545bb1f1..4716684b4dd5 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. @@ -306,6 +314,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) @@ -332,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? @@ -533,40 +535,63 @@ public actor CmxConnectivityEngine { let protocolConfiguration = protocolConfiguration let diagnosticLog = diagnosticLog let clock = clock + let dialPhaseTimeout = dialPhaseTimeout let peer = CmxConnectivityPeerSession( 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 - ) - }, - 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 @@ -914,5 +939,3 @@ public actor CmxConnectivityEngine { return lhs.deviceID < rhs.deviceID } } - -extension CmxConnectivityEngine: CmxIrohRelayEndpointControlling {} 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/CmxIrohBackpressuredBroker.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift index 8a8e79439bdf..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: @@ -74,18 +75,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) @@ -107,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, @@ -167,18 +174,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 +204,10 @@ public struct CmxIrohBackpressuredRelayPolicyBroker: CmxIrohRelayPolicyServing, self.accountID = accountID } - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + /// 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.issueRelayBootstrap(endpointID: endpointID) + try await broker.fetchRelayPolicy() } } @@ -231,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/CmxIrohBrokerCredentialRepository.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift index ea30aabd86df..b42293bd15bc 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" @@ -53,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. @@ -82,123 +85,6 @@ public actor CmxIrohBrokerCredentialRepository { 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 +233,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..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 { @@ -352,9 +383,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 +395,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/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/CmxIrohClientRuntime+Lifecycle.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift index 9f3068f099e0..0ea08f87e96b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift @@ -72,8 +72,7 @@ extension CmxIrohClientRuntime { registrationRefreshEnabled = false supervisorEventTask?.cancel() supervisorEventTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil + await registryContextProvider?.cancelCacheFirstRefresh() await contextRouter.clear() authoritativeDiscovery = nil if !preserveBinding { @@ -101,18 +100,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..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, @@ -354,8 +412,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 +456,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 ef1382f57fd0..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,16 +18,20 @@ 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. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let binding = localBinding else { throw CmxIrohClientRuntimeError.inactive } @@ -41,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 9c3818e05d2b..244d1c248cbd 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 @@ -40,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 { @@ -74,6 +77,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 @@ -83,17 +94,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 +138,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,20 +155,28 @@ 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() + // 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, @@ -172,7 +187,8 @@ public actor CmxIrohClientRuntime { supervisor: supervisor, contextProvider: contextRouter, protocolConfiguration: protocolConfiguration, - diagnosticLog: diagnosticLog + diagnosticLog: diagnosticLog, + dialPhaseTimeout: configuration.dialPhaseTimeout ) self.supervisor = supervisor self.connectivityEngine = connectivityEngine @@ -180,6 +196,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 @@ -189,10 +206,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( @@ -205,12 +220,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 @@ -372,8 +381,7 @@ public actor CmxIrohClientRuntime { cachedTargetBindings: [], cachedLANRendezvous: nil ), - revision: revision, - startRelays: false + revision: revision ) try requireCurrent(revision) let published = await handleBinding(discoveredBinding, discovery) @@ -484,21 +492,30 @@ 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? + 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 @@ -511,14 +528,36 @@ 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, startRelays: true) + 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 @@ -547,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, @@ -616,8 +669,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 893a1262e811..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 @@ -44,6 +41,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()`` @@ -58,8 +61,9 @@ 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. public init( accountID: String, deviceID: String, @@ -71,8 +75,8 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { capabilities: [String], 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) @@ -84,7 +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/CmxIrohClientSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift index e812caa454e6..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, @@ -431,56 +432,81 @@ public actor CmxIrohClientSession { do { try Task.checkCancellation() - 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() + // 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). 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" + ) } - try Task.checkCancellation() - try await stream.sendStream.send( - admissionCodec.encodeFrame(.clientReady) - ) - let confirmation = try await readAdmissionFrame( - from: stream.receiveStream, - initialBuffer: admission.trailingBytes + ) { [weak self] in + guard let self else { throw CancellationError() } + return try await self.performAdmissionBarrier( + on: establishedConnection ) - switch confirmation.frame { - case .serverReady: - break - case let .denied(code): - throw CmxIrohClientSessionError.admissionDenied(code: code) - case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: - throw CmxIrohClientSessionError.invalidAdmissionFrame - } + } + } 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() - return CmxIrohConnectedControl( - connection: establishedConnection, - stream: stream, - initialReceiveBuffer: confirmation.trailingBytes - ) + 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 .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 +524,43 @@ 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 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 - 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 { + await abortOnDeadline?() throw CmxIrohClientSessionError.dialTimedOut } - return connection + return value } } 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/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/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/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/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..775f54e1e84a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift @@ -28,17 +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)? - - /// Replaces relay credentials without changing the EndpointID. + /// The returned attempt performs the handshake in + /// ``CmxIrohIncomingConnection/establish()``, so a peer that stops making + /// handshake progress never blocks the accept queue behind it. /// - /// - Parameter relays: The new complete managed relay set. - /// - Throws: A transport error when the update cannot be applied. - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws + /// - 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. /// @@ -65,11 +64,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. - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { - guard profile.source == .managed else { - throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement - } - try await replaceRelays(profile.managedRelays) + /// 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 } } 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/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..241b940fc90e --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRecordPolicy.swift @@ -0,0 +1,110 @@ +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. + /// + /// 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 { + 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 + /// 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/CmxIrohEndpointRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift index e0718237d03e..2d97e53f947a 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,27 +14,26 @@ 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. 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] - 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 +41,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 +90,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/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index e7b70b90435e..88734f48fd2f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -42,17 +42,35 @@ 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)? + /// 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 + /// 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 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 } @@ -154,13 +172,15 @@ 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() + connection.closeWatcherTask.cancel() await connection.connection.close( errorCode: 1, reason: "server_stopped" @@ -205,13 +225,25 @@ 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) + 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 { @@ -235,55 +267,35 @@ 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( - 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 - } + ) -> Bool { 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" - ) - return + return false } 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 +309,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,28 +325,129 @@ public actor CmxIrohEndpointServer { } pendingAdmissions[id] = PendingAdmission( generation: generation, - remoteIdentity: remoteIdentity, - connection: connection, + incoming: incoming, handlerTask: handlerTask, deadlineTask: deadlineTask ) + return true + } + + /// 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() + 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 { + // 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 + // 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 + && activeForIdentity > 0 + 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() + // 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 { 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 - // 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 == admission.remoteIdentity + && connection.remoteIdentity == remoteIdentity } let requiresReplacement = activeConnections.count >= maximumConnections || activeForIdentity.count >= maximumConnectionsPerIdentity @@ -343,23 +456,36 @@ 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 { + // 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 { 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: admission.remoteIdentity, - connection: admission.connection, + 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" @@ -388,6 +514,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" @@ -399,15 +526,18 @@ 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 { return } + active.closeWatcherTask.cancel() if error != nil { await active.connection.close( errorCode: 1, @@ -416,15 +546,35 @@ public actor CmxIrohEndpointServer { } } - private func timeOutAdmission(_ id: UUID) async { - guard let admission = pendingAdmissions.removeValue(forKey: id) else { + /// 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 var admission = pendingAdmissions[id] else { return } admission.handlerTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "admission_timeout" - ) + if let connection = admission.connection { + pendingAdmissions[id] = nil + await connection.close(errorCode: 1, reason: "admission_timeout") + 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( @@ -438,7 +588,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 @@ -446,6 +600,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/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/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/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 77bf8b8d3600..bd35fa2eb462 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 @@ -272,11 +268,63 @@ 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, + lanRendezvous: cached.lanRendezvous, + routePathHints: [], + registrationRetryAfterSeconds: nil + ) + } + + /// 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, confirmedBinding: CmxIrohBrokerBinding?, - relayBootstrap: CmxIrohRelayTokenResponse?, allowFallback: Bool ) throws -> ResolvedPolicy { if let confirmedBinding, let localBinding, @@ -305,7 +353,6 @@ extension CmxIrohHostRuntime { pairingEnabled: cached.pairingEnabled, grantVerificationKeys: cached.grantVerificationKeys, attestation: cached.endpointAttestation, - relayBootstrap: relayBootstrap ?? configuration.cachedRelayCredential, lanRendezvous: cached.lanRendezvous, routePathHints: [], registrationRetryAfterSeconds: ( @@ -365,15 +412,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 @@ -541,7 +579,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 +598,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,12 +615,47 @@ 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, 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. + 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 + } + } await handleBinding(registration, discovery, policy.attestation) try requireCurrent(revision) await handleRoute(policy.binding, policy.routePathHints) @@ -593,6 +673,7 @@ extension CmxIrohHostRuntime { revision: revision ) registrationRefreshFailureCount = 0 + initialPublicationPending = false completedSuccessfully = true scheduleRegistrationRenewal( binding: registration.binding, @@ -634,6 +715,31 @@ extension CmxIrohHostRuntime { } } + /// Rebinds binding-scoped components to an authenticated replacement + /// binding. `CmxIrohAdmissionController.update` already propagates the new + /// acceptor to online and offline admission. + private func adoptReplacedBinding( + policy _: ResolvedPolicy, + revision: UInt64 + ) async throws { + try requireCurrent(revision) + // 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) + } + if initialPublicationPending { + // The drained gate owned the relay-readiness wait for the deferred + // first publication. Re-arm it so the endpoint still publishes + // once the relay becomes usable. + scheduleInitialPublication(revision: 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 f0e0e25254c7..f67f68e2df33 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,16 +18,20 @@ 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. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let connectivityEngine, let binding = localBinding else { @@ -42,54 +45,35 @@ extension CmxIrohHostRuntime { throw CmxIrohHostRuntimeError.relayFleetMismatch } let revision = lifecycleRevision + let previousRelayURLs = currentEndpointRelayProfile?.allowedRelayURLs + ?? configuration.endpointRelayProfile?.allowedRelayURLs + ?? [] - 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 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 + ) + } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift index 8ec12fc57b44..0739d9954fb0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift @@ -86,8 +86,10 @@ extension CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 - relayActivationTask?.cancel() - relayActivationTask = nil + initialPublicationTask?.cancel() + initialPublicationTask = nil + initialPublicationPending = false + allowsReplacedBindingAdoption = false lanPublicationGeneration &+= 1 lanPublicationTask?.cancel() lanPublicationTask = nil @@ -98,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 c70ac5fbb0a6..adc61f4351d8 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? @@ -67,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 @@ -74,11 +72,13 @@ 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 let handleDeactivation: DeactivationHandler - let handleRelayCredential: RelayCredentialHandler let handleLANRefresh: LANRefreshHandler let handleLANPolicy: LANPolicyHandler @@ -86,13 +86,19 @@ 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. + 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? @@ -123,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(), @@ -131,17 +138,18 @@ 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 }, handleDeactivation: @escaping DeactivationHandler = { _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, handleLANRefresh: @escaping LANRefreshHandler = {}, handleLANPolicy: @escaping LANPolicyHandler = { _, _ in } ) { self.factory = factory self.broker = broker self.configuration = configuration + self.pairedPeerAllowlist = pairedPeerAllowlist self.pendingRevocations = pendingRevocations self.protocolConfiguration = protocolConfiguration self.now = now @@ -149,11 +157,11 @@ public actor CmxIrohHostRuntime { self.registrationClock = registrationClock self.registrationRetrySchedule = registrationRetrySchedule self.registrationRetryJitter = registrationRetryJitter + self.relayReadinessTimeout = relayReadinessTimeout self.handleTransport = handleTransport self.handleBinding = handleBinding self.handleRoute = handleRoute self.handleDeactivation = handleDeactivation - self.handleRelayCredential = handleRelayCredential self.handleLANRefresh = handleLANRefresh self.handleLANPolicy = handleLANPolicy managedRelayURLs = configuration.managedRelayURLs @@ -161,8 +169,23 @@ 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 { + 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 } @@ -173,6 +196,8 @@ public actor CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 + initialPublicationPending = false + allowsReplacedBindingAdoption = false currentSnapshot = CmxIrohHostRuntimeSnapshot( state: .starting, endpointID: nil, @@ -180,15 +205,38 @@ public actor CmxIrohHostRuntime { ) do { - let endpointRelayProfile = try (currentEndpointRelayProfile - ?? configuration.resolvedEndpointRelayProfile(now: now())) - .droppingExpiredManagedCredentials(at: now()) + // 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 + ) 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, @@ -208,12 +256,41 @@ 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) + 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 @@ -229,28 +306,19 @@ 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 + ) ) - 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 @@ -278,18 +346,7 @@ public actor CmxIrohHostRuntime { bindingID: policy.binding.bindingID ) var publishedPolicy = policy - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission 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 } @@ -318,9 +375,18 @@ public actor CmxIrohHostRuntime { // into `readyPolicy`; do not immediately publish a third copy. registrationRefreshPending = false } + // 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 { + let discovery = publishedPolicy.discovery, + publishInline { await handleBinding(registration, discovery, publishedPolicy.attestation) try requireCurrent(revision) if let routeRevision = discovery.revision { @@ -337,36 +403,66 @@ 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 - ) + // 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 + // generation's live direct port. Give the lifecycle-owned + // retry loop the incomplete activation. + scheduleRegistrationRetry( + 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 + .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 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 { - scheduleRelayActivation( - relayCoordinator, - binding: policy.binding, - endpointID: endpointID, - bootstrap: policy.relayBootstrap, - revision: revision - ) - } scheduleLANPublication( binding: publishedPolicy.binding, rendezvous: publishedPolicy.lanRendezvous, @@ -557,49 +653,92 @@ public actor CmxIrohHostRuntime { await handleLANPolicy(context, directAddresses) } - 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 - ) + /// 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 { + if await engine.hasUsableHomeRelay() { return true } + return !(await engine.hasConfiguredRelay()) + } + + func scheduleInitialPublication(revision: UInt64) { + initialPublicationTask?.cancel() + initialPublicationTask = Task { [weak self] in + await self?.runInitialPublication(revision: revision) } } - private func activateRelaySidecar( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) async { + private func runInitialPublication(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. + guard let connectivityEngine else { return } + 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 + // 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 + } + } } - if relayCoordinator === coordinator { - relayActivationTask = nil + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + 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 } + scheduleRegistrationRefresh(revision: revision) } func scheduleLANPublication( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift index 9b16048a5e20..bd778a02dc93 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift @@ -21,8 +21,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. @@ -40,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, @@ -55,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 @@ -70,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/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 - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift new file mode 100644 index 000000000000..366d12429106 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift @@ -0,0 +1,22 @@ +/// 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 +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift index cdc6867eb366..e749ede626d4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift @@ -140,18 +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()) - } - - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - let profile = try relayProfile.replacingManagedRelays(relays) - try await replaceRelayProfile(profile) + return CmxIrohLibIncomingConnection(incoming: incoming, alpns: alpns) } func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { @@ -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..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( @@ -47,11 +56,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)) } } @@ -59,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) } @@ -68,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(), @@ -78,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/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/CmxIrohLibIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift new file mode 100644 index 000000000000..85d937c1d5cc --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift @@ -0,0 +1,33 @@ +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. 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 + + 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/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/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..7b2971fffd72 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPairedPeerAllowlist.swift @@ -0,0 +1,271 @@ +import CryptoKit +public import CMUXMobileCore +public import Foundation + +/// 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. + /// + /// - Parameter secureStore: The secure persistence boundary. + 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. + /// + /// - 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, + 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. + /// + /// - 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, + 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. + /// + /// - 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 + ) 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. + /// + /// - Parameters: + /// - bindingID: The locally revoked broker binding. + /// - scope: The active account, namespace, and app-instance owner. + 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. + /// + /// - Throws: A secure-storage error. + 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 = pairedPeerAllowlistScopeDigest(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 = pairedPeerAllowlistScopeDigest(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 + ) + } +} + +/// 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/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/CmxIrohRegistryAddressLookup.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift new file mode 100644 index 000000000000..3c23d5fb19d4 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryAddressLookup.swift @@ -0,0 +1,378 @@ +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 { + /// 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 + } + + /// 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 + + /// 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)) + 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 + } + } + + /// 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( + 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 + + 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/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index a5fb93af343a..9a32d1f8e63e 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? @@ -49,6 +57,14 @@ 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? + /// 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( @@ -182,6 +198,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) { @@ -193,6 +227,35 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { ) usedFreshDiscovery = true } catch { + try Task.checkCancellation() + // 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, + 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, @@ -302,6 +365,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 @@ -371,6 +448,10 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { authoritativeDiscovery = nil staleDiscoveryPeers.removeAll(keepingCapacity: false) staleDiscoveryDeviceIDs.removeAll(keepingCapacity: false) + cancelCacheFirstRefresh() + establishedPeers.removeAll(keepingCapacity: false) + credentialRequiredPeers.removeAll(keepingCapacity: false) + hydratedEstablishedPeers = false } self.localBindingExpectation = localBindingExpectation self.managedRelayURLs = managedRelayURLs @@ -385,6 +466,147 @@ 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 } + let generation = cacheFirstRefreshGeneration + cacheFirstRefreshTask = Task { [weak self] in + await self?.runCacheFirstRefresh( + for: request, + 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, + generation: UInt64 + ) async { + defer { + if generation == cacheFirstRefreshGeneration { + 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 + } + 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. + let confirmed: CmxIrohCachedClientPolicy? + do { + confirmed = try await cachedPolicy( + for: request, + confirmedDiscovery: fresh, + at: now() + ) + } catch { + confirmed = nil + } + guard generation == cacheFirstRefreshGeneration else { return } + 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? { @@ -565,7 +787,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 { @@ -621,7 +843,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } return CmxIrohClientContext( dialPlan: dialPlan, - credential: try .pairGrant(pairGrantToken), + credential: try pairGrantToken.map(CmxIrohAdmissionCredential.pairGrant), privateFallbackAuthorization: fallbackAuthorization ) } @@ -647,7 +869,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) @@ -711,7 +933,7 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { )) return CmxIrohClientContext( dialPlan: dialPlan, - credential: try .pairGrant(pairGrantToken), + credential: try pairGrantToken.map(CmxIrohAdmissionCredential.pairGrant), privateFallbackAuthorization: nil ) } @@ -1011,6 +1233,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/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 a2fad66b2758..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +++ /dev/null @@ -1,632 +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 - ) - 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/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/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/CmxIrohRelayPolicyCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift index 6712e257bc9d..e27d5105ae99 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift @@ -92,21 +92,66 @@ 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, 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( 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. 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) + grace 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/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 8405a2292b6c..0a5a9a71388c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift @@ -6,75 +6,84 @@ 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 + + /// 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 private let broker: (any CmxIrohRelayPolicyServing)? + private let expiredPolicyReuseGrace: TimeInterval private var currentEffective: CmxIrohEffectiveRelayPolicy? 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( 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. + /// 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( - 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, - 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 - ) + 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 @@ -84,7 +93,6 @@ public actor CmxIrohRelayPolicyService { response: CmxIrohRelayPolicyResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -105,7 +113,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.preferenceRevision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: false, @@ -143,7 +150,6 @@ public actor CmxIrohRelayPolicyService { public func restore( accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -180,7 +186,6 @@ public actor CmxIrohRelayPolicyService { configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: nil, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, @@ -196,7 +201,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,11 +218,12 @@ public actor CmxIrohRelayPolicyService { failure: .policyUnavailable ) } + let policyIsExpired = TimeInterval(policy.expiresAt) + <= now.timeIntervalSince1970 let resolution = await Resolver.resolve( configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: true, @@ -218,7 +232,9 @@ public actor CmxIrohRelayPolicyService { return commit( Resolution( effective: resolution.effective, - failure: cleanupFailure ?? resolution.failure + failure: policyIsExpired + ? .policyExpired + : cleanupFailure ?? resolution.failure ), operation: operation ) @@ -247,7 +263,6 @@ public actor CmxIrohRelayPolicyService { _ preference: CmxIrohAccountRelayPreference, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let current: CmxIrohAccountRelayConfiguration @@ -261,7 +276,6 @@ public actor CmxIrohRelayPolicyService { current.updatingActivePreference(preference), accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now ) } @@ -274,7 +288,6 @@ public actor CmxIrohRelayPolicyService { _ configuration: CmxIrohAccountRelayConfiguration, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -299,7 +312,6 @@ public actor CmxIrohRelayPolicyService { authoritative, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -310,7 +322,6 @@ public actor CmxIrohRelayPolicyService { response, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -336,7 +347,6 @@ public actor CmxIrohRelayPolicyService { _ response: CmxIrohRelayPreferenceResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date, operation: UInt64 ) async throws -> CmxIrohEffectiveRelayPolicy { @@ -352,7 +362,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, @@ -490,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, @@ -509,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/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/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/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 772165665a8c..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -extension CmxIrohHostRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -extension CmxIrohClientRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - 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/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/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/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/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift new file mode 100644 index 000000000000..01c858d6bc0b --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient+EndpointRecords.swift @@ -0,0 +1,21 @@ +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 { + /// 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 + } + 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 970206b712f5..011d9be366f3 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 @@ -230,41 +245,17 @@ 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 PublishEndpointRecordRequest: Encodable { + let bindingId: String + let record: String } - private struct RelayTokenHeader: Decodable { - let alg: String - let typ: String + private struct PublishEndpointRecordResponse: Decodable, Sendable { + let published: Bool } - 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 @@ -289,6 +280,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( @@ -382,7 +375,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 @@ -390,6 +386,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 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 + // the interactive challenge, never by retrying the proof. + } + } let challenge: CmxIrohChallengeResponse = try await self.sendUngated( path: "api/devices/iroh/challenge", method: "POST", @@ -467,54 +481,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. @@ -539,6 +521,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", @@ -887,6 +892,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") @@ -984,130 +997,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/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/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..88091e24974f 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift @@ -1,26 +1,17 @@ import CMUXMobileCore +import CryptoKit import Foundation import Testing @testable import CmuxIrohTransport @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 +23,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 +34,72 @@ 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 + appInstanceID: binding.appInstanceID ) == nil ) - #expect(await secureStore.recordCount() == 0) } - @Test("expired or refresh-stale relay capabilities are deleted") - func loadRejectsStaleCredential() async throws { + @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() - 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 + // 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 ) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(60 * 60) - ) == nil + let replacement = try metadata( + bindingID: "123e4567-e89b-42d3-a456-426614174020", + endpointByte: "cd", + generation: 2 ) - #expect(await secureStore.recordCount() == 0) + try await repository.saveBinding(replacement, 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(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( + try await repository.loadBinding( accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == 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] + appInstanceID: replacement.appInstanceID + ) == replacement ) - 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 +107,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", @@ -446,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)) @@ -472,21 +174,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/CmxIrohClientRuntimeCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift new file mode 100644 index 000000000000..705be881b649 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeCacheFirstTests.swift @@ -0,0 +1,200 @@ +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, + await recorder.observedPolicyInvalidationCount() == 1, + await seed.store.recordCount() == 0 { + 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() + } +} 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..5f144dacdf9b 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]), @@ -461,9 +450,7 @@ private actor ClientRuntimeBlockingCloseEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + 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 47d58effaf44..50279896b755 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 ), @@ -112,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() @@ -164,22 +261,16 @@ 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 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], - relay: relay, - registrationError: .connectivity - ), + broker: broker, configuration: configuration, pendingRevocations: CmxIrohPendingRevocationOutbox( secureStore: TestSecureCredentialStore() @@ -188,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 @@ -203,7 +307,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), embedInitialDiscovery: true ) let runtime = try CmxIrohClientRuntime( @@ -245,7 +348,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [authoritativeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: staleDiscovery, embeddedRegistrationDiscoveryIsComplete: true, registrationRevision: 1 @@ -282,7 +384,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [truncatedRegistrationDiscovery, completeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: truncatedRegistrationDiscovery, connectivitySnapshotsProvenComplete: nil ) @@ -335,7 +436,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [revisionOne, revisionTwo], - relay: fixture.relayResponse(), blockedRegistrationCount: 1 ) let runtime = try CmxIrohClientRuntime( @@ -384,8 +484,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 +534,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: discoveries, - relay: fixture.relayResponse(), blockedSyncCount: 2 ) let runtime = try CmxIrohClientRuntime( @@ -474,8 +572,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 +584,7 @@ struct CmxIrohClientRuntimeTests { handleBinding: { _, _ in await recorder.recordBinding() return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } + } ) try await runtime.start() @@ -503,10 +599,17 @@ 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: 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) - await recorder.waitForRelayCount(1) - #expect(await recorder.observedRelayCount() == 1) #expect(runtime.transportFactory.supportedKinds == [.iroh]) await runtime.stop() } @@ -516,8 +619,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 +653,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -591,7 +692,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: rateLimit] ) let runtime = try CmxIrohClientRuntime( @@ -620,7 +720,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 +758,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 +799,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 +840,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 +890,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 +934,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 +967,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 +992,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 +1015,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 +1041,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -988,8 +1078,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 +1113,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: terminal] ) let offlineStore = TestSecureCredentialStore() @@ -1059,8 +1147,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 +1195,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: failure] ) let offlineStore = TestSecureCredentialStore() @@ -1144,7 +1230,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: TestIrohTransportError.unsupported ) let recorder = ClientRuntimeTestRecorder() @@ -1221,8 +1306,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 +1329,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 +1367,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 +1418,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: CmxIrohTrustBrokerClientError.connectivity ) let runtime = try CmxIrohClientRuntime( @@ -1368,13 +1449,12 @@ struct CmxIrohClientRuntimeTests { } -private actor TestRevisionedClientBroker: +actor TestRevisionedClientBroker: CmxIrohClientBrokerServing, CmxConnectivityAuthorityServing { 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 +1471,6 @@ private actor TestRevisionedClientBroker: init( binding: CmxIrohBrokerBinding, discoveries: [CmxIrohDiscoveryResponse], - relay: CmxIrohRelayTokenResponse, blockedSyncCount: Int? = nil, blockedRegistrationCount: Int? = nil, embedInitialDiscovery: Bool = false, @@ -1403,7 +1482,6 @@ private actor TestRevisionedClientBroker: ) { self.binding = binding self.discoveries = discoveries - self.relay = relay self.blockedSyncCount = blockedSyncCount self.blockedRegistrationCount = blockedRegistrationCount self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery @@ -1431,7 +1509,7 @@ private actor TestRevisionedClientBroker: ?? embeddedRegistrationDiscovery?.revision ?? discoveries.first?.revision, binding: binding, - relay: .issued(relay), + relay: .unavailable, discovery: embeddedRegistrationDiscovery, discoveryComplete: embeddedRegistrationDiscoveryIsComplete ) @@ -1472,13 +1550,6 @@ private actor TestRevisionedClientBroker: throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relay - } - func revoke(bindingID _: String) {} func revokeStale(bindingID _: String) {} @@ -1534,9 +1605,7 @@ private actor TestSubstitutedAddressEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift new file mode 100644 index 000000000000..6e0f6a51d145 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift @@ -0,0 +1,251 @@ +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() + } + + @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( + _ 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/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..bf44cdb4b0a6 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 ) @@ -442,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/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/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/CmxIrohDebugRelayOverrideTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift new file mode 100644 index 000000000000..af77f9c6d67c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift @@ -0,0 +1,155 @@ +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(debugOverride: override) + #expect(resolved == override) + + let managed = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) + #expect(managed.source == .managed) + #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( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: override) + #expect(resolved == override) + + 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/CmxIrohEndpointServerCapacityReleaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift new file mode 100644 index 000000000000..24a3cc0560a9 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift @@ -0,0 +1,450 @@ +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: [] + ) + ) + } + + /// 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( + 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 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( + 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() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift new file mode 100644 index 000000000000..fc412a49978c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift @@ -0,0 +1,269 @@ +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() + } + + /// 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 +/// 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 var abandonCount = 0 + private var abandonWaiters: [(minimum: Int, continuation: 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 CmxIrohIncomingConnection)? { + 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. + return StalledIncomingConnection(endpoint: self) + case let .connection(connection): + return CmxIrohEstablishedIncomingConnection(connection) + } + } + + func awaitStallRelease() async { + 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 } + + 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) + } + } +} + +/// 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 { + // 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/CmxIrohEndpointServerTests+Capacity.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift index 4074fe48ea4e..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) ) @@ -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() @@ -53,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() @@ -100,8 +94,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 +154,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 +237,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..42a7e2e5f4e8 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() @@ -503,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()) @@ -519,7 +512,6 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { return try Self.resolve(event) } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } @@ -553,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/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/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..e041b4baf2f6 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift @@ -46,10 +46,15 @@ extension CmxIrohHostRuntimeTests { } ) try await runtime.start() + // 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( 2, - timeout: .seconds(1) + timeout: .seconds(5) ) #expect( republished, @@ -75,14 +80,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) @@ -92,38 +99,10 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } - @Test - func validatedBindingPublishesBeforeRelayCredentialInstallationCompletes() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - 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() - 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..f6e131d1ba2c 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, @@ -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) @@ -403,7 +409,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 +453,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 +480,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,17 +510,20 @@ 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, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity, + subsequentRegistrationErrors: [ + .connectivity, .connectivity, .connectivity, + ] ) let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( @@ -527,11 +536,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.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await runtime.snapshot().state == .active) #expect(await bindings.count() == 0) await runtime.stop() } @@ -564,7 +579,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) @@ -573,7 +588,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 +602,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 +695,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 +720,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 +755,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 +781,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 +822,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..0b48a7c6302c 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, @@ -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() @@ -318,19 +317,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 +339,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.waitForCloseCallCount(1)) + #expect(await bindings.count() == 0) } @Test @@ -367,7 +368,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 +387,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 +465,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 +473,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 +489,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 endpoint.observedCloseCallCount() == 1) + #expect(await runtime.snapshot().state == .failed) + #expect(await bindings.count() == 0) + #expect(await endpoint.waitForCloseCallCount(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 +515,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 +532,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 endpoint.observedCloseCallCount() == 1) + #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.waitForCloseCallCount(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 +566,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.waitForCloseCallCount(1)) } } 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 + ) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift index d13387d7a1fc..6c3e55c29a52 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, @@ -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 new file mode 100644 index 000000000000..98ceefc1e1c4 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -0,0 +1,564 @@ +import CMUXMobileCore +import Foundation +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 + } + } + + /// 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 { + /// 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 + /// 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) + + // 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() + #expect(republished.map(\.binding.bindingID) == [fixture.binding.bindingID]) + #expect(republished.map(\.pathHints) == [[relayHint]]) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + + 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() + } + + /// 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() + } + + /// 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() + } + + /// 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 + /// 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 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)))) + 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. + @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) + } +} 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/", diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index 1a25463a0ade..2c1f52e6fc1a 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() @@ -58,7 +145,6 @@ struct CmxIrohHostRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) await runtime.stop() } @@ -381,7 +467,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 +476,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 +493,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 +508,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { self.revokeError = revokeError self.registrationHook = registrationHook self.subsequentRegistrationHook = subsequentRegistrationHook - self.relayIssueHook = relayIssueHook self.embedDiscoveryStartingAtRegistrationCount = embedDiscoveryInRegistration ? 1 @@ -503,21 +585,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 +602,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { func observedPreparedRegistrations() -> [CmxIrohPreparedRegistration] { preparedRegistrations } - func observedRelayIssueCount() -> Int { relayIssueCount } func observedDiscoveryCount() -> Int { discoveryCount } func enqueueSubsequentRegistrationError( @@ -798,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 @@ -806,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 { @@ -819,9 +894,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 { @@ -830,10 +907,9 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { Task { await self.cancelAccept(id) } } try Task.checkCancellation() - return connection + return connection.map { CmxIrohEstablishedIncomingConnection($0) } } - 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/CmxIrohPairedPeerAdmissionTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift new file mode 100644 index 000000000000..85c87e12c284 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerAdmissionTests.swift @@ -0,0 +1,276 @@ +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 + ) + guard case .accepted = warm else { + Issue.record("allowlist admission after relaunch was denied") + return + } + } + + @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)) + } +} 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 new file mode 100644 index 000000000000..b62fb114f7fb --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPairedPeerWireTests.swift @@ -0,0 +1,89 @@ +import CMUXMobileCore +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) + } + + /// 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]) + } +} 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..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, @@ -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 @@ -426,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/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/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift new file mode 100644 index 000000000000..4826d3eeb032 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderCacheFirstTests.swift @@ -0,0 +1,274 @@ +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) + } + + /// 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( + 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/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..d89b400c8c8f 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) @@ -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,13 +385,25 @@ 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: [])) } #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) } @@ -409,7 +425,6 @@ extension CmxIrohRegistryContextProviderTests { for: expectation, now: fixture.now ) - let readsBeforeDial = await store.readCount() let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], @@ -431,6 +446,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 +484,6 @@ extension CmxIrohRegistryContextProviderTests { TestRegistryBrokerFailure.tls, TestRegistryBrokerFailure.decode, ] { - let readsBeforeDial = await store.readCount() let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], @@ -482,6 +502,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/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift index 9fa448ad5975..20c6484e1815 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() @@ -410,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( @@ -549,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] = [] @@ -575,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 } 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 c258d8206c01..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ /dev/null @@ -1,671 +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 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..1c66830e6c9e 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,18 +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 broker = RefreshPolicyBroker( + response: try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 ) ) let service = CmxIrohRelayPolicyService( @@ -54,35 +51,123 @@ struct CmxIrohRelayPolicyServiceRefreshTests { broker: broker ) - let outcome = try await service.refreshWithCredential( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), + let effective = try await service.refresh( 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) + #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) + #expect(effective.endpointRelayProfile.activeRelays.count == fixture.relayURLs.count) + #expect(effective.endpointRelayProfile.activeRelays.allSatisfy { + $0.authenticationToken == nil + }) + #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 refreshDelegatesToRefreshWithCredential() async throws { + func refreshFailuresPublishGrowingStreak() 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 = 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 ) - let service = CmxIrohRelayPolicyService( + #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( @@ -90,17 +175,33 @@ struct CmxIrohRelayPolicyServiceRefreshTests { ), broker: broker ) + } +} - let effective = try await service.refresh( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) +private enum TestRelayPolicyTransportError: Error { + case offline +} - #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) - #expect(await broker.bootstrapRequestCount == 1) +/// 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/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 272faa3df11a..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 ) } @@ -288,6 +276,62 @@ 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, + now: fixture.now + ) + + let expired = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + 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, + 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, + now: fixture.now.addingTimeInterval(3_600 + 300) + ) + + #expect(rejected.source == .managedUnavailable) + #expect(rejected.endpointRelayProfile.allowedRelayURLs.isEmpty) + } + @Test func cacheRestoresUntilSignedExpiryAndSupportsStagedKeyRotation() async throws { let fixture = RelayPolicyServiceTestFixture() @@ -300,7 +344,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) _ = try await stores.service.install( @@ -311,27 +354,59 @@ 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) #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) + } + + @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, + 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, + 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) } @@ -347,7 +422,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -360,7 +434,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -498,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..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() @@ -269,6 +322,41 @@ 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 farPastExpiry = fixture.now.addingTimeInterval( + 3_600 + CmxIrohRelayPolicyCache.maximumExpiredPolicyReuseGrace + 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() @@ -298,8 +386,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 +401,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 +502,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/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/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/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift index 8cd6e833f7d2..f852c2d86f97 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift @@ -49,9 +49,87 @@ 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 combinedRegistrationUsesOneGateForBothHTTPLegs() async throws { + 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 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"}"# @@ -67,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", ]) @@ -75,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), ]) @@ -184,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"}"# @@ -203,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] ) @@ -284,8 +363,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 +380,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 +411,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/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 + } +} 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..bc89722ccd5b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift @@ -35,11 +35,11 @@ actor TestBlockingRelayUpdateEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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..1e19f62d3471 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift @@ -44,11 +44,10 @@ actor TestCancellableDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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..e4403a830dd8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift @@ -50,11 +50,10 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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..5fd4c1e2b799 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift @@ -32,11 +32,10 @@ actor TestGatedDialEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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..77cd76cb381b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift @@ -43,11 +43,10 @@ actor TestHangingDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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..407604155bd2 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 } @@ -86,27 +89,20 @@ actor TestIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { 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/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) + } + } +} 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/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/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f78fadb67384..14e85aa71134 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?() } 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/ExtensionWorktreePrototype.swift b/Sources/ExtensionWorktreePrototype.swift index 457b8d9d12a6..486e0c59a850 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. diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift index d8e1d46089a4..2cd266b553d0 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,20 +241,33 @@ extension MobileHostIrohRuntime { ), managedRelayURLs: managedRelayURLs, endpointRelayProfile: endpointRelayProfile, - cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, cachedHostPolicy: cachedHostPolicy ) let credentialRepository = brokerCredentials let hostPolicyCache = hostPolicies let lanPublisher = lanPublisher - let activeRelayPolicyService = resolvedPolicyService + // 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, pendingRevocations: pendingRevocations, + pairedPeerAllowlist: pairedPeers, protocolConfiguration: protocolConfiguration, handleTransport: { [weak self] session, isCurrent in guard let self else { @@ -458,21 +439,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+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+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+RelayDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift new file mode 100644 index 000000000000..d94023d25539 --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift @@ -0,0 +1,163 @@ +import CmuxIrohTransport +import Foundation +import os + +/// 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] + /// 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 + /// 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: RelayDiagMirror(policy: nil, refreshFailure: nil) + ) + + 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(), + refreshFailingSince: refreshFailure?.since, + consecutiveRefreshFailures: refreshFailure?.consecutiveFailures ?? 0 + ) + } + relayDiagMirror.withLock { + $0 = RelayDiagMirror(policy: state, refreshFailure: refreshFailure) + } + } + + nonisolated static func currentRelayDiagState() -> RelayDiagState? { + relayDiagMirror.withLock { $0.policy } + } + + /// 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 { + 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"] + 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 { + 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 { + 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: ", "))") + } + 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+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/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 3b1b07ce5332..d9edb471b3c3 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 @@ -108,8 +109,22 @@ final class MobileHostIrohRuntime { var transitionTask: Task? var runtime: CmxIrohHostRuntime? var relayPolicyService: CmxIrohRelayPolicyService? - var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? - var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? + var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? { + didSet { + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) + } + } + var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? { + didSet { + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) + } + } var relayPolicyEndpointID: CmxIrohPeerIdentity? var relayPolicyObservationTask: Task? var relayPolicyRefreshTask: Task? @@ -176,6 +191,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( @@ -209,6 +229,7 @@ final class MobileHostIrohRuntime { installState: installState ) hostPolicies = CmxIrohHostPolicyCache() + pairedPeers = CmxIrohPairedPeerAllowlist() pendingRevocations = CmxIrohPendingRevocationOutbox( secureStore: CmxIrohKeychainCredentialStore( service: "com.cmuxterm.iroh.pending-revocations.v1" @@ -286,6 +307,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 33f1549af7e6..d292a24da0b5 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,11 @@ class TerminalController { 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\n" + + MobileHostIrohRuntime.addressLookupDiagReportText() + "\n" } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f955d975a3fb..eed609dff070 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 */; }; @@ -1446,7 +1447,9 @@ 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 */; }; 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */; }; 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09010000000000000001 /* MobileHostIrohRuntime.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 = ""; }; @@ -4272,7 +4276,9 @@ 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 = ""; }; 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 = ""; }; @@ -5968,6 +5974,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A2DBE587F52C8A3B2A2CFCB8 /* MobileStateSync.swift */, 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 */, @@ -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 */, @@ -9942,7 +9952,9 @@ 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 */, 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */, 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */, @@ -11984,6 +11996,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="; 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/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift index d8fc565f0227..12cbf31a0fbe 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 ) } @@ -359,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)) diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 366b3fca855c..ed4ad5d89050 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -447,3 +447,99 @@ 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) + """) + } + + @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) + """) + } +} 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") + } +} 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; 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="; 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/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..571b0e92362b 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. @@ -254,6 +257,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? @@ -396,8 +408,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 +475,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 +518,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 @@ -1677,10 +1695,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 +1754,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.hasDialableRelays + } + 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 @@ -1767,35 +1819,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 +1871,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,34 +1888,58 @@ 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, - accountID: accountID, - trustRoot: relayPolicyTrustRoot, - now: now() - ) - effective = outcome.effective - freshRelayCredential = outcome.relayCredential - 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, - relayCredential: cachedRelay, 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 @@ -1907,12 +1962,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,20 +1973,42 @@ 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 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, @@ -2006,7 +2077,6 @@ public final class MobileIrohRuntimeComposition: accountID: accountID ) }, - automaticRelayCredentialRefreshEnabled: automaticRelayCredentialRefreshEnabled, handleBinding: { [weak self] binding, discovery in guard await self?.allowsPersistence( accountID: accountID, @@ -2042,21 +2112,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 +2810,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 +2974,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() @@ -2969,9 +3022,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, @@ -3038,7 +3090,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/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" + )) + } +} 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..9013f8e2719a 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 } @@ -987,8 +958,7 @@ private actor MobileIrohCooldownEndpoint: CmxIrohEndpoint { throw MobileIrohCooldownTestError.unavailable } - func accept() -> (any CmxIrohConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + func accept() -> (any CmxIrohIncomingConnection)? { nil } func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index 21faf770b166..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 }, @@ -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/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/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index c4f24eb1f3b7..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,18 +73,36 @@ 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. -func isTokenFreshEnough(_ accessToken: String?) -> Bool { +/// +/// 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 { 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 - - 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 @@ -475,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 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") diff --git a/web/.env.example b/web/.env.example index 7a73d45a897d..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= @@ -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. @@ -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/api/devices/iroh/relay-token/route.ts b/web/app/api/devices/iroh/endpoint-record/route.ts similarity index 73% rename from web/app/api/devices/iroh/relay-token/route.ts rename to web/app/api/devices/iroh/endpoint-record/route.ts index c7dd0ed2899f..c6808fde4071 100644 --- a/web/app/api/devices/iroh/relay-token/route.ts +++ b/web/app/api/devices/iroh/endpoint-record/route.ts @@ -2,5 +2,5 @@ import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; export async function POST(request: Request): Promise { - return handleIrohRoute(request, "relay_token"); + return handleIrohRoute(request, "publish_record"); } 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/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/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/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 7bc0eba4598f..8a550b3a88c6 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), @@ -86,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", ]); @@ -116,32 +103,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({ @@ -258,8 +219,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,11 +241,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,20 +253,14 @@ 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(), 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])?$/), ), @@ -326,8 +276,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(), }, @@ -405,22 +355,16 @@ 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), 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/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/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 88d3197818a3..400ab41fa8f4 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -982,6 +982,22 @@ 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 }), + // 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(), @@ -1000,6 +1016,28 @@ 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`, + ), + // 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/connectivity/routeHandler.ts b/web/services/connectivity/routeHandler.ts index ea472e1164de..a62bd68680e7 100644 --- a/web/services/connectivity/routeHandler.ts +++ b/web/services/connectivity/routeHandler.ts @@ -148,21 +148,8 @@ 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": - case "IrohRelayMintError": return connectivityJsonResponse({ error: "connectivity_service_unavailable" }, 503); } } diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 06f728d81b8b..efd0af8fd952 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -45,33 +45,25 @@ 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 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. - -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"`; 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 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/crypto.ts b/web/services/iroh/crypto.ts index 3b818ce76592..5bb651b3c56f 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; @@ -153,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; @@ -350,109 +363,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 +737,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/errors.ts b/web/services/iroh/errors.ts index 67d5214f3490..16c727160460 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -17,18 +17,12 @@ 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" | "grant_verification" | "account_subject" - | "lan_discovery" - | "relay_minter"; + | "lan_discovery"; }> {} export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ @@ -36,20 +30,13 @@ 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 | IrohForbiddenError | IrohConflictError - | IrohQuotaExceededError | IrohConfigurationError - | IrohDatabaseError - | IrohRelayMintError; + | IrohDatabaseError; export function irohExpectedError(error: unknown): IrohExpectedError | null { if (!error || typeof error !== "object") return null; @@ -85,8 +72,6 @@ const IROH_ERROR_TAGS = new Set([ "IrohNotFoundError", "IrohForbiddenError", "IrohConflictError", - "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/model.ts b/web/services/iroh/model.ts index 7e474031775e..16255a016f14 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -16,10 +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_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; @@ -65,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 = { @@ -97,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"), @@ -245,6 +271,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; @@ -621,6 +692,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/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts index e43abc27f3e4..3b61ac4dd671 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. * @@ -57,6 +49,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/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/repository.ts b/web/services/iroh/repository.ts index 4ad49a8ba9c6..636277214ed3 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: { @@ -104,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; @@ -152,6 +166,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; @@ -181,30 +202,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")< @@ -221,35 +218,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({ @@ -286,11 +260,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) @@ -304,368 +275,185 @@ 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)) + 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 ? [] @@ -810,13 +598,41 @@ function makeLiveRepository(): IrohRepositoryShape { }, ), - revokeBinding: (input) => repositoryEffect("revoke_binding", 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 [binding] = await tx - .select() - .from(irohEndpointBindings) + // 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); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [binding] = await tx + .select() + .from(irohEndpointBindings) .where(and( eq(irohEndpointBindings.id, input.bindingId), eq(irohEndpointBindings.userId, input.userId), @@ -1145,134 +961,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"), - )); - }); - }), }; } @@ -1298,6 +986,12 @@ async function revokeActiveBindings( directPortV6: null, pathHints: [], 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( @@ -1424,6 +1118,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 +1129,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 @@ -1735,11 +1432,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 { @@ -1819,3 +1515,325 @@ function bindingMatchesGrantPeer(binding: IrohBindingRecord, peer: PairGrantPeer 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" }); + + // 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 }; +} + +async function acquireRegistrationSlotLocks( + tx: CloudDbTransaction, + userId: string, + payload: IrohRegistrationPayload, +): Promise { + 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))`); +} + +/** + * 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 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/routeHandler.ts b/web/services/iroh/routeHandler.ts index be8a16491398..fd57aca4e363 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -24,7 +24,7 @@ export type IrohRouteOperation = | "endpoint_attestation" | "revoke" | "pair_grant" - | "relay_token"; + | "publish_record"; type RouteDependencies = { readonly verify?: typeof verifyRequest; @@ -221,8 +221,8 @@ 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); + case "publish_record": + return broker.publishEndpointRecord(userId, body, undefined, clientNamespace, bindingProof); } } @@ -336,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 { @@ -353,17 +355,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) }, - ); - } - 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 13cdc74166fc..b78d641b74f7 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,22 +38,21 @@ 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, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_SCOPE, - IROH_RELAY_TOKEN_LIFETIME_SECONDS, - IROH_RELAY_TOKEN_REFRESH_SECONDS, assertChallengeMatchesPayload, decodeRegistrationPayload, parseBindingIdBody, + parsePublishEndpointRecordBody, parseRevokeBindingBody, parseChallengeRequest, parseIrohPathHint, parsePairGrantRequest, parseRegisterRequest, - sha256, type IrohPathHint, } from "./model"; import { canIOSBindingUseMac } from "./buildCompatibility"; @@ -60,6 +60,7 @@ import { IrohRepository, IrohRepositoryLive, type IrohBindingRecord, + type IrohRegistrationCommit, type IrohRepositoryShape, } from "./repository"; import { @@ -67,11 +68,6 @@ import { legacyIrohDiscoveryRequest, parseIrohDiscoveryRequest, } from "./discoveryPagination"; -import { - IrohRelayMinter, - IrohRelayMinterLive, - type IrohRelayMinterShape, -} from "./relayMinter"; import { defaultRelayPreference, type RelayPreference, @@ -84,6 +80,7 @@ import { import { MANAGED_RELAY_URLS, accountPrivateIrohPathHints, + isPublishableAttachedRelayURL, } from "./publicationPolicy"; import { discoveryScopeMatchesRegistration, @@ -131,21 +128,21 @@ export type IrohTrustBrokerShape = { clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; - readonly issuePairGrant: ( + readonly publishEndpointRecord: ( userId: string, raw: unknown, now?: Date, clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; - readonly issueEndpointAttestation: ( + readonly issuePairGrant: ( userId: string, raw: unknown, now?: Date, clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; - readonly issueRelayToken: ( + readonly issueEndpointAttestation: ( userId: string, raw: unknown, now?: Date, @@ -161,7 +158,6 @@ export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< export function makeIrohTrustBroker( repository: IrohRepositoryShape, - relayMinter: IrohRelayMinterShape, config: IrohTrustBrokerConfigShape, relayPreferences: Pick = { getPreference: () => Effect.succeed({ @@ -210,73 +206,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 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(), @@ -451,17 +380,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( @@ -473,41 +391,91 @@ 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, - }); + }); + } - // 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: 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 - ? 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( @@ -582,6 +550,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, @@ -722,8 +730,6 @@ export function makeIrohTrustBroker( grant_verification_keys: verificationKeys.keySet, }; }), - - issueRelayToken, }; } @@ -732,23 +738,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, )), ); @@ -788,15 +788,90 @@ 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(), + // 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 }), + }; +} + +/** + * 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) || + !attachmentCorroborated(binding, now) + ) { + 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; + +/** + * 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 { @@ -834,4 +909,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/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..d166a993f4de --- /dev/null +++ b/web/services/relay/report.ts @@ -0,0 +1,338 @@ +// 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. +// 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). 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. +// +// 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; + +/** + * 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 = + /^[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 || + ts < now.getTime() - RELAY_REPORT_MAX_PAST_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 (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 + 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. + * + * 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, +): 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; + + 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()], + ); + // 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/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 12e243610d42..341779e81fe9 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -21,13 +21,9 @@ 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 = { - 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-----`, @@ -174,25 +170,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 +200,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 +230,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 +246,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-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 3dc55195279b..75a999a4beb5 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", }); }); @@ -421,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"; @@ -698,6 +792,120 @@ 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. + // (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({ + 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"; @@ -1878,112 +2086,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-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index fc1b70d9114b..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, @@ -35,13 +33,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"); @@ -482,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, @@ -637,188 +523,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-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 4bb17b0a48ca..bf316b9ec0fc 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,13 @@ describe("Iroh route boundary", () => { issueEndpointAttestation: namespaced, revoke: namespaced, issuePairGrant: namespaced, - issueRelayToken: namespaced, + publishEndpointRecord: namespaced, }); const operations = [ "endpoint_attestation", "revoke", "pair_grant", - "relay_token", + "publish_record", ] as const; for (const operation of operations) { const base = authedPost("/api/devices/iroh", {}); @@ -351,7 +351,9 @@ 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(4).fill("dev.cmux.app.demo")); expect(receivedBindingIDs).toEqual( @@ -359,26 +361,6 @@ describe("Iroh route boundary", () => { ); }); - 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 +417,7 @@ function broker(overrides: Partial = {}): IrohTrustBrokerS issueEndpointAttestation: unavailable, revoke: unavailable, issuePairGrant: unavailable, - issueRelayToken: unavailable, + publishEndpointRecord: unavailable, ...overrides, }; } diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 8938329fbcb2..7b5f0670e5f5 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,9 +31,8 @@ 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 { IrohDiscoveryScope } from "../services/iroh/discoveryScope"; import type { RelayPreference } from "../services/relay/model"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -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 () => { @@ -515,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( @@ -1337,6 +1443,87 @@ 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("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({ + 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 }); @@ -1704,18 +1891,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, @@ -1734,7 +1909,6 @@ describe("Iroh discovery and grants", () => { ), "IrohNotFoundError"); expect(internal.revokedAt).toBeNull(); - expect(fixture.minter.calls).toBe(0); expect(fixture.repository.pairGrantAudits).toHaveLength(0); }); @@ -1835,13 +2009,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, }); @@ -1851,54 +2025,140 @@ describe("Iroh discovery and grants", () => { }); }); -describe("Iroh relay quotas", () => { - test("never calls the minter for an unregistered or revoked binding", async () => { +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.issueRelayToken(USER_A, { bindingId: randomUUID() }, NOW), - "IrohNotFoundError", + 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 revoked = binding({ userId: USER_A, revokedAt: NOW }); - fixture.repository.bindings.push(revoked); + + 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.issueRelayToken(USER_A, { bindingId: revoked.id }, NOW), + fixture.broker.publishEndpointRecord( + USER_A, + body, + NOW, + "legacy", + fixture.bindingProof(randomUUID(), "POST", RECORD_PATH, body), + ), "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); + 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", + ); + } + }); - await Effect.runPromise(fixture.broker.issueRelayToken( + 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, - { bindingId: active.id }, + body, NOW, + "legacy", + fixture.bindingProof(bindingId, "POST", RECORD_PATH, body), )); - 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 Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId }, + new Date(NOW.getTime() + 1_000), + )); - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, NOW), - "IrohNotFoundError", - ); - expect(fixture.repository.relayIssuances[0]?.status).toBe("failed"); + const row = fixture.repository.bindings.find((binding) => binding.id === bindingId); + expect(row?.revokedAt).not.toBeNull(); + expect(row?.endpointRecord).toBeNull(); }); }); @@ -1908,17 +2168,43 @@ 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[] = []; 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; @@ -2075,6 +2361,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?.(); @@ -2172,6 +2503,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); @@ -2233,6 +2579,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, @@ -2324,63 +2672,10 @@ 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; - } -} - -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; @@ -2399,7 +2694,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; @@ -2424,22 +2718,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, @@ -2523,6 +2813,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 }), + }; + }, }; } @@ -2556,6 +2900,10 @@ function binding(overrides: Partial = {}): MutableBinding { directPortV6: null, pathHints: [], pathHintsNextExpiry: null, + relayAttachedUrl: null, + relayAttachReportedAt: null, + endpointRecord: null, + endpointRecordUpdatedAt: 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..8bb67505b356 --- /dev/null +++ b/web/tests/relay-report-db-behavior.test.ts @@ -0,0 +1,335 @@ +// 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 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("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); + 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, 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([]); + }); +}); + +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"), + }], + }), + }; +} diff --git a/web/tests/relay-report-route.test.ts b/web/tests/relay-report-route.test.ts new file mode 100644 index 000000000000..3b011107cbbc --- /dev/null +++ b/web/tests/relay-report-route.test.ts @@ -0,0 +1,400 @@ +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_MAX_PAST_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 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", { + 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); + }); +}); 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); - }); -});