Isolate every iOS build by bundle identity (#9183)

* test: require iOS build isolation

* fix: isolate iOS builds by bundle identity

* fix: compile namespaced Mac backup publisher

* chore: remove shared app group example

* test: require isolated iOS OAuth cookies

* fix: isolate iOS OAuth browser cookies

* fix: close autoreview namespace gaps

* fix: complete iOS namespace isolation

* refactor: satisfy namespace isolation policy

* fix: close namespace migration review gaps

* fix: preserve isolated Iroh and OAuth admission

* test: cover isolated iOS rollout paths

* fix: authenticate isolated iOS rollout paths

* test: cover authenticated management recovery

* fix: authorize isolated management operations

* test: cover cached proof and Mac forget

* fix: preserve proof across isolated lifecycle paths

* fix: satisfy Iroh API package policy

* test: cover autoreview isolation regressions

* fix: close isolated rollout review gaps

* test: cover target and trust boundary regressions

* fix: enforce isolated pairing boundaries

* test: cover legacy tombstone migration

* fix: preserve tombstones across backup migration

* test: cover migration precedence and discovery privacy

* fix: enforce migration and discovery precedence

* test: cover push targeting and migration bounds

* fix: close push and migration isolation gaps

* test: prevent entitlement dumps on signing failures

* fix: redact signed entitlements on upload failure

* fix: make backup scope provider sendable

* fix: return localized pairing target names

* fix: drain legacy namespaced revocations

* test: track push token policy limits

* test: cover post-revocation fallback paths

* fix: refresh authority after binding revocations

* test: cover paired-Mac migration boundaries

* fix: bound and scope legacy paired-Mac migration

* fix: capture migration account without async coalescing

* fix: keep legacy migration and keychain deletion safe

* chore: refresh pull request head

* fix: bound legacy backup reconciliation

* test: cover conditional paired Mac migration

* fix: make paired Mac migration conditional

* test: cover pairing migration recovery races

* fix: serialize identity persistence recovery

* test: cover migration team and pairing lanes

* fix: pin migration team and pairing channel

* test: await development identity storage

* test: cover pairing emission and keychain scope

* fix: fail closed on app and keychain scope

* test: cover bundle-derived Mac namespaces

* fix: namespace Mac bindings by bundle

* test: cover legacy sign-out binding namespaces

* fix: revoke bindings in their stored namespace

* test: expect APNs bundle in send outcome

* test: cover untagged debug pairing identity

* fix: preserve untagged debug pairing identity

* test: cover rate-limited pending revocations

* fix: drain revocations after rate-limited registration

* test: cover safe pending revocation reconciliation

* fix: preserve active binding during revocation recovery

* fix: preserve broker proof through client wrappers

* test: retain authorization during rate-limited recovery

* fix: preserve retained binding during rate-limit recovery

* test: cover stale binding cleanup authorization

* fix: authorize stale binding cleanup

* docs: document binding authorization helpers

* test: type stale cleanup response

* fix(macos): restore compilable cleanupSurfaceState call

https://github.com/manaflow-ai/cmux/pull/10072 changed this call site to
pass workspaceID: but never landed that overload, so the macOS target
has not compiled since it merged (CI is dispatch-only and did not catch
it). Restore the existing signature; the native-mobile-surface
preservation intent needs to re-land together with its implementation.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix: wire mobile surface artifact integration

* fix: require explicit trust broker namespace

* fix: require explicit management revocation routes

* fix: reject ambiguous iOS bundle namespaces

* fix: bound identity waits and scope legacy auth

* fix: cancel queued identity operations

* fix: modernize browser change handlers

* fix: scope legacy token deletion

---------

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Abdulaziz Albahar
2026-08-18 13:37:30 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 029d652972
commit 7589f52b81
174 changed files with 7666 additions and 745 deletions
@@ -12,9 +12,16 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
/// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the
/// ticket has no Tailscale route to disclose.
public func encode(_ ticket: CmxAttachTicket) throws -> URL? {
public func encode(
_ ticket: CmxAttachTicket,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) throws -> URL? {
let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale }
guard !tailscaleRoutes.isEmpty else { return nil }
guard !tailscaleRoutes.isEmpty,
let scheme = pairingURLScheme?.rawValue else {
return nil
}
let legacyTicket = try CmxAttachTicket(
version: ticket.version,
@@ -35,7 +42,7 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
encoder.dateEncodingStrategy = .iso8601
let payload = base64URLEncode(try encoder.encode(legacyTicket))
return URL(
string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)"
string: "\(scheme)://attach?v=\(legacyTicket.version)&payload=\(payload)"
)
}
@@ -77,8 +77,13 @@ public struct CmxPairingQRCode: Sendable {
/// route is dropped, never written into a scannable code.
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
routeDisclosureMode: CmxPairingRouteDisclosureMode,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) -> String? {
guard let scheme = pairingURLScheme?.rawValue else {
return nil
}
let items: [String]
switch routeDisclosureMode {
case .irohIdentityOnly:
@@ -119,7 +124,7 @@ public struct CmxPairingQRCode: Sendable {
// Mac's QR opens the dev iOS build, a release Mac's QR opens the
// release build, and the system camera can no longer hand a beta/prod
// code to a dev build that also claimed the scheme.
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
return "\(scheme)://attach?" + items.joined(separator: "&")
}
/// Whether `ticket` is expressible in the selected minimal grammar.
@@ -224,7 +229,7 @@ public struct CmxPairingQRCode: Sendable {
/// the minimal grammar).
public func isPairingCodeURLString(_ rawValue: String) -> Bool {
guard let url = URL(string: rawValue),
CmxPairingURLScheme.isPairingScheme(url.scheme),
CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "attach",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
@@ -1,75 +1,95 @@
import Foundation
/// The channel-specific URL scheme carried by cmux pairing/attach deep links.
/// One validated URL scheme carried by a cmux pairing or attach deep link.
///
/// All builds used to register and emit one scheme (`cmux-ios`), so scanning a
/// beta/prod pairing QR with the iOS Camera app could open a *dev* build that
/// happened to be installed (the OS picks an arbitrary app when several claim
/// a scheme). The scheme is therefore channel-specific, mirroring how
/// `MobileBuildType` splits channels:
///
/// - **Development (DEBUG)** builds local Xcode and `reload.sh` tagged
/// builds on both Mac and iPhone register and emit ``development``.
/// - **Release** builds (TestFlight beta and App Store prod) register and
/// emit ``release``. Beta and prod share a scheme because they are the same
/// compile configuration and a phone realistically has only one of them.
///
/// Emitters (the Mac building a pairing QR or attach URL) use ``current`` so a
/// dev Mac pairs a dev phone and a release Mac pairs a release phone via the
/// system camera. Parsers (the in-app scanner, manual paste, the root scene's
/// deep-link gate) accept *any* pairing scheme via ``isPairingScheme(_:)`` /
/// ``hasPairingScheme(_:)``, so cross-channel pairing still works when the
/// user scans from inside the app.
///
/// The iOS app's registered scheme comes from `CMUX_IOS_URL_SCHEME` in
/// `ios/Config/Shared.xcconfig` (dev) and `ios/Config/Release.xcconfig`
/// (release); keep those values in sync with these constants.
///
/// lint:allow namespace-type the build channel's URL scheme is a pure
/// compile-time constant set with no per-instance state to inject; these
/// scheme strings and the stateless pairing-scheme predicates are a genuine
/// namespace, like the sanctioned FFI/seam holders.
/// Every installed iOS bundle registers exactly one scheme derived from its
/// complete bundle identifier. Parsers also accept the two historical shared
/// schemes so an old QR remains scannable inside an already-open app, but new
/// apps never register those shared schemes with iOS.
public struct CmxPairingURLScheme {
private init() {}
/// The validated, lowercase URL scheme.
public let rawValue: String
/// The scheme Release (TestFlight beta + App Store) builds register and emit.
/// Creates the exact scheme registered by one installed iOS bundle.
public init?(iOSBundleIdentifier: String?) {
guard let namespace = MobileIOSAppNamespace(
bundleIdentifier: iOSBundleIdentifier
) else {
return nil
}
let scheme = namespace.pairingURLScheme.lowercased()
guard Self.releaseSchemes.contains(scheme)
|| scheme == Self.untaggedDevelopmentScheme
|| scheme.hasPrefix(Self.developmentPrefix) else {
return nil
}
rawValue = scheme
}
/// Parses a classifiable bundle-specific or historical shared pairing
/// scheme. Unknown release-like namespaces fail closed so account preflight
/// cannot be bypassed by a syntactically valid but unclassified scheme.
public init?(rawValue: String?) {
guard let rawValue else { return nil }
let normalized = rawValue.lowercased()
if Self.all.contains(normalized) {
self.rawValue = normalized
return
}
let prefix = "cmux-ios-"
guard normalized.hasPrefix(prefix),
MobileIOSAppNamespace(
bundleIdentifier: String(normalized.dropFirst(prefix.count))
) != nil,
Self.releaseSchemes.contains(normalized)
|| normalized == Self.untaggedDevelopmentScheme
|| normalized.hasPrefix(Self.developmentPrefix) else {
return nil
}
self.rawValue = normalized
}
/// Parses the scheme from a complete pairing URL.
public init?(urlString: String) {
guard urlString.contains("://"),
let components = URLComponents(string: urlString),
let scheme = CmxPairingURLScheme(rawValue: components.scheme) else {
return nil
}
self = scheme
}
/// Whether this scheme identifies a tagged iOS development build.
public var isDevelopment: Bool {
rawValue == Self.development
|| rawValue == Self.untaggedDevelopmentScheme
|| rawValue.hasPrefix(Self.developmentPrefix)
}
/// Whether this scheme identifies an App Store or TestFlight build.
public var isRelease: Bool {
Self.releaseSchemes.contains(rawValue)
}
/// Historical shared Release scheme. Parse-only in new iOS builds.
public static let release = "cmux-ios"
/// The scheme development (DEBUG/tagged) builds register and emit.
/// Historical shared development scheme. Parse-only in new iOS builds.
public static let development = "cmux-ios-dev"
/// Every scheme any cmux build may emit; parsers accept all of them.
/// Historical schemes retained for source compatibility and old QR tests.
public static let all: [String] = [release, development]
/// The scheme this build emits in pairing QRs and attach URLs.
public static var current: String {
scheme(isDevelopmentBuild: isDevelopmentBuild)
}
private static let untaggedDevelopmentScheme = "cmux-ios-dev.cmux.ios"
private static let developmentPrefix = "cmux-ios-dev.cmux.ios."
/// Pure channel-to-scheme mapping, injected with the compile flag so the
/// derivation is testable from a single build configuration.
public static func scheme(isDevelopmentBuild: Bool) -> String {
isDevelopmentBuild ? development : release
}
/// Whether `scheme` is a pairing scheme from any cmux channel.
public static func isPairingScheme(_ scheme: String?) -> Bool {
guard let scheme else { return false }
return all.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame }
}
/// Whether `rawValue` starts with any channel's pairing scheme (the
/// scanner/paste-side prefix check, before URL parsing).
public static func hasPairingScheme(_ rawValue: String) -> Bool {
let lowercased = rawValue.lowercased()
return all.contains { lowercased.hasPrefix($0 + "://") }
}
private static var isDevelopmentBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
private static let releaseSchemes: Set<String> = [
release,
"cmux-ios-com.cmux.app",
"cmux-ios-dev.cmux.app.beta",
"cmux-ios-dev.cmux.app.internal",
"cmux-ios-dev.cmux.app.demo",
]
}
extension CmxPairingURLScheme: Equatable, Sendable {}
@@ -0,0 +1,71 @@
import Foundation
/// Resolves the pairing target for the current process without global state.
public struct CmxPairingURLSchemeResolver: Sendable {
private let currentIOSBundleIdentifier: String?
private let targetIOSBundleIdentifier: String?
private let macInstanceTag: String?
private let isDevelopmentBuild: Bool
/// Captures the current app identity and any explicit Mac pairing target.
///
/// A Mac may set `CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER` to any authoritative
/// release-lane bundle id. Tagged Mac builds otherwise target their exact
/// same-tag iOS bundle.
public init(
bundle: Bundle = .main,
environment: [String: String] = ProcessInfo.processInfo.environment
) {
currentIOSBundleIdentifier = bundle.bundleIdentifier
targetIOSBundleIdentifier =
environment["CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER"]
macInstanceTag = environment["CMUX_TAG"]
#if DEBUG
isDevelopmentBuild = true
#else
isDevelopmentBuild = false
#endif
}
init(
currentIOSBundleIdentifier: String?,
targetIOSBundleIdentifier: String?,
macInstanceTag: String?,
isDevelopmentBuild: Bool
) {
self.currentIOSBundleIdentifier = currentIOSBundleIdentifier
self.targetIOSBundleIdentifier = targetIOSBundleIdentifier
self.macInstanceTag = macInstanceTag
self.isDevelopmentBuild = isDevelopmentBuild
}
/// The exact scheme this process should emit, or `nil` on invalid identity.
public var resolved: CmxPairingURLScheme? {
#if os(iOS)
return CmxPairingURLScheme(
iOSBundleIdentifier: currentIOSBundleIdentifier
)
#else
if let targetIOSBundleIdentifier {
return CmxPairingURLScheme(
iOSBundleIdentifier: targetIOSBundleIdentifier
)
}
if macInstanceTag == nil || macInstanceTag?.isEmpty == true {
return CmxPairingURLScheme(
iOSBundleIdentifier: isDevelopmentBuild
? "dev.cmux.ios"
: "com.cmux.app"
)
}
guard let namespace = MobileIOSAppNamespace(
pairedMacInstanceTag: macInstanceTag
) else {
return nil
}
return CmxPairingURLScheme(
iOSBundleIdentifier: namespace.bundleIdentifier
)
#endif
}
}
@@ -0,0 +1,93 @@
import Foundation
/// Immutable isolation boundary for one installed cmux iOS application.
///
/// The complete bundle identifier is the namespace. Distribution labels and
/// short development tags are deliberately not accepted here because either
/// can alias another installed app.
public struct MobileIOSAppNamespace: Equatable, Hashable, Sendable {
/// Exact bundle identifier that owns this namespace.
public let bundleIdentifier: String
/// Creates a namespace from one complete, validated iOS bundle identifier.
public init?(bundleIdentifier: String?) {
guard let bundleIdentifier else { return nil }
let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed == bundleIdentifier,
trimmed == trimmed.lowercased(),
trimmed.count <= 255,
trimmed.contains("."),
trimmed.range(
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
options: .regularExpression
) != nil
else {
return nil
}
self.bundleIdentifier = trimmed
}
/// Resolves the exact iOS bundle paired with one Mac app instance.
///
/// Tagged Mac builds pair with the same tagged iOS development bundle.
/// The stable Mac instance pairs with the public App Store bundle. Invalid
/// tags fail closed instead of aliasing another installed iOS app.
public init?(pairedMacInstanceTag instanceTag: String?) {
let tag = instanceTag?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if let instanceTag, instanceTag != tag {
return nil
}
let bundleIdentifier = if tag.isEmpty || tag == "default" {
"com.cmux.app"
} else {
"dev.cmux.ios.\(tag)"
}
self.init(bundleIdentifier: bundleIdentifier)
}
/// The exact Keychain access group this app must claim after signing.
public func keychainAccessGroup(teamIdentifier: String) -> String {
"\(teamIdentifier).\(bundleIdentifier)"
}
/// A Keychain service that cannot collide with another installed bundle.
public func keychainService(base: String) -> String {
"\(base).\(bundleIdentifier)"
}
/// The only pairing URL scheme this bundle registers with iOS.
public var pairingURLScheme: String {
"cmux-ios-\(bundleIdentifier)"
}
/// Opaque server partition for data restored to this exact app bundle.
public var serverScope: String {
let encoded = Data(bundleIdentifier.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "ios:v3:\(encoded)"
}
/// The only legacy backup collection that can be attributed to this bundle.
///
/// The App Store app owns the former unscoped release collection. Tagged
/// development bundles own their same-tag v2 collection. Beta, Internal,
/// and Demo intentionally adopt nothing because their old unscoped records
/// cannot be attributed without risking cross-build restore.
public var legacyBackupScope: MobileIOSLegacyBackupScope? {
if bundleIdentifier == "com.cmux.app" {
return .unscoped
}
let prefix = "dev.cmux.ios."
guard bundleIdentifier.hasPrefix(prefix),
let buildScope = MobileIOSBuildScope(
String(bundleIdentifier.dropFirst(prefix.count))
) else {
return nil
}
return .scoped(buildScope.serializedScope)
}
}
@@ -0,0 +1,16 @@
/// One unambiguous pre-v3 backup collection eligible for one-time adoption.
public enum MobileIOSLegacyBackupScope: Equatable, Sendable {
/// The former App Store collection that did not carry a scope header.
case unscoped
/// A former development collection identified by its exact v2 scope.
case scoped(String)
/// The legacy request header value, or `nil` for the unscoped collection.
public var headerValue: String? {
switch self {
case .unscoped: nil
case .scoped(let value): value
}
}
}
@@ -116,14 +116,15 @@ public struct MobileSyncPairingPayload: Equatable, Sendable, Codable {
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(self)
let payload = Self.base64URLEncode(data)
guard let url = URL(string: "\(CmxPairingURLScheme.current)://pair?v=\(version)&payload=\(payload)") else {
guard let scheme = CmxPairingURLSchemeResolver().resolved?.rawValue,
let url = URL(string: "\(scheme)://pair?v=\(version)&payload=\(payload)") else {
throw MobileSyncPairingPayloadError.invalidURL
}
return url
}
public static func decodeURL(_ url: URL, now: Date = Date()) throws -> MobileSyncPairingPayload {
guard CmxPairingURLScheme.isPairingScheme(url.scheme),
guard CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "pair",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let encodedPayload = components.queryItems?.first(where: { $0.name == "payload" })?.value,
@@ -4,6 +4,9 @@ import Testing
private let compactIrohQRCoder = CmxAttachTicketCompactCoder()
private let compactIrohQREndpointID = String(repeating: "c", count: 64)
private let compactIrohQRTarget = CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.beta"
)!
private func compactIrohQRExpiry() -> Date {
Date(timeIntervalSince1970: 4_000_000_000)
@@ -88,11 +91,12 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
#expect(hints.isEmpty)
let pairingURL = try #require(CmxPairingQRCode().encode(
ticket,
routeDisclosureMode: .irohIdentityOnly
routeDisclosureMode: .irohIdentityOnly,
pairingURLScheme: compactIrohQRTarget
))
#expect(
pairingURL
== "\(CmxPairingURLScheme.current)://attach?v=3&i=\(compactIrohQREndpointID)"
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
)
#expect(!pairingURL.contains("payload="))
#expect(!pairingURL.contains("mac-1"))
@@ -130,7 +134,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let beforeURL =
"\(CmxPairingURLScheme.current)://attach?v=1&payload=\(compactBase64)"
"\(compactIrohQRTarget.rawValue)://attach?v=1&payload=\(compactBase64)"
let beforeImage = try #require(CmxPairingQRBitmap().makeImage(payload: beforeURL))
let afterImage = try #require(CmxPairingQRBitmap().makeImage(payload: pairingURL))
let quietZone = CmxPairingQRBitmap.quietZoneModules * 2
@@ -56,10 +56,12 @@ import Testing
try tailscaleRoute(index: 0, host: "100.64.0.5"),
])
let url = try #require(encodeLegacy(ticket))
// The scheme is channel-specific: a release Mac emits cmux-ios, a dev
// Mac emits cmux-ios-dev, so the system camera routes each channel's QR
// to its build. The rest of the URL is identical across channels.
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
// The scheme is bundle-specific, so the system camera routes the QR to
// the matching installed iOS build. The rest of the URL is unchanged.
let scheme = try #require(
CmxPairingURLSchemeResolver().resolved?.rawValue
)
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == ticket.routes)
@@ -148,7 +150,10 @@ import Testing
let ticket = try pairingTicket(routes: [loopback, tailscale])
let url = try #require(encodeLegacy(ticket))
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
let scheme = try #require(
CmxPairingURLSchemeResolver().resolved?.rawValue
)
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == [tailscale])
}
@@ -2,52 +2,153 @@ import Foundation
import Testing
@testable import CMUXMobileCore
/// The pairing/attach URL scheme is channel-specific so the system Camera app
/// can never hand a beta/prod QR to a dev build that also claimed the scheme:
/// dev (Debug/tagged) builds register + emit `cmux-ios-dev`, Release (beta +
/// prod) registers + emits `cmux-ios`. Parsers accept every channel's scheme so
/// cross-channel pairing still works from inside the app.
/// Every installed iOS bundle owns one pairing URL scheme. Parsers accept only
/// schemes whose release or development lane can be classified for account
/// preflight, while installed builds still register their exact bundle scheme.
@Suite struct CmxPairingURLSchemeTests {
@Test func developmentBuildsEmitDevScheme() {
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: true) == "cmux-ios-dev")
@Test func everyInstalledBundleEmitsItsOwnScheme() {
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.internal"
)?.rawValue == "cmux-ios-dev.cmux.app.internal"
)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.demo"
)?.rawValue == "cmux-ios-dev.cmux.app.demo"
)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
)?.rawValue == "cmux-ios-dev.cmux.ios.feature-a"
)
}
@Test func releaseBuildsEmitReleaseScheme() {
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: false) == "cmux-ios")
}
@Test func currentMatchesThisBuildsCompileChannel() {
// `current` derives from the DEBUG compile flag, so a Debug test run
// emits the dev scheme and a Release test run emits the release scheme.
#if DEBUG
#expect(CmxPairingURLScheme.current == "cmux-ios-dev")
#else
#expect(CmxPairingURLScheme.current == "cmux-ios")
@Test func invalidIdentityDoesNotFallBackToAnotherApp() {
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "") == nil)
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "invalid bundle") == nil)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.unrecognized"
) == nil
)
#if !os(iOS)
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: "invalid tag",
isDevelopmentBuild: true
).resolved == nil
)
#endif
}
@Test func parserAcceptsEverySchemeRegardlessOfChannel() {
// Both channels' schemes parse, case-insensitively, so a phone on
// either channel can pair from a QR minted by either channel's Mac.
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios"))
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios-dev"))
#expect(CmxPairingURLScheme.isPairingScheme("CMUX-IOS-DEV"))
#if !os(iOS)
@Test func untaggedDebugMacTargetsDefaultDebugIOSBundle() {
#if DEBUG
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: true
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
)
#endif
}
@Test func untaggedMacBuildChannelsResolveDistinctExactBundles() {
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: true
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
)
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: false
).resolved?.rawValue == "cmux-ios-com.cmux.app"
)
}
@Test func macCanExplicitlyTargetEveryReleaseLane() {
for bundleIdentifier in [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: bundleIdentifier,
macInstanceTag: nil,
isDevelopmentBuild: false
).resolved?.rawValue
== "cmux-ios-\(bundleIdentifier)"
)
}
}
#endif
@Test func parserAcceptsNamespacedSchemes() {
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.internal") != nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.demo") != nil)
#expect(CmxPairingURLScheme(rawValue: "CMUX-IOS-DEV.CMUX.IOS.FEATURE-A") != nil)
// Old QR codes remain scannable inside an already-open app. New builds
// do not register these shared schemes with iOS.
#expect(CmxPairingURLScheme(rawValue: "cmux-ios") != nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev") != nil)
}
@Test func parserRejectsForeignSchemes() {
#expect(!CmxPairingURLScheme.isPairingScheme(nil))
#expect(!CmxPairingURLScheme.isPairingScheme(""))
#expect(!CmxPairingURLScheme.isPairingScheme("https"))
// A different cmux scheme that is not a pairing scheme must not match.
#expect(!CmxPairingURLScheme.isPairingScheme("cmux-ios-staging"))
#expect(CmxPairingURLScheme(rawValue: nil) == nil)
#expect(CmxPairingURLScheme(rawValue: "") == nil)
#expect(CmxPairingURLScheme(rawValue: "https") == nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-*") == nil)
}
@Test func prefixCheckAcceptsBothChannelsAndRejectsOthers() {
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios://attach?v=2&r=100.64.0.5:58465"))
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios-dev://attach?v=2&r=100.64.0.5:58465"))
#expect(CmxPairingURLScheme.hasPairingScheme("CMUX-IOS://attach?v=2"))
#expect(!CmxPairingURLScheme.hasPairingScheme("https://example.com"))
// A bare scheme name without "://" is not a deep link.
#expect(!CmxPairingURLScheme.hasPairingScheme("cmux-ios"))
@Test func channelClassificationRecognizesOnlyAuthoritativeLanes() throws {
for bundleIdentifier in [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
let scheme = try #require(
CmxPairingURLScheme(
iOSBundleIdentifier: bundleIdentifier
)
)
#expect(scheme.isRelease)
#expect(!scheme.isDevelopment)
}
let development = try #require(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
)
)
#expect(development.isDevelopment)
#expect(!development.isRelease)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.unrecognized") == nil)
}
@Test func prefixCheckAcceptsNamespacedSchemesAndRejectsOthers() {
#expect(CmxPairingURLScheme(urlString:
"cmux-ios-dev.cmux.app.internal://attach?v=2&r=100.64.0.5:58465"
) != nil)
#expect(CmxPairingURLScheme(urlString:
"CMUX-IOS-DEV.CMUX.IOS.FEATURE-A://attach?v=2"
) != nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios://attach?v=2") != nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev://attach?v=2") != nil)
#expect(CmxPairingURLScheme(urlString: "https://example.com") == nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev.cmux.app.internal") == nil)
}
}
@@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct MobileIOSAppNamespaceTests {
@Test(
arguments: [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
"dev.cmux.ios.feature-a",
"dev.cmux.ios.feature-b",
]
)
func fullBundleIdentifierOwnsEveryNamespace(bundleIdentifier: String) throws {
let namespace = try #require(
MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier)
)
#expect(namespace.bundleIdentifier == bundleIdentifier)
#expect(
namespace.keychainService(base: "com.cmuxterm.iroh.identity")
== "com.cmuxterm.iroh.identity.\(bundleIdentifier)"
)
#expect(
namespace.keychainAccessGroup(teamIdentifier: "7WLXT3NR37")
== "7WLXT3NR37.\(bundleIdentifier)"
)
#expect(
namespace.pairingURLScheme
== "cmux-ios-\(bundleIdentifier)"
)
}
@Test func appTypesAndDevTagsNeverSharePersistentOrPairingScopes() throws {
let bundleIdentifiers = [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
"dev.cmux.ios.feature-a",
"dev.cmux.ios.feature-b",
]
let namespaces = try bundleIdentifiers.map {
try #require(MobileIOSAppNamespace(bundleIdentifier: $0))
}
#expect(Set(namespaces.map(\.serverScope)).count == namespaces.count)
#expect(Set(namespaces.map(\.pairingURLScheme)).count == namespaces.count)
#expect(
Set(
namespaces.map {
$0.keychainService(base: "com.cmuxterm.iroh.identity")
}
).count == namespaces.count
)
}
@Test func rejectsMissingOrUnsafeBundleIdentifiers() {
#expect(MobileIOSAppNamespace(bundleIdentifier: nil) == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "Dev.cmux.ios.feature-a") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.*") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev cmux ios") == nil)
}
@Test func macInstanceTagResolvesOneExactIOSBundle() {
#expect(
MobileIOSAppNamespace(pairedMacInstanceTag: "feature-a")?.bundleIdentifier
== "dev.cmux.ios.feature-a"
)
#expect(
MobileIOSAppNamespace(pairedMacInstanceTag: "default")?.bundleIdentifier
== "com.cmux.app"
)
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: "invalid tag") == nil)
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: " feature-a ") == nil)
}
@Test func legacyBackupAdoptionIsLimitedToUnambiguousOwners() throws {
let appStore = try #require(
MobileIOSAppNamespace(bundleIdentifier: "com.cmux.app")
)
let tagged = try #require(
MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.feature-a")
)
#expect(appStore.legacyBackupScope == .unscoped)
#expect(
tagged.legacyBackupScope
== .scoped("ios:v2:ZmVhdHVyZS1h")
)
for bundleIdentifier in [
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
#expect(
MobileIOSAppNamespace(
bundleIdentifier: bundleIdentifier
)?.legacyBackupScope == nil
)
}
}
}
@@ -23,12 +23,14 @@ public struct StackAuthClient: AuthClient {
/// - config: The resolved auth configuration (project id + publishable key).
/// - tokenStore: Where Stack persists tokens. Pass `.memory` for the
/// simulator DEBUG flow and `.keychain` for real devices/release.
/// - oauthBrowserSessionPrivacy: Whether OAuth may reuse Safari cookies.
/// - baseURL: Stack API origin. Defaults to Stack's production API.
/// - noAutomaticPrefetch: Disables Stack project prefetch when the host
/// owns startup sequencing.
public init(
config: AuthConfig,
tokenStore: TokenStoreInit,
oauthBrowserSessionPrivacy: OAuthBrowserSessionPrivacy = .shared,
baseURL: String = "https://api.stack-auth.com",
noAutomaticPrefetch: Bool = false
) {
@@ -38,7 +40,8 @@ public struct StackAuthClient: AuthClient {
publishableClientKey: config.stack.publishableClientKey,
baseUrl: baseURL,
tokenStore: tokenStore,
noAutomaticPrefetch: noAutomaticPrefetch
noAutomaticPrefetch: noAutomaticPrefetch,
oauthBrowserSessionPrivacy: oauthBrowserSessionPrivacy
)
)
}
@@ -820,7 +820,10 @@ public actor PushRegistrationService: PushRegistering {
guard case let .success(context) = await makeRequest(
method: "DELETE",
path: "/api/device-tokens",
body: ["deviceToken": tokenHex],
body: [
"deviceToken": tokenHex,
"bundleId": bundleID,
],
capturedAccessToken: capturedAccessToken,
capturedRefreshToken: capturedRefreshToken,
sessionSnapshot: sessionSnapshot,
@@ -872,6 +875,7 @@ public actor PushRegistrationService: PushRegistering {
request.httpMethod = method
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue(bundleID, forHTTPHeaderField: "X-Cmux-App-Namespace")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 15
@@ -19,15 +19,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
private static let accessTokenAccount = "cmux-auth-access-token"
private static let refreshTokenAccount = "cmux-auth-refresh-token"
private let service: String
private let accessGroup: String?
private let legacyProjectID: String?
private let log = AuthDebugLog()
private var cachedAccessToken: String?
private var cachedRefreshToken: String?
/// Creates a keychain store writing under `service`.
/// - Parameter service: The keychain service name; see ``serviceName(bundleIdentifier:)``.
public init(service: String) {
/// Creates a Keychain store writing under one exact signed access group.
///
/// - Parameters:
/// - service: The bundle-scoped Keychain service.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyProjectID: The Stack project whose older account-only items
/// may be adopted from this same access group.
public init(
service: String,
accessGroup: String? = nil,
legacyProjectID: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyProjectID = legacyProjectID
}
/// The keychain service name auth tokens are stored under, namespaced by
@@ -43,12 +56,18 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
public func getStoredAccessToken() async -> String? {
if let cachedAccessToken { return cachedAccessToken }
return keychainRead(account: Self.accessTokenAccount)
return readOrAdoptLegacyToken(
account: Self.accessTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
)
}
public func getStoredRefreshToken() async -> String? {
if let cachedRefreshToken { return cachedRefreshToken }
return keychainRead(account: Self.refreshTokenAccount)
return readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
}
public func setTokens(accessToken: String?, refreshToken: String?) async {
@@ -83,13 +102,20 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
cachedRefreshToken = nil
keychainDelete(account: Self.accessTokenAccount)
keychainDelete(account: Self.refreshTokenAccount)
deleteLegacyTokens()
}
@discardableResult
public func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool {
let snapshot = AuthTokenSnapshot(
accessToken: keychainRead(account: Self.accessTokenAccount),
refreshToken: keychainRead(account: Self.refreshTokenAccount)
accessToken: readOrAdoptLegacyToken(
account: Self.accessTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
),
refreshToken: readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
)
guard snapshot.matches(expectedAccessToken: accessToken, expectedRefreshToken: refreshToken) else {
log.log("keychain.clearTokensIfCurrent: skipped stale clear")
@@ -110,7 +136,10 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
newRefreshToken: String?,
newAccessToken: String?
) async {
let current = keychainRead(account: Self.refreshTokenAccount)
let current = readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
let matches = current == compareRefreshToken
log.log("keychain.compareAndSet: matches=\(matches) hasNewRefresh=\(newRefreshToken?.isEmpty == false) hasNewAccess=\(newAccessToken?.isEmpty == false)")
guard matches else { return }
@@ -121,13 +150,58 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
}
#if canImport(Security)
private func readOrAdoptLegacyToken(
account: String,
legacyAccount: String?
) -> String? {
if let current = keychainRead(account: account) {
return current
}
// Legacy account-only items are ambiguous without the exact signed
// access group. Never let a caller using the current-token-only API
// adopt another installed cmux bundle's Stack session.
guard accessGroup != nil,
let legacyAccount,
let legacy = keychainReadLegacy(account: legacyAccount),
keychainWrite(legacy, account: account) else {
return nil
}
keychainDeleteLegacy(account: legacyAccount)
return legacy
}
private func deleteLegacyTokens() {
guard accessGroup != nil, let legacyProjectID else { return }
keychainDeleteLegacy(account: "stack-auth-access-\(legacyProjectID)")
keychainDeleteLegacy(account: "stack-auth-refresh-\(legacyProjectID)")
}
private func baseQuery(account: String) -> [String: Any] {
[
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func legacyBaseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
// The legacy Stack SDK omitted this attribute when adding items,
// which Keychain persists as the empty service. An omitted query
// attribute is a wildcard and could match another credential.
kSecAttrService as String: "",
kSecAttrAccount as String: account,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func keychainRead(account: String) -> String? {
@@ -170,7 +244,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
private func keychainDelete(account: String) {
_ = SecItemDelete(baseQuery(account: account) as CFDictionary)
}
private func keychainReadLegacy(account: String) -> String? {
var query = legacyBaseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
log.log("keychain legacy READ status=\(status) account=\(account)")
}
return nil
}
return String(data: data, encoding: .utf8)
}
private func keychainDeleteLegacy(account: String) {
_ = SecItemDelete(legacyBaseQuery(account: account) as CFDictionary)
}
#else
private func readOrAdoptLegacyToken(account: String, legacyAccount: String?) -> String? { nil }
private func deleteLegacyTokens() {}
private func keychainRead(account: String) -> String? { nil }
private func keychainWrite(_ value: String, account: String) -> Bool { false }
private func keychainDelete(account: String) {}
@@ -0,0 +1,47 @@
import Foundation
#if canImport(Security)
import Security
#endif
import Testing
@testable import CmuxAuthRuntime
@Suite(.serialized)
struct KeychainStackTokenStoreTests {
#if canImport(Security)
@Test func clearingLegacyTokensPreservesSameAccountInAnotherService() async throws {
let projectID = UUID().uuidString
let account = "stack-auth-access-\(projectID)"
let unrelatedService = "cmux-test-unrelated-\(UUID().uuidString)"
let unrelatedToken = Data("unrelated-token".utf8)
let unrelatedQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: unrelatedService,
kSecAttrAccount as String: account,
]
_ = SecItemDelete(unrelatedQuery as CFDictionary)
defer { _ = SecItemDelete(unrelatedQuery as CFDictionary) }
var insertion = unrelatedQuery
insertion[kSecValueData as String] = unrelatedToken
insertion[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlock
try #require(SecItemAdd(insertion as CFDictionary, nil) == errSecSuccess)
let store = KeychainStackTokenStore(
service: "cmux-test-current-\(UUID().uuidString)",
legacyProjectID: projectID
)
await store.clearTokens()
var lookup = unrelatedQuery
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
#expect(
SecItemCopyMatching(lookup as CFDictionary, &result)
== errSecSuccess
)
#expect(result as? Data == unrelatedToken)
}
#endif
}
@@ -312,6 +312,10 @@ actor RetryDelayRecorder {
}
#expect(request?.httpMethod == "DELETE")
#expect(request?.value(forHTTPHeaderField: "X-Stack-Refresh-Token") == "captured-refresh")
#expect(
request?.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
== "dev.cmux.ios"
)
}
@Test func signOutUnregisterNeverFallsBackToLiveProvider() async {
@@ -36,6 +36,16 @@ public struct CmxIrohBackpressuredClientBroker:
}
}
/// Reports whether the wrapped client retains request authorization.
public func hasBindingAuthorization() async -> Bool {
await broker.hasBindingAuthorization()
}
/// Returns the binding ID represented by the wrapped client's proof.
public func bindingAuthorizationID() async -> String? {
await broker.bindingAuthorizationID()
}
public func discover() async throws -> CmxIrohDiscoveryResponse {
try await gate.perform(accountID: accountID, operation: .discovery) {
try await broker.discover()
@@ -81,6 +91,20 @@ public struct CmxIrohBackpressuredClientBroker:
try await broker.revoke(bindingID: bindingID)
}
}
/// Revokes an older same-device binding through the wrapped stale route.
public func revokeStale(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.revokeStale(bindingID: bindingID)
}
}
/// Revokes one same-build Mac through the wrapped account-management path.
public func forgetMac(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.forgetMac(bindingID: bindingID)
}
}
}
/// Operation-gated host broker used by an account-owned Mac runtime.
@@ -160,6 +184,13 @@ public struct CmxIrohBackpressuredHostBroker:
try await broker.revoke(bindingID: bindingID)
}
}
/// Revokes an older same-device binding through the wrapped stale route.
public func revokeStale(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.revokeStale(bindingID: bindingID)
}
}
}
/// Operation-gated relay-policy broker sharing a runtime's account gate.
@@ -0,0 +1,45 @@
public import CMUXMobileCore
/// Proof material that lets a fresh broker client act as one registered binding.
public struct CmxIrohBindingRequestAuthorization: Sendable {
/// The exact broker binding whose endpoint key signs each request.
public let bindingID: String
/// The exact app namespace recorded on the authorized binding.
public let clientNamespace: String
let signer: CmxIrohRegistrationSigner
/// Reconstructs request authorization from retained binding and identity state.
///
/// - Parameters:
/// - bindingID: The exact registered broker binding identifier.
/// - clientNamespace: The exact namespace recorded during registration.
/// - identity: The endpoint identity material that owns the binding.
/// - endpointID: The endpoint identifier recorded on the binding.
/// - Throws: ``CmxIrohRegistrationError/endpointIdentityMismatch`` when the
/// supplied identity does not derive the recorded endpoint.
public init(
bindingID: String,
clientNamespace: String,
identity: CmxIrohIdentityMaterial,
endpointID: CmxIrohPeerIdentity
) throws {
self.bindingID = bindingID
self.clientNamespace = clientNamespace
signer = try CmxIrohRegistrationSigner(
identity: identity,
endpointID: endpointID.endpointID
)
}
init(
bindingID: String,
clientNamespace: String,
signer: CmxIrohRegistrationSigner
) {
self.bindingID = bindingID
self.clientNamespace = clientNamespace
self.signer = signer
}
}
@@ -6,4 +6,8 @@ public protocol CmxIrohBindingRevoking: Sendable {
///
/// - Parameter bindingID: The broker-owned lowercase binding UUID.
func revoke(bindingID: String) async throws
/// Revokes an older same-device binding through the account-scoped stale
/// cleanup route, rather than pretending the caller owns that ID.
func revokeStale(bindingID: String) async throws
}
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
case bindingID
case deviceID
case appInstanceID
case clientNamespace
case tag
case platform
case endpointID
@@ -23,6 +24,9 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// The installation's broker-facing app-instance UUID.
public let appInstanceID: String
/// The exact app namespace that owns the binding.
public let clientNamespace: String
/// The build tag registered with the broker.
public let tag: String
@@ -44,6 +48,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// - bindingID: The broker-owned lowercase binding UUID.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - clientNamespace: The exact bundle-derived app namespace.
/// - tag: The safe build tag sent during registration.
/// - platform: The endpoint's platform role.
/// - endpointID: The registered Iroh endpoint identity.
@@ -54,6 +59,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
@@ -63,13 +69,15 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeTag(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration) else {
throw CmxIrohBrokerCredentialRepositoryError.invalidBinding
}
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.endpointID = endpointID
@@ -84,6 +92,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID = binding.bindingID
deviceID = binding.deviceID
appInstanceID = binding.appInstanceID
clientNamespace = binding.clientNamespace
tag = binding.tag
platform = binding.platform
endpointID = binding.endpointID
@@ -101,6 +110,10 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID: container.decode(String.self, forKey: .bindingID),
deviceID: container.decode(String.self, forKey: .deviceID),
appInstanceID: container.decode(String.self, forKey: .appInstanceID),
clientNamespace: container.decodeIfPresent(
String.self,
forKey: .clientNamespace
) ?? "legacy",
tag: container.decode(String.self, forKey: .tag),
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
@@ -116,13 +129,4 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeTag(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
case bindingID = "binding_id"
case deviceID = "device_id"
case appInstanceID = "app_instance_id"
case clientNamespace = "client_namespace"
case tag
case platform
case displayName = "display_name"
@@ -22,6 +23,9 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
public let bindingID: String
public let deviceID: String
public let appInstanceID: String
/// The exact bundle-derived app namespace that owns this binding.
public let clientNamespace: String
public let tag: String
public let platform: CmxIrohPlatform
public let displayName: String?
@@ -38,6 +42,10 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
let bindingID = try container.decode(String.self, forKey: .bindingID)
let deviceID = try container.decode(String.self, forKey: .deviceID)
let appInstanceID = try container.decode(String.self, forKey: .appInstanceID)
let clientNamespace = try container.decodeIfPresent(
String.self,
forKey: .clientNamespace
) ?? "legacy"
let tag = try container.decode(String.self, forKey: .tag)
let endpointID = try container.decode(String.self, forKey: .endpointID)
let identityGeneration = try container.decode(Int.self, forKey: .identityGeneration)
@@ -52,11 +60,12 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken),
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }),
displayName.map(Self.isSafeDisplayName) ?? true,
pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount,
pathHints.filter({ $0.kind == .relayURL }).count <= 2,
@@ -72,6 +81,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
platform = try container.decode(CmxIrohPlatform.self, forKey: .platform)
self.displayName = displayName
@@ -89,6 +99,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
try container.encode(bindingID, forKey: .bindingID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(appInstanceID, forKey: .appInstanceID)
try container.encode(clientNamespace, forKey: .clientNamespace)
try container.encode(tag, forKey: .tag)
try container.encode(platform, forKey: .platform)
try container.encodeIfPresent(displayName, forKey: .displayName)
@@ -105,16 +116,6 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
private static func isSafeDisplayName(_ value: String) -> Bool {
!value.isEmpty
&& value.utf16.count <= 128
@@ -4,6 +4,8 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
public let deviceId: String
/// Stable app-instance UUID.
public let appInstanceId: String
/// Exact app namespace that owns the prospective binding.
public let clientNamespace: String
/// Safe build or app-instance tag.
public let tag: String
/// Exact Iroh EndpointID that will sign the challenge.
@@ -16,6 +18,7 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
init(payload: CmxIrohRegistrationPayload, payloadSHA256: String) {
deviceId = payload.deviceID
appInstanceId = payload.appInstanceID
clientNamespace = payload.clientNamespace
tag = payload.tag
endpointId = payload.endpointID
identityGeneration = payload.identityGeneration
@@ -10,10 +10,27 @@ public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing,
prepared: CmxIrohPreparedRegistration,
signer: CmxIrohRegistrationSigner
) async throws -> CmxIrohRegistrationResponse
/// Reports whether signed post-registration broker requests can be made.
/// A rate-limited registration cannot establish this proof on a cold start.
func hasBindingAuthorization() async -> Bool
/// Returns the binding ID represented by the retained request proof.
func bindingAuthorizationID() async -> String?
/// Revokes one same-build Mac through the explicit account-management path.
func forgetMac(bindingID: String) async throws
}
public extension CmxIrohClientBrokerServing {
/// Accepts the operation when a conformer does not impose a local broker floor.
func preflight(operation _: CmxIrohBrokerOperation) async throws {}
/// Reports no retained request proof for conformers that do not persist one.
func hasBindingAuthorization() async -> Bool { false }
/// Reports no retained binding ID for conformers that do not persist proof.
func bindingAuthorizationID() async -> String? { nil }
}
extension CmxIrohTrustBrokerClient: CmxIrohClientBrokerServing {}
@@ -456,6 +456,7 @@ public actor CmxIrohClientOfflinePolicyCache {
left.bindingID == right.bindingID
&& left.deviceID == right.deviceID
&& left.appInstanceID == right.appInstanceID
&& left.clientNamespace == right.clientNamespace
&& left.tag == right.tag
&& left.platform == right.platform
&& left.endpointID == right.endpointID
@@ -469,7 +470,7 @@ public actor CmxIrohClientOfflinePolicyCache {
for expectation: CmxIrohClientOfflinePolicyExpectation
) -> String {
let transcript = Data(
"cmux/iroh/offline-client-policy-scope/v1\0\(expectation.accountID)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
"cmux/iroh/offline-client-policy-scope/v2\0\(expectation.accountID)\0\(expectation.localBindingExpectation.clientNamespace)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
)
return SHA256.hash(data: transcript)
.map { String(format: "%02x", $0) }
@@ -3,6 +3,7 @@ public import Foundation
extension CmxIrohClientRuntime {
func performSignOut(
pendingRevocation: CmxIrohPendingRevocation?,
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
revision: UInt64
) async -> CmxIrohClientSignOutPreparation {
async let wasPersisted = Self.persist(pendingRevocation, to: pendingRevocations)
@@ -10,7 +11,8 @@ extension CmxIrohClientRuntime {
let (persisted, _) = await (wasPersisted, networkTeardown)
let preparation = CmxIrohClientSignOutPreparation(
pendingRevocation: pendingRevocation,
wasPersisted: persisted
wasPersisted: persisted,
bindingAuthorization: bindingAuthorization
)
guard lifecyclePhase == .signingOut,
@@ -15,6 +15,7 @@ extension CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
endpointID: expectedEndpointID,
@@ -101,6 +102,7 @@ extension CmxIrohClientRuntime {
)
let prepared = try signer.prepare(payload: payload)
let registration: CmxIrohRegistrationResponse?
var registrationFailure: (any Error)?
do {
registration = try await broker.register(prepared: prepared, signer: signer)
} catch {
@@ -108,6 +110,7 @@ extension CmxIrohClientRuntime {
// Registration backpressure blocks mutation, while a fresh
// authenticated discovery can still confirm an existing tuple.
registration = nil
registrationFailure = error
} else {
guard !prefetchedDiscoveryRejectedCachedBinding,
Self.recoversWithCachedPolicy(error),
@@ -130,12 +133,38 @@ extension CmxIrohClientRuntime {
if let registration, !expectation.matches(registration.binding) {
throw CmxIrohClientRuntimeError.invalidLocalBinding
}
if registration == nil,
!(await broker.hasBindingAuthorization()) {
// No registration response means this broker instance did not get
// a chance to install fresh proof. Do not drain revocations or
// issue namespaced discovery requests without persisted proof.
throw registrationFailure
?? CmxIrohTrustBrokerClientError.invalidAuthentication
}
if registration != nil {
lastRegistrationRefreshState = refreshState
}
let revokedPendingBinding: Bool
let activeBindingID: String?
if let registration {
activeBindingID = registration.binding.bindingID
} else {
activeBindingID = await broker.bindingAuthorizationID()
}
guard let activeBindingID else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
revokedPendingBinding = try await pendingRevocations.reconcilePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
activeBindingID: activeBindingID,
using: broker
)
try requireCurrent(revision)
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration?.discovery,
if !revokedPendingBinding,
let embedded = registration?.discovery,
registration?.embeddedDiscoveryComplete == true {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration?.revision,
@@ -264,6 +293,7 @@ extension CmxIrohClientRuntime {
return try CmxIrohRegistrationPayload(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
displayName: configuration.displayName,
@@ -287,12 +317,6 @@ extension CmxIrohClientRuntime {
}
func preparePolicyResolution(revision: UInt64) async throws {
try await pendingRevocations.revokePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
using: broker
)
try requireCurrent(revision)
try await broker.preflight(operation: .discovery)
try requireCurrent(revision)
}
@@ -90,6 +90,7 @@ extension CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: binding.clientNamespace,
tag: binding.tag,
platform: binding.platform,
endpointID: binding.endpointID,
@@ -326,6 +326,7 @@ public actor CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
endpointID: liveEndpointIdentity,
@@ -733,6 +734,14 @@ public actor CmxIrohClientRuntime {
bindingID: binding.bindingID
)
}
let bindingAuthorization = localBinding.flatMap { binding in
try? CmxIrohBindingRequestAuthorization(
bindingID: binding.bindingID,
clientNamespace: binding.clientNamespace,
identity: configuration.identity,
endpointID: binding.endpointID
)
}
lifecyclePhase = .signingOut
lifecycleRevision &+= 1
let revision = lifecycleRevision
@@ -745,6 +754,7 @@ public actor CmxIrohClientRuntime {
let operation = Task {
await self.performSignOut(
pendingRevocation: pendingRevocation,
bindingAuthorization: bindingAuthorization,
revision: revision
)
}
@@ -11,6 +11,9 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
/// The account-and-build-scoped app-instance UUID.
public let appInstanceID: String
/// Exact installed-app namespace sent to every broker request.
public let clientNamespace: String
/// The release channel or tagged-build scope registered with the broker.
public let tag: String
@@ -61,6 +64,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String,
tag: String,
displayName: String?,
identity: CmxIrohIdentityMaterial,
@@ -73,6 +77,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
self.accountID = accountID
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace
self.tag = tag
self.displayName = displayName
self.identity = identity
@@ -6,6 +6,9 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
/// Whether the first device-only persistence attempt succeeded.
public let wasPersisted: Bool
/// In-memory proof retained across local identity deletion for immediate revoke.
public let bindingAuthorization: CmxIrohBindingRequestAuthorization?
/// The broker binding to revoke, or `nil` before registration.
public var bindingID: String? { pendingRevocation?.bindingID }
@@ -14,12 +17,28 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
/// - Parameters:
/// - pendingRevocation: The validated prior binding, or `nil` before registration.
/// - wasPersisted: Whether it was durably queued before local teardown.
/// - bindingAuthorization: Ephemeral proof for a fresh captured-token client.
public init(
pendingRevocation: CmxIrohPendingRevocation?,
wasPersisted: Bool
wasPersisted: Bool,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil
) {
self.pendingRevocation = pendingRevocation
self.wasPersisted = pendingRevocation == nil || wasPersisted
self.bindingAuthorization = bindingAuthorization
}
/// Compares durable state and the authorized binding without exposing key bytes.
public static func == (
lhs: CmxIrohSignOutPreparation,
rhs: CmxIrohSignOutPreparation
) -> Bool {
lhs.pendingRevocation == rhs.pendingRevocation
&& lhs.wasPersisted == rhs.wasPersisted
&& lhs.bindingAuthorization?.bindingID
== rhs.bindingAuthorization?.bindingID
&& lhs.bindingAuthorization?.clientNamespace
== rhs.bindingAuthorization?.clientNamespace
}
/// Revokes the captured binding with a broker authenticated from captured tokens.
@@ -6,25 +6,26 @@ public import Foundation
/// access group, so the data-protection Keychain returns
/// `errSecMissingEntitlement`. Production compositions must keep using
/// ``CmxIrohKeychainIdentityStore``.
public final class CmxIrohDevelopmentFileIdentityStore:
CmxIrohSecureIdentityStoring,
@unchecked Sendable
public actor CmxIrohDevelopmentFileIdentityStore:
CmxIrohSecureIdentityStoring
{
private let directory: URL
nonisolated private let directory: URL
/// Creates a store inside a tag-specific application-support directory.
public init(directory: URL) {
self.directory = directory
}
public func read(account: String) throws -> Data? {
/// Loads one development identity record.
public func read(account: String) async throws -> Data? {
try CmxIrohDevelopmentFileStorage.read(
account: account,
directory: directory
)
}
public func write(_ data: Data, account: String) throws {
/// Replaces one development identity record.
public func write(_ data: Data, account: String) async throws {
try CmxIrohDevelopmentFileStorage.write(
data,
account: account,
@@ -33,11 +34,9 @@ public final class CmxIrohDevelopmentFileIdentityStore:
}
/// Whether ANY identity record file exists, without reading or creating
/// one. Development-build counterpart of
/// ``CmxIrohKeychainIdentityStore/containsAnyRecord()``; file storage lives
/// in the app container (which CAN travel in a backup), an accepted
/// dev-only weakening of the continuity signal.
public func containsAnyRecord() -> Bool {
/// one. File storage lives in the app container (which CAN travel in a
/// backup), an accepted dev-only weakening of the continuity signal.
public nonisolated func containsAnyRecord() -> Bool {
let entries = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
@@ -45,14 +44,16 @@ public final class CmxIrohDevelopmentFileIdentityStore:
return !entries.isEmpty
}
public func delete(account: String) throws {
/// Removes one development identity record.
public func delete(account: String) async throws {
try CmxIrohDevelopmentFileStorage.delete(
account: account,
directory: directory
)
}
public func deleteAll() throws {
/// Removes every development identity record in this store.
public func deleteAll() async throws {
try CmxIrohDevelopmentFileStorage.deleteAll(in: directory)
}
}
@@ -197,6 +197,7 @@ public actor CmxIrohHostPolicyCache {
let binding = policy.binding
guard binding.deviceID == expectation.deviceID,
binding.appInstanceID == expectation.appInstanceID,
binding.clientNamespace == expectation.clientNamespace,
binding.tag == expectation.tag,
binding.platform == .mac,
binding.endpointID == expectation.endpointID,
@@ -235,7 +236,7 @@ public actor CmxIrohHostPolicyCache {
for expectation: CmxIrohHostPolicyExpectation
) -> String {
let transcript = Data(
"cmux/iroh/offline-host-policy-scope/v1\0\(expectation.accountID)\0\(expectation.appInstanceID)".utf8
"cmux/iroh/offline-host-policy-scope/v2\0\(expectation.accountID)\0\(expectation.clientNamespace)\0\(expectation.appInstanceID)".utf8
)
return SHA256.hash(data: transcript)
.map { String(format: "%02x", $0) }
@@ -12,6 +12,9 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
/// The current app-instance UUID, which changes when the account or build tag changes.
public let appInstanceID: String
/// The exact Mac app namespace that owns this endpoint.
public let clientNamespace: String
/// The build tag registered with the trust broker.
public let tag: String
@@ -36,6 +39,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
/// - accountID: The current authenticated account identifier.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - clientNamespace: The exact bundle-derived app namespace.
/// - tag: The safe build tag used for broker registration.
/// - endpointID: The current local Iroh EndpointID.
/// - identityGeneration: The positive local identity generation.
@@ -46,6 +50,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
endpointID: CmxIrohPeerIdentity,
identityGeneration: Int,
@@ -56,16 +61,18 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
accountID.utf8.count <= 1_024,
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken) else {
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
throw CmxIrohHostPolicyCacheError.invalidExpectation
}
self.accountID = accountID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.endpointID = endpointID
self.identityGeneration = identityGeneration
@@ -77,13 +84,4 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -7,13 +7,11 @@ extension CmxIrohHostRuntime {
expectedEndpointID: CmxIrohPeerIdentity,
revision: UInt64
) async throws -> ResolvedPolicy {
try await revokePendingBeforeRegistration()
try requireCurrent(revision)
var failureCount = 0
while true {
try requireCurrent(revision)
do {
return try await resolvePolicyAfterPendingRevocations(
return try await resolvePolicyAfterAuthenticatedRegistration(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
@@ -21,6 +19,8 @@ extension CmxIrohHostRuntime {
)
} catch is CancellationError {
throw CancellationError()
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
throw failure.underlying
} catch {
try requireCurrent(revision)
guard CmxIrohTrustBrokerClientError
@@ -48,25 +48,30 @@ extension CmxIrohHostRuntime {
revision: UInt64,
allowCachedFallback: Bool
) async throws -> ResolvedPolicy {
try await revokePendingBeforeRegistration()
try requireCurrent(revision)
return try await resolvePolicyAfterPendingRevocations(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
allowCachedFallback: allowCachedFallback
)
do {
return try await resolvePolicyAfterAuthenticatedRegistration(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
allowCachedFallback: allowCachedFallback
)
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
throw failure.underlying
}
}
private func revokePendingBeforeRegistration() async throws {
try await pendingRevocations.revokePending(
private func reconcilePendingAfterRegistration(
activeBindingID: String
) async throws -> Bool {
try await pendingRevocations.reconcilePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
activeBindingID: activeBindingID,
using: broker
)
}
private func resolvePolicyAfterPendingRevocations(
private func resolvePolicyAfterAuthenticatedRegistration(
engine: CmxConnectivityEngine,
expectedEndpointID: CmxIrohPeerIdentity,
revision: UInt64,
@@ -117,9 +122,19 @@ extension CmxIrohHostRuntime {
}
try requireCurrent(revision)
try validateLocalBinding(registration.binding, endpointID: expectedEndpointID)
let revokedPendingBinding: Bool
do {
revokedPendingBinding = try await reconcilePendingAfterRegistration(
activeBindingID: registration.binding.bindingID
)
} catch {
throw CmxIrohPostRegistrationRevocationFailure(underlying: error)
}
try requireCurrent(revision)
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration.discovery,
if !revokedPendingBinding,
let embedded = registration.discovery,
registration.embeddedDiscoveryComplete {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration.revision,
@@ -228,6 +243,7 @@ extension CmxIrohHostRuntime {
return try CmxIrohRegistrationPayload(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .mac,
displayName: configuration.displayName,
@@ -304,6 +320,7 @@ extension CmxIrohHostRuntime {
) throws {
guard binding.deviceID == configuration.deviceID,
binding.appInstanceID == configuration.appInstanceID,
binding.clientNamespace == configuration.clientNamespace,
binding.tag == configuration.tag,
binding.platform == .mac,
binding.endpointID == endpointID,
@@ -322,6 +339,7 @@ extension CmxIrohHostRuntime {
let binding = policy.binding
guard binding.deviceID == configuration.deviceID,
binding.appInstanceID == configuration.appInstanceID,
binding.clientNamespace == configuration.clientNamespace,
binding.tag == configuration.tag,
binding.platform == .mac,
binding.endpointID == endpointID,
@@ -232,6 +232,14 @@ extension CmxIrohHostRuntime {
bindingID: binding.bindingID
)
}
let bindingAuthorization = localBinding.flatMap { binding in
try? CmxIrohBindingRequestAuthorization(
bindingID: binding.bindingID,
clientNamespace: binding.clientNamespace,
identity: configuration.identity,
endpointID: binding.endpointID
)
}
lifecyclePhase = .signingOut
lifecycleRevision &+= 1
let revision = lifecycleRevision
@@ -244,6 +252,7 @@ extension CmxIrohHostRuntime {
let operation = Task {
await self.performSignOut(
pendingRevocation: pendingRevocation,
bindingAuthorization: bindingAuthorization,
requiresNetworkDeactivation: requiresNetworkDeactivation,
revision: revision
)
@@ -3,6 +3,7 @@ public import Foundation
extension CmxIrohHostRuntime {
func performSignOut(
pendingRevocation: CmxIrohPendingRevocation?,
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
requiresNetworkDeactivation: Bool,
revision: UInt64
) async -> CmxIrohHostSignOutPreparation {
@@ -17,7 +18,8 @@ extension CmxIrohHostRuntime {
let (persisted, _) = await (wasPersisted, networkTeardown)
let preparation = CmxIrohHostSignOutPreparation(
pendingRevocation: pendingRevocation,
wasPersisted: persisted
wasPersisted: persisted,
bindingAuthorization: bindingAuthorization
)
guard lifecyclePhase == .signingOut,
@@ -7,6 +7,8 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
public let deviceID: String
public let appInstanceID: String
/// Exact Mac build namespace sent to every broker request.
public let clientNamespace: String
public let tag: String
public let displayName: String?
public let identity: CmxIrohIdentityMaterial
@@ -29,6 +31,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
/// - accountID: The exact account that owns this host binding.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The current app-instance UUID.
/// - clientNamespace: The exact installed Mac bundle namespace.
/// - tag: The broker registration build tag.
/// - displayName: The optional user-visible Mac name.
/// - identity: The stable Iroh secret and generation.
@@ -43,6 +46,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: CmxIrohMacBundleNamespace,
tag: String,
displayName: String?,
identity: CmxIrohIdentityMaterial,
@@ -57,6 +61,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
self.accountID = accountID
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace.rawValue
self.tag = tag
self.displayName = displayName
self.identity = identity
@@ -6,11 +6,15 @@ public actor CmxIrohIdentityRepository {
private static let installMarkerKey = "cmux.iroh.identity.install-marker.v1"
private static let activeScopeKey = "cmux.iroh.identity.active-scope.v1"
private static let recordVersion: UInt8 = 1
private static let maximumQueuedOperations = 64
private let secureStore: any CmxIrohSecureIdentityStoring
private let installState: any CmxIrohInstallStateStoring
private let randomBytes: @Sendable () throws -> Data
private let marker: @Sendable () -> String
private var operationIsActive = false
private var operationWaiters: [UUID: CheckedContinuation<Void, any Error>] = [:]
private var operationWaiterOrder: [UUID] = []
/// Creates an identity repository with injectable persistence and entropy.
public init(
@@ -32,34 +36,84 @@ public actor CmxIrohIdentityRepository {
/// A missing install marker removes Keychain material that survived an app
/// uninstall. Changing account scope removes the prior account key before
/// creating a new EndpointID.
public func identity(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
if let encoded = try secureStore.read(account: scope) {
public func identity(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
if let encoded = try await secureStore.read(account: scope) {
return try Self.decode(encoded)
}
return try create(scope: scope, generation: 1)
return try await create(scope: scope, generation: 1)
}
/// Replaces the active account key and increments its identity generation.
public func rotate(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
let current = try secureStore.read(account: scope).map(Self.decode)
public func rotate(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
let current = try await secureStore.read(account: scope).map(Self.decode)
let generation = try current.map { material in
guard material.generation < Int(Int32.max) else {
throw CmxIrohIdentityRepositoryError.invalidGeneration
}
return material.generation + 1
} ?? 1
return try create(scope: scope, generation: generation)
return try await create(scope: scope, generation: generation)
}
/// Removes all endpoint identity when signing out or locally revoking it.
public func deactivate() throws {
try secureStore.deleteAll()
public func deactivate() async throws {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
try await secureStore.deleteAll()
installState.set(nil, forKey: Self.activeScopeKey)
}
private func prepareScope(accountID: String, appInstanceID: String) throws -> String {
private func beginOperation() async throws {
guard operationIsActive else {
operationIsActive = true
return
}
guard operationWaiterOrder.count < Self.maximumQueuedOperations else {
throw CmxIrohIdentityRepositoryError.operationLimitExceeded
}
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled {
continuation.resume(throwing: CancellationError())
return
}
operationWaiters[id] = continuation
operationWaiterOrder.append(id)
}
} onCancel: {
Task { await self.cancelOperationWaiter(id) }
}
}
private func endOperation() {
guard let id = operationWaiterOrder.first else {
operationIsActive = false
return
}
operationWaiterOrder.removeFirst()
operationWaiters.removeValue(forKey: id)?.resume()
}
private func cancelOperationWaiter(_ id: UUID) {
guard let continuation = operationWaiters.removeValue(forKey: id) else {
return
}
operationWaiterOrder.removeAll { $0 == id }
continuation.resume(throwing: CancellationError())
}
private func prepareScope(accountID: String, appInstanceID: String) async throws -> String {
guard !accountID.isEmpty,
accountID.utf8.count <= 1_024,
!appInstanceID.isEmpty,
@@ -68,7 +122,7 @@ public actor CmxIrohIdentityRepository {
}
var clearedSecureStore = false
if installState.string(forKey: Self.installMarkerKey) == nil {
try secureStore.deleteAll()
try await secureStore.deleteAll()
clearedSecureStore = true
installState.set(nil, forKey: Self.activeScopeKey)
installState.set(marker(), forKey: Self.installMarkerKey)
@@ -76,17 +130,17 @@ public actor CmxIrohIdentityRepository {
let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID)
if installState.string(forKey: Self.activeScopeKey) != scope {
if !clearedSecureStore {
try secureStore.deleteAll()
try await secureStore.deleteAll()
}
installState.set(scope, forKey: Self.activeScopeKey)
}
return scope
}
private func create(scope: String, generation: Int) throws -> CmxIrohIdentityMaterial {
private func create(scope: String, generation: Int) async throws -> CmxIrohIdentityMaterial {
let secretKey = try CmxIrohSecretKey(bytes: randomBytes())
let material = try CmxIrohIdentityMaterial(secretKey: secretKey, generation: generation)
try secureStore.write(Self.encode(material), account: scope)
try await secureStore.write(Self.encode(material), account: scope)
return material
}
@@ -9,6 +9,9 @@ public enum CmxIrohIdentityRepositoryError: Error, Equatable, Sendable {
/// The identity generation is zero, exhausted, or database-incompatible.
case invalidGeneration
/// Too many identity operations are waiting behind a stalled persistence call.
case operationLimitExceeded
/// Secure random generation failed with the platform status code.
case randomGenerationFailed(Int32)
}
@@ -4,12 +4,24 @@ import Security
/// Device-only Keychain storage for Iroh relay capabilities.
public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
private let service: String
private let accessGroup: String?
private let legacyService: String?
/// Creates a Keychain store isolated by service name.
///
/// - Parameter service: The generic-password service identifier.
public init(service: String = "com.cmuxterm.iroh.relay-credentials.v1") {
/// - Parameters:
/// - service: The bundle-scoped generic-password service identifier.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyService: An older service whose item may be adopted only from
/// the same exact access group.
public init(
service: String = "com.cmuxterm.iroh.relay-credentials.v1",
accessGroup: String? = nil,
legacyService: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyService = legacyService == service ? nil : legacyService
}
/// Loads one opaque-scope capability from Keychain.
@@ -18,7 +30,24 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
/// - Returns: The stored capability, or `nil` when none exists.
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func read(account: String) throws -> Data? {
var query = baseQuery(account: account)
if let current = try read(service: service, account: account) {
return current
}
guard let legacyService,
let legacy = try read(service: legacyService, account: account) else {
return nil
}
try write(
legacy,
account: account,
accessibility: .afterFirstUnlockThisDeviceOnly
)
try delete(query: baseQuery(service: legacyService, account: account))
return legacy
}
private func read(service: String, account: String) throws -> Data? {
var query = baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -44,7 +73,7 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
account: String,
accessibility: CmxIrohSecureCredentialAccessibility
) throws {
let query = baseQuery(account: account)
let query = baseQuery(service: service, account: account)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: secAccessibility(accessibility),
@@ -83,17 +112,26 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
/// - Parameter account: The repository-derived scope.
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func delete(account: String) throws {
try delete(query: baseQuery(account: account))
try delete(query: baseQuery(service: service, account: account))
if let legacyService {
try delete(query: baseQuery(service: legacyService, account: account))
}
}
/// Removes every relay capability owned by this Keychain service.
///
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func deleteAll() throws {
try delete(query: baseQuery())
try delete(query: baseQuery(service: service))
if let legacyService {
try delete(query: baseQuery(service: legacyService))
}
}
private func baseQuery(account: String? = nil) -> [String: Any] {
private func baseQuery(
service: String,
account: String? = nil
) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -103,6 +141,9 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
if let account {
query[kSecAttrAccount as String] = account
}
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
@@ -2,18 +2,44 @@ public import Foundation
import Security
/// Device-only Keychain storage for Iroh EndpointID secret material.
public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
public actor CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring {
private let service: String
private let accessGroup: String?
private let legacyService: String?
/// Creates a Keychain store isolated by service name.
///
/// - Parameter service: The generic-password service identifier.
public init(service: String = "com.cmuxterm.iroh.endpoint-identity.v1") {
/// - Parameters:
/// - service: The bundle-scoped generic-password service identifier.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyService: An older service whose item may be adopted only from
/// the same exact access group.
public init(
service: String = "com.cmuxterm.iroh.endpoint-identity.v1",
accessGroup: String? = nil,
legacyService: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyService = legacyService == service ? nil : legacyService
}
public func read(account: String) throws -> Data? {
var query = baseQuery(account: account)
/// Loads one identity, adopting its same-access-group legacy record when needed.
public func read(account: String) async throws -> Data? {
if let current = try read(service: service, account: account) {
return current
}
guard let legacyService,
let legacy = try read(service: legacyService, account: account) else {
return nil
}
try writeStored(legacy, account: account)
try delete(query: baseQuery(service: legacyService, account: account))
return legacy
}
private func read(service: String, account: String) throws -> Data? {
var query = baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -27,8 +53,13 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
return data
}
public func write(_ data: Data, account: String) throws {
let query = baseQuery(account: account)
/// Replaces one identity in the bundle-scoped Keychain service.
public func write(_ data: Data, account: String) async throws {
try writeStored(data, account: account)
}
private func writeStored(_ data: Data, account: String) throws {
let query = baseQuery(service: service, account: account)
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
@@ -48,28 +79,20 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
}
}
public func delete(account: String) throws {
try delete(query: baseQuery(account: account))
/// Removes one identity from the current and eligible legacy services.
public func delete(account: String) async throws {
try delete(query: baseQuery(service: service, account: account))
if let legacyService {
try delete(query: baseQuery(service: legacyService, account: account))
}
}
/// Whether ANY identity record exists under this store's service, without
/// reading or creating one.
///
/// Items here are `AfterFirstUnlockThisDeviceOnly`: they never travel in a
/// device backup, so a present record is proof the app previously ran (and
/// activated iroh) on THIS physical device the non-migrating continuity
/// signal the device-registry mirror adoption gates on. Any error
/// (including a locked Keychain) reports `false`: absence of proof, never
/// proof of absence, so callers stay fail-safe.
public func containsAnyRecord() -> Bool {
var query = baseQuery()
query[kSecMatchLimit as String] = kSecMatchLimitOne
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
public func deleteAll() throws {
try delete(query: baseQuery())
/// Removes every identity from the current and eligible legacy services.
public func deleteAll() async throws {
try delete(query: baseQuery(service: service))
if let legacyService {
try delete(query: baseQuery(service: legacyService))
}
}
/// Generates one Ed25519 secret using Security.framework.
@@ -85,7 +108,10 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
return data
}
private func baseQuery(account: String? = nil) -> [String: Any] {
private func baseQuery(
service: String,
account: String? = nil
) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -95,6 +121,9 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
if let account {
query[kSecAttrAccount as String] = account
}
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
@@ -5,6 +5,9 @@ import Foundation
public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public let deviceID: String
public let appInstanceID: String
/// The exact bundle-derived app namespace expected in discovery.
public let clientNamespace: String
public let tag: String
public let platform: CmxIrohPlatform
public let endpointID: CmxIrohPeerIdentity
@@ -15,6 +18,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public init(
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
@@ -24,15 +28,17 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
) throws {
guard Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken) else {
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
throw CmxIrohLocalBindingExpectationError.invalidExpectation
}
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.endpointID = endpointID
@@ -45,6 +51,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public func matches(_ binding: CmxIrohBrokerBinding) -> Bool {
binding.deviceID == deviceID
&& binding.appInstanceID == appInstanceID
&& binding.clientNamespace == clientNamespace
&& binding.tag == tag
&& binding.platform == platform
&& binding.endpointID == endpointID
@@ -58,13 +65,4 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -0,0 +1,29 @@
import Foundation
/// Broker namespace owned by one exact installed macOS app bundle.
public struct CmxIrohMacBundleNamespace: Equatable, Hashable, Sendable {
/// Canonical `mac:<bundle-id>` value sent to the trust broker.
public let rawValue: String
/// Creates a namespace from one complete macOS bundle identifier.
public init?(bundleIdentifier: String?) {
guard let bundleIdentifier else { return nil }
let trimmed = bundleIdentifier.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard trimmed == bundleIdentifier,
trimmed.contains("."),
trimmed.utf8.count <= 251,
trimmed.range(
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
options: .regularExpression
) != nil else {
return nil
}
let value = "mac:\(trimmed.lowercased())"
guard cmxIrohIsSafeToken(value, maximumUTF8ByteCount: 255) else {
return nil
}
rawValue = value
}
}
@@ -0,0 +1,4 @@
struct CmxIrohMacForgetRequest: Encodable {
let bindingId: String
let intent = "forget_mac"
}
@@ -68,4 +68,5 @@ public struct CmxIrohPendingRevocation: Codable, Equatable, Sendable {
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
}
@@ -70,12 +70,14 @@ public actor CmxIrohPendingRevocationOutbox {
/// - accountID: The currently authenticated account.
/// - tag: The build tag about to register.
/// - broker: An authenticated idempotent binding revoker.
/// - Returns: Whether at least one pending binding was revoked.
/// - Throws: The first broker, validation, decoding, or persistence error.
@discardableResult
public func revokePending(
accountID: String,
beforeRegisteringTag tag: String,
using broker: any CmxIrohBindingRevoking
) async throws {
) async throws -> Bool {
guard CmxIrohPendingRevocation.isSafeAccountID(accountID),
CmxIrohPendingRevocation.isSafeTag(tag) else {
throw CmxIrohPendingRevocationError.invalidRecord
@@ -88,6 +90,41 @@ public actor CmxIrohPendingRevocationOutbox {
try await removeConfirmed(revocation)
}
return !ordered.isEmpty
}
/// Reconciles pending bindings after registration has installed one active
/// binding. A broker may reuse the same binding identifier when a sign-out
/// was queued and the app signs back in before the queue drained. That
/// identifier is already active again, so removing its stale queue entry
/// must not send a revoke request for it.
///
/// - Returns: Whether at least one different pending binding was revoked.
public func reconcilePending(
accountID: String,
beforeRegisteringTag tag: String,
activeBindingID: String,
using broker: any CmxIrohBindingRevoking
) async throws -> Bool {
guard UUID(uuidString: activeBindingID)?.uuidString.lowercased() == activeBindingID,
CmxIrohPendingRevocation.isSafeAccountID(accountID),
CmxIrohPendingRevocation.isSafeTag(tag) else {
throw CmxIrohPendingRevocationError.invalidRecord
}
let snapshot = try await pending(accountID: accountID)
let ordered = snapshot.filter { $0.tag == tag }
+ snapshot.filter { $0.tag != tag }
var revoked = false
for revocation in ordered {
if revocation.bindingID == activeBindingID {
try await removeConfirmed(revocation)
continue
}
try await broker.revokeStale(bindingID: revocation.bindingID)
try await removeConfirmed(revocation)
revoked = true
}
return revoked
}
private func removeConfirmed(
@@ -0,0 +1,3 @@
struct CmxIrohPostRegistrationRevocationFailure: Error {
let underlying: any Error
}
@@ -10,6 +10,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
case routeContractVersion = "route_contract_version"
case deviceID = "deviceId"
case appInstanceID = "appInstanceId"
case clientNamespace
case tag
case platform
case displayName
@@ -27,6 +28,8 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
public let deviceID: String
/// Stable app-instance UUID for this installation and tag.
public let appInstanceID: String
/// Exact app namespace that owns this binding.
public let clientNamespace: String
/// Safe build or app-instance tag.
public let tag: String
/// Device role used by grant policy.
@@ -53,6 +56,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
public init(
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
displayName: String? = nil,
@@ -66,6 +70,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
) throws {
guard Self.isBrokerUUID(deviceID),
Self.isBrokerUUID(appInstanceID),
Self.isSafeToken(clientNamespace, maximum: 255),
Self.isSafeToken(tag, maximum: 64),
(try? CmxIrohPeerIdentity(endpointID: endpointID)) != nil,
(1...Int(Int32.max)).contains(identityGeneration),
@@ -89,6 +94,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
routeContractVersion = Self.currentRouteContractVersion
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.displayName = displayName
@@ -72,6 +72,29 @@ public struct CmxIrohRegistrationSigner: Sendable {
)
}
/// Signs one authenticated broker request with the registered endpoint key.
func signBrokerRequest(
bindingID: String,
method: String,
path: String,
timestamp: Int64,
body: Data
) throws -> String {
guard Self.isBrokerUUID(bindingID),
!method.isEmpty,
method.utf8.allSatisfy({ (65...90).contains($0) }),
!path.isEmpty,
path.utf8.allSatisfy({ $0 >= 0x21 && $0 <= 0x7e }),
timestamp > 0 else {
throw CmxIrohRegistrationError.invalidChallenge
}
let bodySHA256 = Self.hex(Data(SHA256.hash(data: body)))
let transcript = Data(
"cmux/iroh/binding-request/v1\n\(bindingID.lowercased())\n\(method)\n\(path)\n\(timestamp)\n\(bodySHA256)".utf8
)
return Self.base64URL(signingKey.sign(message: transcript).toBytes())
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -0,0 +1,16 @@
import Foundation
func cmxIrohIsSafeToken(
_ value: String,
maximumUTF8ByteCount: Int = 64
) -> Bool {
guard (1 ... maximumUTF8ByteCount).contains(value.utf8.count) else {
return false
}
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
@@ -3,14 +3,14 @@ public import Foundation
/// Minimal secure-storage boundary used by the Iroh identity repository.
public protocol CmxIrohSecureIdentityStoring: Sendable {
/// Loads the record for an opaque account scope.
func read(account: String) throws -> Data?
func read(account: String) async throws -> Data?
/// Replaces the record for an opaque account scope.
func write(_ data: Data, account: String) throws
func write(_ data: Data, account: String) async throws
/// Removes one opaque account scope.
func delete(account: String) throws
func delete(account: String) async throws
/// Removes every Iroh identity owned by this app installation.
func deleteAll() throws
func deleteAll() async throws
}
@@ -0,0 +1,4 @@
struct CmxIrohStaleBindingRevocationRequest: Encodable {
let bindingId: String
let intent = "revoke_stale"
}
@@ -1,6 +1,23 @@
public import CMUXMobileCore
public import Foundation
private func cmxIsSafeClientNamespace(_ value: String) -> Bool {
(1 ... 255).contains(value.utf8.count)
&& value.utf8.allSatisfy {
(48 ... 57).contains($0)
|| (65 ... 90).contains($0)
|| (97 ... 122).contains($0)
|| [45, 46, 58, 95].contains($0)
}
}
private func cmxIsSafeBrokerHeaderValue(_ value: String) -> Bool {
(1 ... 16 * 1_024).contains(value.utf8.count)
&& !value.unicodeScalars.contains(
where: { $0.value < 0x20 || $0.value == 0x7f }
)
}
/// One access + refresh credential pair captured from a single session snapshot.
///
/// Assembling a request from one snapshot prevents pairing a stale access token
@@ -269,12 +286,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
private let transport: any CmxIrohHTTPTransport
private let requestTimeout: TimeInterval
private let backpressureGate: CmxIrohBrokerBackpressureGate?
private let clientNamespace: String
private var bindingAuthorization: CmxIrohBindingRequestAuthorization?
private let discoveryScope: CmxConnectivityDiscoveryScope?
/// Creates a client that rejects cleartext non-loopback API origins.
public init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
clientNamespace: String,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
@@ -282,6 +303,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
try self.init(
baseURL: baseURL,
tokenSource: tokenSource,
clientNamespace: clientNamespace,
bindingAuthorization: bindingAuthorization,
discoveryScope: discoveryScope,
transport: CmxIrohURLSessionTransport(),
requestTimeout: requestTimeout,
@@ -293,18 +316,26 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
clientNamespace: String,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
transport: any CmxIrohHTTPTransport,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
) throws {
guard Self.isAllowedBaseURL(baseURL), requestTimeout > 0 else {
guard Self.isAllowedBaseURL(baseURL),
cmxIsSafeClientNamespace(clientNamespace),
bindingAuthorization?.clientNamespace == nil
|| bindingAuthorization?.clientNamespace == clientNamespace,
requestTimeout > 0 else {
throw CmxIrohTrustBrokerClientError.invalidBaseURL
}
self.baseURL = baseURL
self.tokenSource = tokenSource
self.transport = transport
self.requestTimeout = requestTimeout
self.clientNamespace = clientNamespace
self.bindingAuthorization = bindingAuthorization
self.discoveryScope = discoveryScope
switch backpressureMode {
case .automatic:
@@ -322,6 +353,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
)
}
/// Reports whether this client retains a signed binding request proof.
public func hasBindingAuthorization() async -> Bool {
bindingAuthorization != nil
}
/// Returns the binding ID represented by the retained request proof.
public func bindingAuthorizationID() async -> String? {
bindingAuthorization?.bindingID
}
public func issueChallenge(
_ request: CmxIrohChallengeRequest
) async throws -> CmxIrohChallengeResponse {
@@ -346,7 +387,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
prepared: CmxIrohPreparedRegistration,
signer: CmxIrohRegistrationSigner
) async throws -> CmxIrohRegistrationResponse {
try await withBackpressure(operation: .registration) {
let response: CmxIrohRegistrationResponse = try await withBackpressure(
operation: .registration
) {
let challenge: CmxIrohChallengeResponse = try await self.sendUngated(
path: "api/devices/iroh/challenge",
method: "POST",
@@ -355,8 +398,15 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
let request = try signer.sign(prepared: prepared, challenge: challenge)
return try await self.registerUngated(request)
}
bindingAuthorization = CmxIrohBindingRequestAuthorization(
bindingID: response.binding.bindingID,
clientNamespace: clientNamespace,
signer: signer
)
return response
}
/// Discovers account bindings visible to this client's exact build namespace.
public func discover() async throws -> CmxIrohDiscoveryResponse {
try await withBackpressure(operation: .discovery) {
if self.discoveryScope != nil {
@@ -488,6 +538,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
)
}
/// Revokes the caller's own binding.
public func revoke(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
@@ -500,6 +551,32 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
}
}
/// Revokes an older binding owned by this app namespace and physical device.
public func revokeStale(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
method: "DELETE",
body: CmxIrohStaleBindingRevocationRequest(bindingId: bindingID),
operation: .revocation
)
guard response.revoked, response.lanRendezvousRotated else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
}
/// Revokes one same-build Mac through the explicit account-management path.
public func forgetMac(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
method: "DELETE",
body: CmxIrohMacForgetRequest(bindingId: bindingID),
operation: .revocation
)
guard response.revoked, response.lanRendezvousRotated else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
}
private func registerUngated(
_ request: CmxIrohRegisterRequest
) async throws -> CmxIrohRegistrationResponse {
@@ -792,7 +869,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
) async throws -> Response {
let accessToken = credentials.accessToken
let refreshToken = credentials.refreshToken
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
guard cmxIsSafeBrokerHeaderValue(accessToken),
cmxIsSafeBrokerHeaderValue(refreshToken) else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
let pathURL = baseURL.appendingPathComponent(path)
@@ -811,6 +889,31 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
request.timeoutInterval = requestTimeout
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue(clientNamespace, forHTTPHeaderField: "X-Cmux-App-Namespace")
if let bindingAuthorization,
path != "api/devices/iroh/challenge",
path != "api/devices/iroh/register" {
let timestamp = Int64(Date().timeIntervalSince1970)
let signature = try bindingAuthorization.signer.signBrokerRequest(
bindingID: bindingAuthorization.bindingID,
method: method,
path: path,
timestamp: timestamp,
body: body ?? Data()
)
request.setValue(
bindingAuthorization.bindingID,
forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID"
)
request.setValue(
String(timestamp),
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
)
request.setValue(
signature,
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
)
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let body {
request.httpBody = body
@@ -869,11 +972,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
return scheme == "http" && ["127.0.0.1", "::1", "localhost"].contains(host)
}
private static func isSafeHeaderValue(_ value: String) -> Bool {
(1 ... 16 * 1_024).contains(value.utf8.count)
&& !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f })
}
private static func retryAfterSeconds(_ value: String?) -> Int? {
guard let value,
!value.isEmpty,
@@ -36,6 +36,7 @@ struct ClientRuntimeTestFixture {
accountID: "account-a",
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: binding.clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -135,6 +135,10 @@ private actor BackpressuredHostBrokerProbe:
throw BackpressuredHostBrokerProbeError.unexpectedCall
}
func revokeStale(bindingID _: String) async throws {
throw BackpressuredHostBrokerProbeError.unexpectedCall
}
func issueRelayBootstrap(
endpointID _: CmxIrohPeerIdentity
) async throws -> CmxIrohRelayBootstrapResponse {
@@ -75,6 +75,7 @@ struct CmxIrohBrokerCredentialPairTests {
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: tokenSource,
clientNamespace: "legacy",
transport: transport
)
@@ -21,6 +21,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -92,6 +93,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -164,6 +166,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -24,6 +24,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests {
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.identity,
@@ -26,6 +26,7 @@ extension CmxIrohClientRuntimeTests {
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,
@@ -3,6 +3,12 @@ import Foundation
import Testing
@testable import CmuxIrohTransport
private extension CmxIrohClientRuntime {
func installLocalBindingForSignOutTest(_ binding: CmxIrohBrokerBinding) {
localBinding = binding
}
}
@Suite
struct CmxIrohClientRuntimeTests {
@Test
@@ -19,6 +25,7 @@ struct CmxIrohClientRuntimeTests {
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,
@@ -149,6 +156,7 @@ struct CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: localBinding.appInstanceID,
clientNamespace: localBinding.clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -216,6 +224,49 @@ struct CmxIrohClientRuntimeTests {
await runtime.stop()
}
@Test
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
let fixture = try ClientRuntimeTestFixture()
let staleDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
revision: 1
)
let authoritativeDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
revision: 2
)
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestRevisionedClientBroker(
binding: fixture.binding,
discoveries: [authoritativeDiscovery],
relay: fixture.relayResponse(),
embeddedRegistrationDiscovery: staleDiscovery,
embeddedRegistrationDiscoveryIsComplete: true,
registrationRevision: 1
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.syncCount == 1)
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 2)
await runtime.stop()
}
@Test
func startupFetchesPaginatedDiscoveryWhenRegistrationAndSyncSnapshotsAreUnproven() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -269,6 +320,7 @@ struct CmxIrohClientRuntimeTests {
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,
@@ -594,6 +646,129 @@ struct CmxIrohClientRuntimeTests {
await runtime.stop()
}
@Test
func rateLimitedRegistrationDrainsPendingRevocationsBeforeDiscovery() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.observedRegistrations().count == 1)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(await broker.observedDiscoveryCount() == 1)
#expect(
try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty
)
await runtime.stop()
}
@Test
func rateLimitedRegistrationWithoutBindingProofDoesNotDrainOrDiscover() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
bindingAuthorizationAvailable: false,
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
await #expect(throws: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)) {
try await runtime.start()
}
#expect(await broker.observedDiscoveryCount() == 0)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
) == [pending])
}
@Test
func rateLimitedRegistrationDoesNotRevokeRetainedAuthorization() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: fixture.configuration.tag,
bindingID: fixture.binding.bindingID
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs().isEmpty)
#expect(await broker.observedDiscoveryCount() == 1)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty)
await runtime.stop()
}
@Test
func rateLimitedRegistrationRejectsMissingOrSubstitutedDiscoveryBinding() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -1005,6 +1180,10 @@ struct CmxIrohClientRuntimeTests {
#expect(preparation.bindingID == fixture.binding.bindingID)
#expect(preparation.wasPersisted)
#expect(
preparation.bindingAuthorization?.bindingID
== fixture.binding.bindingID
)
#expect(await recorder.observedLocalWipes() == [true])
#expect(await offlineStore.deleteAllCount() == 1)
#expect(await runtime.snapshot().state == .inactive)
@@ -1024,6 +1203,38 @@ struct CmxIrohClientRuntimeTests {
#expect(await runtime.snapshot().state == .inactive)
}
@Test
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
let fixture = try ClientRuntimeTestFixture()
let configuration = CmxIrohClientRuntimeConfiguration(
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: "dev.cmux.app.beta",
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.configuration.identity,
capabilities: fixture.configuration.capabilities,
managedRelayURLs: fixture.configuration.managedRelayURLs
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: []),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
await runtime.installLocalBindingForSignOutTest(fixture.binding)
let preparation = await runtime.deactivateForSignOut()
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
}
@Test
func suspendedSignOutPersistenceBlocksRestartUntilLocalTeardownCompletes() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -1112,7 +1323,7 @@ struct CmxIrohClientRuntimeTests {
}
@Test
func pendingRevocationFailureBlocksRegistrationAndOfflineFallback() async throws {
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
let fixture = try ClientRuntimeTestFixture()
let store = TestSecureCredentialStore()
let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store)
@@ -1145,7 +1356,8 @@ struct CmxIrohClientRuntimeTests {
try await runtime.start()
}
#expect(await broker.observedRegistrations().isEmpty)
#expect(await broker.observedRegistrations().count == 1)
#expect(await broker.observedDiscoveryCount() == 0)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(
try await pendingRevocations.pending(
@@ -1269,6 +1481,10 @@ private actor TestRevisionedClientBroker:
func revoke(bindingID _: String) {}
func revokeStale(bindingID _: String) {}
func forgetMac(bindingID _: String) {}
func waitUntilSyncCount(_ minimum: Int) async {
while syncCount < minimum {
await Task.yield()
@@ -140,7 +140,8 @@ struct CmxIrohCustomRelayLiveTests {
refreshToken: refreshToken
)
}
)
),
clientNamespace: "legacy"
)
let runTag = "relay-live-\(UUID().uuidString.lowercased())"
let firstSecretKey = try randomSecretKey()
@@ -153,6 +153,7 @@ struct CmxIrohCustomRelayRuntimeTests {
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.identity,
@@ -230,6 +231,7 @@ struct CmxIrohCustomRelayRuntimeTests {
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.identity,
@@ -4,16 +4,16 @@ import Testing
@Suite(.serialized)
struct CmxIrohDevelopmentFileStorageTests {
@Test func identityRoundTripsWithPrivateFilesystemPermissions() throws {
@Test func identityRoundTripsWithPrivateFilesystemPermissions() async throws {
let fixture = try Fixture()
defer { fixture.remove() }
let store = CmxIrohDevelopmentFileIdentityStore(
directory: fixture.directory
)
try store.write(Data([1, 2, 3]), account: "identity-scope")
try await store.write(Data([1, 2, 3]), account: "identity-scope")
#expect(try store.read(account: "identity-scope") == Data([1, 2, 3]))
#expect(try await store.read(account: "identity-scope") == Data([1, 2, 3]))
#expect(try fixture.permissions(at: fixture.directory) == 0o700)
#expect(try fixture.permissions(
at: fixture.directory.appendingPathComponent(
@@ -52,15 +52,15 @@ struct CmxIrohDevelopmentFileStorageTests {
#expect(FileManager.default.fileExists(atPath: unrelated.path))
}
@Test func traversalScopeIsRejected() throws {
@Test func traversalScopeIsRejected() async throws {
let fixture = try Fixture()
defer { fixture.remove() }
let store = CmxIrohDevelopmentFileIdentityStore(
directory: fixture.directory
)
#expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
try store.write(Data([1]), account: "../outside")
await #expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
try await store.write(Data([1]), account: "../outside")
}
}
@@ -5,6 +5,14 @@ import Testing
@testable import CmuxIrohTransport
private extension CmxIrohHostRuntime {
func installLocalBindingForSignOutTest(
_ binding: CmxIrohBrokerBindingMetadata
) {
localBinding = binding
}
}
extension CmxIrohHostRuntimeTests {
@Test
func emptyPublicHintsRenewRegistrationBeforePrivatePortFreshnessExpires() async throws {
@@ -353,10 +361,45 @@ extension CmxIrohHostRuntimeTests {
await store.resumeSuspendedWrite()
let preparation = await signOut.value
#expect(preparation.wasPersisted)
#expect(
preparation.bindingAuthorization?.bindingID
== fixture.binding.bindingID
)
#expect(await ordering.values() == ["true:true"])
#expect(await runtime.snapshot().state == .inactive)
}
@Test
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
let fixture = try HostRuntimeFixture()
let legacyBinding = try CmxIrohBrokerBindingMetadata(
bindingID: fixture.binding.bindingID,
deviceID: fixture.binding.deviceID,
appInstanceID: fixture.binding.appInstanceID,
clientNamespace: "legacy",
tag: fixture.binding.tag,
platform: fixture.binding.platform,
endpointID: fixture.binding.endpointID,
identityGeneration: fixture.binding.identityGeneration,
pathHints: fixture.binding.pathHints
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: []),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() }
)
await runtime.installLocalBindingForSignOutTest(legacyBinding)
let preparation = await runtime.deactivateForSignOut()
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
}
@Test
func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws {
let fixture = try HostRuntimeFixture()
@@ -87,6 +87,54 @@ extension CmxIrohHostRuntimeTests {
await runtime.stop()
}
@Test
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
let fixture = try HostRuntimeFixture()
let staleDiscovery = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
lanGeneration: 1,
revision: 1
)
let authoritativeDiscovery = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
lanGeneration: 2,
revision: 2
)
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: authoritativeDiscovery,
embeddedRegistrationDiscovery: staleDiscovery,
embeddedRegistrationDiscoveryIsComplete: true,
registrationRevision: 1
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(await broker.observedDiscoveryCount() == 1)
#expect(await runtime.connectivityEngine?.snapshot().routeRevision == 2)
#expect(await runtime.lanAdvertisementContext()?.rendezvous.generation == 2)
await runtime.stop()
}
@Test
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
let fixture = try HostRuntimeFixture()
@@ -231,4 +231,6 @@ private actor TestRevisionedHostBroker:
}
func revoke(bindingID _: String) {}
func revokeStale(bindingID _: String) {}
}
@@ -1,6 +1,7 @@
import CMUXMobileCore
import CryptoKit
import Foundation
import Testing
@testable import CmuxIrohTransport
struct HostRuntimeFixture {
@@ -9,6 +10,7 @@ struct HostRuntimeFixture {
let binding: CmxIrohBrokerBinding
let discovery: CmxIrohDiscoveryResponse
let managedRelays: Set<String>
let clientNamespace: CmxIrohMacBundleNamespace
let configuration: CmxIrohHostRuntimeConfiguration
init(
@@ -27,6 +29,11 @@ struct HostRuntimeFixture {
.joined()
)
managedRelays = Set(Self.relayURLs)
clientNamespace = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.tests"
)
)
binding = try Self.binding(
endpointID: endpointID.endpointID,
lastSeenAt: now,
@@ -41,6 +48,7 @@ struct HostRuntimeFixture {
accountID: "account-a",
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -60,6 +68,7 @@ struct HostRuntimeFixture {
accountID: configuration.accountID,
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -178,6 +187,7 @@ struct HostRuntimeFixture {
"binding_id": bindingID,
"device_id": deviceID,
"app_instance_id": "123e4567-e89b-42d3-a456-426614174012",
"client_namespace": "mac:com.cmuxterm.tests",
"tag": "cmux-ios-v0",
"platform": "mac",
"display_name": "Test Mac",
@@ -297,7 +297,7 @@ struct CmxIrohHostRuntimeTests {
}
@Test
func pendingRevocationFailureBlocksHostRegistrationAndCachedFallback() async throws {
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
let fixture = try HostRuntimeFixture()
let pendingRevocations = CmxIrohPendingRevocationOutbox(
secureStore: TestSecureCredentialStore()
@@ -327,15 +327,50 @@ struct CmxIrohHostRuntimeTests {
try await runtime.start()
}
#expect(await broker.observedRegistrationCount() == 0)
#expect(await broker.observedRegistrationCount() == 1)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(
try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
) == [pending]
) == [pending]
)
}
@Test
func registrationReconcilesPendingBindingWithoutRevokingFreshBinding() async throws {
let fixture = try HostRuntimeFixture()
let pendingRevocations = CmxIrohPendingRevocationOutbox(
secureStore: TestSecureCredentialStore()
)
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: fixture.configuration.tag,
bindingID: fixture.binding.bindingID
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(
endpoints: [TestIrohEndpoint(identity: fixture.endpointID)]
),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs().isEmpty)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty)
await runtime.stop()
}
}
actor TestIrohHostBroker: CmxIrohHostBrokerServing {
@@ -489,6 +524,10 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
if let revokeError { throw revokeError }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func observedRegistrationCount() -> Int { registrationCount }
func observedPreflightOperations() -> [CmxIrohBrokerOperation] {
preflightOperations
@@ -14,7 +14,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(first == second)
#expect(first.generation == 1)
#expect(harness.secure.deleteAllCount == 1)
#expect(await harness.secure.deleteAllCount() == 1)
}
@Test("account switches rotate and do not resurrect prior keys")
@@ -28,7 +28,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(accountA.secretKey != accountB.secretKey)
#expect(accountA.secretKey != accountAAgain.secretKey)
#expect(harness.secure.deleteAllCount == 3)
#expect(await harness.secure.deleteAllCount() == 3)
}
@Test("missing install marker rejects a key that survived uninstall")
@@ -42,7 +42,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(original.secretKey != afterReinstall.secretKey)
#expect(afterReinstall.generation == 1)
#expect(harness.secure.deleteAllCount == 2)
#expect(await harness.secure.deleteAllCount() == 2)
}
@Test("explicit rotation increments generation without changing scope")
@@ -84,6 +84,64 @@ struct CmxIrohIdentityRepositoryTests {
try await repository.identity(accountID: "user", appInstanceID: "")
}
}
@Test("concurrent identity loads share one persisted identity")
func concurrentIdentityLoadsAreSerialized() async throws {
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = TestControllableSecureIdentityStore()
let entropy = TestIdentityEntropy()
let repository = CmxIrohIdentityRepository(
secureStore: store,
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
randomBytes: { entropy.nextBytes() },
marker: { entropy.nextMarker() }
)
await store.suspendNextWrite()
let first = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
await store.waitUntilWriteIsSuspended()
let second = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
try await ContinuousClock().sleep(for: .milliseconds(50))
await store.resumeSuspendedWrite()
let firstIdentity = try await first.value
let secondIdentity = try await second.value
#expect(firstIdentity == secondIdentity)
#expect(await store.recordCount() == 1)
}
@Test("deactivation waits for an in-flight identity write")
func deactivationFencesInFlightIdentityWrite() async throws {
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = TestControllableSecureIdentityStore()
let repository = CmxIrohIdentityRepository(
secureStore: store,
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
randomBytes: { Data(repeating: 7, count: 32) },
marker: { "install-marker" }
)
await store.suspendNextWrite()
let identity = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
await store.waitUntilWriteIsSuspended()
let deactivate = Task { try await repository.deactivate() }
try await ContinuousClock().sleep(for: .milliseconds(50))
await store.resumeSuspendedWrite()
_ = try await identity.value
try await deactivate.value
#expect(await store.recordCount() == 0)
}
}
private final class IdentityHarness: @unchecked Sendable {
@@ -101,32 +159,25 @@ private final class IdentityHarness: @unchecked Sendable {
}
}
private final class TestSecureIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
private let lock = NSLock()
private actor TestSecureIdentityStore: CmxIrohSecureIdentityStoring {
private var records: [String: Data] = [:]
private var storedDeleteAllCount = 0
var deleteAllCount: Int {
lock.withLock { storedDeleteAllCount }
}
func deleteAllCount() -> Int { storedDeleteAllCount }
func read(account: String) -> Data? {
lock.withLock { records[account] }
}
func read(account: String) -> Data? { records[account] }
func write(_ data: Data, account: String) {
lock.withLock { records[account] = data }
records[account] = data
}
func delete(account: String) {
_ = lock.withLock { records.removeValue(forKey: account) }
records.removeValue(forKey: account)
}
func deleteAll() {
lock.withLock {
records.removeAll()
storedDeleteAllCount += 1
}
records.removeAll()
storedDeleteAllCount += 1
}
}
@@ -0,0 +1,32 @@
import Testing
@testable import CmuxIrohTransport
@Suite
struct CmxIrohMacBundleNamespaceTests {
@Test func exactBundlesRemainDistinctEvenWhenTheirTagsMatch() throws {
let stable = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app"
)
)
let staging = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app.staging"
)
)
#expect(stable.rawValue == "mac:com.cmuxterm.app")
#expect(staging.rawValue == "mac:com.cmuxterm.app.staging")
#expect(stable != staging)
}
@Test func invalidOrMissingBundleIdentityFailsClosed() {
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: nil) == nil)
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: "") == nil)
#expect(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app:other"
) == nil
)
}
}
@@ -94,6 +94,26 @@ struct CmxIrohPendingRevocationOutboxTests {
)
}
@Test("reconciliation removes a re-registered binding without revoking it")
func reconciliationDoesNotRevokeActiveBinding() async throws {
let store = TestSecureCredentialStore()
let outbox = CmxIrohPendingRevocationOutbox(secureStore: store)
let pending = try revocation()
try await outbox.enqueue(pending)
let broker = PendingRevocationBroker()
let revoked = try await outbox.reconcilePending(
accountID: accountID,
beforeRegisteringTag: tag,
activeBindingID: pending.bindingID,
using: broker
)
#expect(!revoked)
#expect(await broker.revokedBindingIDs().isEmpty)
#expect(try await outbox.pending(accountID: accountID).isEmpty)
}
private func revocation() throws -> CmxIrohPendingRevocation {
try CmxIrohPendingRevocation(
accountID: accountID,
@@ -116,5 +136,9 @@ private actor PendingRevocationBroker: CmxIrohBindingRevoking {
if let error { throw error }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func revokedBindingIDs() -> [String] { bindingIDs }
}
@@ -29,6 +29,7 @@ struct CmxIrohRegistrationSignerTests {
let payload = try CmxIrohRegistrationPayload(
deviceID: "123e4567-e89b-12d3-a456-426614174000",
appInstanceID: "123e4567-e89b-12d3-a456-426614174001",
clientNamespace: "dev.cmux.app.internal",
tag: "stable",
platform: .ios,
displayName: "Phone",
@@ -71,6 +72,11 @@ struct CmxIrohRegistrationSignerTests {
)
#expect(payloadObject["endpointId"] as? String == endpointID)
#expect(payloadObject["endpointID"] == nil)
#expect(payloadObject["clientNamespace"] as? String == "dev.cmux.app.internal")
#expect(
prepared.challengeRequest.clientNamespace
== "dev.cmux.app.internal"
)
let pathHints = try #require(payloadObject["pathHints"] as? [[String: Any]])
let encodedHint = try #require(pathHints.first)
#expect(encodedHint["observed_at"] is String)
@@ -126,6 +126,7 @@ struct CmxIrohRelayPolicyBrokerTests {
CmxIrohBrokerCredentials(accessToken: "access", refreshToken: "refresh")
}
),
clientNamespace: "legacy",
transport: transport
)
}
@@ -10,8 +10,8 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
let lowercaseUUID = uppercaseUUID.lowercased()
let uuidHost = hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueHost = hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
let uuidHost = try hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueHost = try hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
let uuidClient = clientConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueClient = clientConfiguration(deviceID: "Legacy-iOS-ID", fixture: fixture)
@@ -24,11 +24,17 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
private func hostConfiguration(
deviceID: String,
fixture: HostRuntimeFixture
) -> CmxIrohHostRuntimeConfiguration {
CmxIrohHostRuntimeConfiguration(
) throws -> CmxIrohHostRuntimeConfiguration {
let clientNamespace = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.tests"
)
)
return CmxIrohHostRuntimeConfiguration(
accountID: "account-a",
deviceID: deviceID,
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
clientNamespace: clientNamespace,
tag: "test",
displayName: nil,
identity: fixture.identity,
@@ -46,6 +52,7 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
accountID: "account-a",
deviceID: deviceID,
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
clientNamespace: fixture.clientNamespace.rawValue,
tag: "test",
displayName: nil,
identity: fixture.identity,
@@ -92,6 +92,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
refreshToken: "stale-refresh"
)
}),
clientNamespace: "legacy",
transport: transport
)
@@ -169,6 +170,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
clientNamespace: "legacy",
transport: transport
)
@@ -200,6 +202,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
clientNamespace: "legacy",
transport: transport
)
@@ -248,6 +251,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
await recorder.recover(rejected)
}
),
clientNamespace: "legacy",
transport: transport
)
}
@@ -130,6 +130,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { nil }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CmxIrohTrustBrokerClientError.missingAuthentication) {
@@ -149,6 +150,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { throw CancellationError() }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CancellationError.self) {
@@ -172,6 +174,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { throw TransientTokenReadError() }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) {
@@ -186,6 +189,7 @@ extension CmxIrohTrustBrokerClientTests {
_ = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "http://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: RecordingBrokerTransport(responses: [])
)
}
@@ -232,6 +236,7 @@ extension CmxIrohTrustBrokerClientTests {
let client = try CmxIrohTrustBrokerClient(
baseURL: try #require(URL(string: "https://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: CmxIrohURLSessionTransport(configuration: configuration),
requestTimeout: 0.1
)
@@ -248,6 +253,7 @@ extension CmxIrohTrustBrokerClientTests {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: transport
)
}
@@ -72,6 +72,86 @@ 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),
])
let client = try makeClient(transport: transport)
let signer = try registrationSigner()
let prepared = try signer.prepare(payload: registrationPayload())
_ = try await client.register(prepared: prepared, signer: signer)
_ = try await client.discover()
let requests = await transport.requests()
let discovery = try #require(requests.last)
#expect(
discovery.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
== "123e4567-e89b-42d3-a456-426614174010"
)
#expect(
Int64(
discovery.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
) ?? ""
) != nil
)
#expect(
discovery.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
)?.count == 86
)
#expect(
requests.dropLast().allSatisfy {
$0.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
) == nil
}
)
}
@Test
func freshManagementClientUsesRetainedBindingProof() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 200, body: Self.discoveryResponse),
])
let authorization = try CmxIrohBindingRequestAuthorization(
bindingID: Self.bindingID,
clientNamespace: "dev.cmux.app.internal",
identity: identityMaterial(),
endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID)
)
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.tokenSource,
clientNamespace: "dev.cmux.app.internal",
bindingAuthorization: authorization,
transport: transport
)
_ = try await client.discover()
let request = try #require(await transport.requests().first)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
== "dev.cmux.app.internal"
)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
== Self.bindingID
)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature")?
.count == 86
)
}
@Test
func registrationDecodesEmbeddedAuthoritativeDiscovery() async throws {
var responseObject = try #require(
@@ -468,6 +548,51 @@ struct CmxIrohTrustBrokerClientTests {
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == bindingID)
#expect(object["intent"] == nil)
}
@Test
func forgetMacUsesExplicitAccountManagementIntent() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
),
])
let client = try makeClient(transport: transport)
try await client.forgetMac(bindingID: Self.bindingID)
let captured = try #require(await transport.requests().first)
#expect(captured.url?.path == "/api/devices/iroh")
#expect(captured.httpMethod == "DELETE")
let body = try #require(captured.httpBody)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == Self.bindingID)
#expect(object["intent"] as? String == "forget_mac")
}
@Test
func revokeStaleUsesExplicitStaleCleanupIntent() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
),
])
let client = try makeClient(transport: transport)
try await client.revokeStale(bindingID: Self.bindingID)
let captured = try #require(await transport.requests().first)
let body = try #require(captured.httpBody)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == Self.bindingID)
#expect(object["intent"] as? String == "revoke_stale")
}
@Test
@@ -1032,6 +1157,7 @@ struct CmxIrohTrustBrokerClientTests {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.tokenSource,
clientNamespace: "dev.cmux.app.internal",
discoveryScope: discoveryScope,
transport: transport
)
@@ -1060,12 +1186,18 @@ struct CmxIrohTrustBrokerClientTests {
}
private func registrationSigner() throws -> CmxIrohRegistrationSigner {
try CmxIrohRegistrationSigner(
identity: identityMaterial(),
endpointID: Self.endpointID
)
}
private func identityMaterial() throws -> CmxIrohIdentityMaterial {
let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init)))
let material = try CmxIrohIdentityMaterial(
return try CmxIrohIdentityMaterial(
secretKey: secret,
generation: 1
)
return try CmxIrohRegistrationSigner(identity: material, endpointID: Self.endpointID)
}
private func registrationPayload() throws -> CmxIrohRegistrationPayload {
@@ -0,0 +1,55 @@
import Foundation
@testable import CmuxIrohTransport
actor TestControllableSecureIdentityStore: CmxIrohSecureIdentityStoring {
private var records: [String: Data] = [:]
private var shouldSuspendNextWrite = false
private var suspendedWrite: CheckedContinuation<Void, Never>?
private var writeSuspensionWaiters: [CheckedContinuation<Void, Never>] = []
func read(account: String) -> Data? {
records[account]
}
func write(_ data: Data, account: String) async {
if shouldSuspendNextWrite {
shouldSuspendNextWrite = false
await withCheckedContinuation { continuation in
suspendedWrite = continuation
let waiters = writeSuspensionWaiters
writeSuspensionWaiters.removeAll(keepingCapacity: false)
for waiter in waiters { waiter.resume() }
}
}
records[account] = data
}
func delete(account: String) {
records.removeValue(forKey: account)
}
func deleteAll() {
records.removeAll(keepingCapacity: false)
}
func suspendNextWrite() {
shouldSuspendNextWrite = true
}
func waitUntilWriteIsSuspended() async {
guard suspendedWrite == nil else { return }
await withCheckedContinuation { continuation in
writeSuspensionWaiters.append(continuation)
}
}
func resumeSuspendedWrite() {
let continuation = suspendedWrite
suspendedWrite = nil
continuation?.resume()
}
func recordCount() -> Int {
records.count
}
}
@@ -7,6 +7,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
private let discoveryResponse: CmxIrohDiscoveryResponse
private let relayResponse: CmxIrohRelayTokenResponse
private let pairGrantResponse: CmxIrohPairGrantResponse?
private let bindingAuthorizationAvailable: Bool
private let revokeError: (any Error)?
private let registrationHook: (@Sendable (_ count: Int) async -> Void)?
private let discoveryHook: (@Sendable (_ count: Int) async -> Void)?
@@ -29,6 +30,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
discovery: CmxIrohDiscoveryResponse,
relay: CmxIrohRelayTokenResponse,
pairGrant: CmxIrohPairGrantResponse? = nil,
bindingAuthorizationAvailable: Bool = true,
issueRelayAtRegistration: Bool = true,
registrationError: (any Error)? = nil,
discoveryErrorsByCount: [Int: any Error] = [:],
@@ -43,6 +45,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
discoveryResponse = discovery
relayResponse = relay
pairGrantResponse = pairGrant
self.bindingAuthorizationAvailable = bindingAuthorizationAvailable
self.revokeError = revokeError
self.registrationError = registrationError
self.discoveryErrorsByCount = discoveryErrorsByCount
@@ -50,6 +53,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
self.discoveryHook = discoveryHook
}
func hasBindingAuthorization() async -> Bool {
bindingAuthorizationAvailable
}
func bindingAuthorizationID() async -> String? {
bindingAuthorizationAvailable ? registration.binding.bindingID : nil
}
func register(
prepared: CmxIrohPreparedRegistration,
signer _: CmxIrohRegistrationSigner
@@ -109,6 +120,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
if let revokeError { throw revokeError }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func forgetMac(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func observedRegistrations() -> [CmxIrohPreparedRegistration] {
preparedRegistrations
}
@@ -26,10 +26,10 @@ public struct CmxAttachTicketInput {
// cmux-ios-dev for development); cross-channel pairing still works when
// the user scans from inside the app. The emitter picks the matching
// scheme so the *system camera* routes each channel's QR to its build.
if CmxPairingURLScheme.isPairingScheme(url.scheme), url.host == "pair" {
if CmxPairingURLScheme(rawValue: url.scheme) != nil, url.host == "pair" {
return try ticket(from: MobileSyncPairingPayload.decodeURL(url))
}
guard CmxPairingURLScheme.isPairingScheme(url.scheme),
guard CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "attach",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw MobileSyncPairingPayloadError.invalidURL
@@ -133,17 +133,44 @@ final class SimulatorDeviceIdentityStore: DeviceIdentityStoring, @unchecked Send
struct KeychainDeviceIdentityStore: DeviceIdentityStoring {
private let service: String
private let account: String
private let accessGroup: String?
private let legacyService: String?
init(
service: String = "com.cmuxterm.deviceRegistry.iosDeviceID.v1",
account: String = "default"
account: String = "default",
accessGroup: String? = nil,
legacyService: String? = nil
) {
self.service = service
self.account = account
self.accessGroup = accessGroup
self.legacyService = legacyService == service ? nil : legacyService
}
func read() -> DeviceIdentityReadResult {
var query = baseQuery()
let current = read(service: service)
guard current == .absent, let legacyService else {
return current
}
switch read(service: legacyService) {
case .found(let legacy):
guard let winner = createOrAdopt(legacy) else {
return .unavailable
}
_ = SecItemDelete(
baseQuery(service: legacyService) as CFDictionary
)
return .found(winner)
case .absent:
return .absent
case .unavailable:
return .unavailable
}
}
private func read(service: String) -> DeviceIdentityReadResult {
var query = baseQuery(service: service)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -176,7 +203,7 @@ struct KeychainDeviceIdentityStore: DeviceIdentityStoring {
func createOrAdopt(_ desired: String) -> String? {
guard let data = desired.data(using: .utf8) else { return nil }
var insert = baseQuery()
var insert = baseQuery(service: service)
insert[kSecValueData as String] = data
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(insert as CFDictionary, nil)
@@ -187,7 +214,7 @@ struct KeychainDeviceIdentityStore: DeviceIdentityStoring {
case errSecDuplicateItem:
// An item already exists. Resolve what it holds so racing callers
// converge on one id and a corrupt item cannot wedge minting forever.
switch read() {
switch read(service: service) {
case .found(let existing):
// A concurrent writer already persisted a usable id. Adopt it so
// every racing caller converges on one id, never overwriting the
@@ -208,7 +235,10 @@ struct KeychainDeviceIdentityStore: DeviceIdentityStoring {
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary)
let updateStatus = SecItemUpdate(
baseQuery(service: service) as CFDictionary,
attributes as CFDictionary
)
return updateStatus == errSecSuccess ? desired : nil
case .unavailable:
// The item exists but the Keychain is locked before first unlock.
@@ -225,13 +255,17 @@ struct KeychainDeviceIdentityStore: DeviceIdentityStoring {
}
}
private func baseQuery() -> [String: Any] {
[
private func baseQuery(service: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: false,
kSecUseDataProtectionKeychain as String: true,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
}
@@ -137,7 +137,6 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
evidence: evidence
)
}
/// Testable core of ``deviceID(defaults:)`` with an injectable identity store.
static func deviceID(
store: any DeviceIdentityStoring,
@@ -223,7 +222,6 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
KeychainDeviceIdentityStore()
#endif
}
/// Testable core of ``durableDeviceID(defaults:)`` with an injectable store.
static func durableDeviceID(
store: any DeviceIdentityStoring,
@@ -755,6 +753,68 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing {
}
}
/// Provides Keychain-scoped device identities for one exact iOS app namespace.
public extension MobileIOSAppNamespace {
/// Returns this app bundle's stable device-registry identity.
///
/// The exact bundle namespace selects a device-only Keychain service. The
/// best-effort registry read may return a process-stable ephemeral value
/// when protected storage is unavailable, but that value is never used for
/// an Iroh binding.
///
/// - Parameters:
/// - keychainAccessGroup: This app's exact signed Keychain access group.
/// - defaults: Legacy mirror storage, injectable for tests.
func deviceRegistryDeviceID(
keychainAccessGroup: String?,
defaults: UserDefaults = .standard,
deviceWitness: String? = nil,
evidence: any SameDeviceEvidenceProbing = IrohEndpointIdentityEvidenceProbe()
) -> String {
DeviceRegistryService.deviceID(
store: KeychainDeviceIdentityStore(
service: keychainService(
base: "com.cmuxterm.deviceRegistry.iosDeviceID.v1"
),
accessGroup: keychainAccessGroup,
legacyService: "com.cmuxterm.deviceRegistry.iosDeviceID.v1"
),
defaults: defaults,
deviceWitness: deviceWitness,
evidence: evidence
)
}
/// Returns this app bundle's durable Iroh device identity.
///
/// A `nil` result means protected storage is unavailable or a fresh value
/// could not be persisted. Callers must defer broker registration instead
/// of substituting an ephemeral identity.
///
/// - Parameters:
/// - keychainAccessGroup: This app's exact signed Keychain access group.
/// - defaults: Legacy mirror storage, injectable for tests.
func durableDeviceRegistryDeviceID(
keychainAccessGroup: String?,
defaults: UserDefaults = .standard,
deviceWitness: String? = nil,
evidence: any SameDeviceEvidenceProbing = IrohEndpointIdentityEvidenceProbe()
) -> String? {
DeviceRegistryService.durableDeviceID(
store: KeychainDeviceIdentityStore(
service: keychainService(
base: "com.cmuxterm.deviceRegistry.iosDeviceID.v1"
),
accessGroup: keychainAccessGroup,
legacyService: "com.cmuxterm.deviceRegistry.iosDeviceID.v1"
),
defaults: defaults,
deviceWitness: deviceWitness,
evidence: evidence
)
}
}
/// Exact, immutable authority lookup for one authenticated registry generation.
/// Building it once keeps a reconnect pass linear even with many saved Macs.
struct DeviceRegistryRouteIndex: Sendable {
@@ -14,9 +14,8 @@ public protocol MobileIrohMacForgetting: Sendable {
/// - Parameters:
/// - macDeviceID: The Mac device id to forget. Canonicalized before
/// matching, so a raw id or a pairing-id form both resolve.
/// - instanceTag: When non-nil, only the matching tagged app instance is
/// revoked; sibling instances on the same Mac stay bound. When nil,
/// every instance sharing the device id is revoked.
/// - instanceTag: When non-nil, it must match the running app's build
/// lane. A nil value still revokes only the running build's Mac binding.
/// - expectedAccountID: The account that owns the row being forgotten,
/// captured by the caller when it read the row. The implementation must
/// revoke only while the live authenticated session still belongs to this
@@ -62,15 +62,11 @@ struct MobilePairingAccountPreflight: Sendable {
}
private var macDeclaresRelease: Bool {
scannedScheme.map {
CmxPairingURLScheme.release.caseInsensitiveCompare($0) == .orderedSame
} ?? false
CmxPairingURLScheme(rawValue: scannedScheme)?.isRelease == true
}
private var macDeclaresDevelopment: Bool {
scannedScheme.map {
CmxPairingURLScheme.development.caseInsensitiveCompare($0) == .orderedSame
} ?? false
CmxPairingURLScheme(rawValue: scannedScheme)?.isDevelopment == true
}
private func normalizedEmail(_ value: String?) -> String? {
@@ -5,7 +5,7 @@ internal import Foundation
extension MobileShellComposite {
static func normalizedPairingURL(_ rawValue: String) -> String {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard CmxPairingURLScheme.hasPairingScheme(trimmed) else {
guard CmxPairingURLScheme(urlString: trimmed) != nil else {
return trimmed
}
let scalars = trimmed.unicodeScalars.filter {
@@ -2380,7 +2380,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
guard !trimmedCode.isEmpty else {
return
}
if CmxPairingURLScheme.hasPairingScheme(trimmedCode) {
if CmxPairingURLScheme(urlString: trimmedCode) != nil {
return
}
let attemptID = beginPairingAttempt()
@@ -2404,7 +2404,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
guard !trimmedCode.isEmpty else {
return
}
if CmxPairingURLScheme.hasPairingScheme(trimmedCode) {
if CmxPairingURLScheme(urlString: trimmedCode) != nil {
// The pairing input field is an explicit in-app code entry (scan
// or paste), the act that authorizes a compatibility Tailscale dial.
await connectPairingURLResult(trimmedCode, userEnteredPairingCode: true)
@@ -1,4 +1,5 @@
public import Foundation
internal import CmuxMobilePairedMac
import os
private let pairedMacBackupLog = Logger(subsystem: "com.cmuxterm.app", category: "PairedMacBackup")
@@ -12,8 +13,11 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
private let tokenSource: PresenceTokenSource
private let teamIDProvider: @Sendable () async -> String?
private let clientScopeProvider: @Sendable () async -> String?
private let legacyClientScopeProvider: (@Sendable () async -> String?)?
private let session: URLSession
private let requestTimeout: TimeInterval
private let migrationDefaults: UserDefaults
private let migrationClock: @Sendable () -> Date
/// Create a backup client for one presence service base URL and token source.
public init(
@@ -21,18 +25,33 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
tokenSource: PresenceTokenSource,
teamIDProvider: @escaping @Sendable () async -> String? = { nil },
clientScopeProvider: @escaping @Sendable () async -> String? = { nil },
legacyClientScopeProvider: (@Sendable () async -> String?)? = nil,
session: sending URLSession = .shared,
requestTimeout: TimeInterval = 5
requestTimeout: TimeInterval = 5,
migrationDefaults: UserDefaults = .standard,
migrationClock: @escaping @Sendable () -> Date = Date.init
) {
self.serviceBaseURL = serviceBaseURL
self.tokenSource = tokenSource
self.teamIDProvider = teamIDProvider
self.clientScopeProvider = clientScopeProvider
self.legacyClientScopeProvider = legacyClientScopeProvider
self.session = session
self.requestTimeout = requestTimeout
self.migrationDefaults = migrationDefaults
self.migrationClock = migrationClock
}
private static let path = "/v1/sync/paired-macs"
private static let maximumMigrationUploadOperations = 200
// A fetch performs at most one conditional write. If more legacy state
// remains, the next fetch resumes from the current snapshot.
private static let maximumMigrationOperationsPerFetch =
maximumMigrationUploadOperations
// Legacy clients can continue writing the old collection after this client
// finishes its bounded migration. Recheck periodically so the normal fetch
// path stays cheap while late legacy writes remain eventually visible.
private static let legacyMigrationRecheckInterval: TimeInterval = 60
/// Build the paired-Mac backup endpoint from a service base URL. The base
/// may include or omit a trailing slash, and may include a deployment base
@@ -86,13 +105,15 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
ops: [PairedMacBackupOp],
teamID: String?,
expectedUserID: String?,
routeDisclosureDate: Date
routeDisclosureDate: Date,
expectedRevision: Int? = nil
) async -> Bool {
await uploadReportingResolvedTeam(
ops: ops,
teamID: teamID,
expectedUserID: expectedUserID,
routeDisclosureDate: routeDisclosureDate
routeDisclosureDate: routeDisclosureDate,
expectedRevision: expectedRevision
).succeeded
}
@@ -120,17 +141,21 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
ops: [PairedMacBackupOp],
teamID: String?,
expectedUserID: String?,
routeDisclosureDate: Date
routeDisclosureDate: Date,
expectedRevision: Int? = nil
) async -> PairedMacBackupUploadOutcome {
guard !ops.isEmpty else {
return PairedMacBackupUploadOutcome(succeeded: true, resolvedTeamID: nil)
}
let body = PairedMacBackupRequestBody(ops: ops.map {
PairedMacBackupOpWire(
op: $0,
routeDisclosureDate: routeDisclosureDate
)
})
let body = PairedMacBackupRequestBody(
ops: ops.map {
PairedMacBackupOpWire(
op: $0,
routeDisclosureDate: routeDisclosureDate
)
},
expectedRevision: expectedRevision
)
guard let data = try? JSONEncoder().encode(body),
let request = await makeRequest(
method: "POST",
@@ -182,11 +207,121 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
/// Fetch live records and tombstones only if auth still belongs to the captured account.
public func fetchSnapshot(teamID: String?, expectedUserID: String?) async -> PairedMacBackupSnapshot? {
// Capture the account once so every read, write, and reconciliation
// request belongs to the same auth generation.
let capturedUserID: String?
if let expectedUserID {
capturedUserID = expectedUserID
} else {
capturedUserID = await tokenSource.currentUserID()
}
guard let primaryResponse = await fetchSnapshotResponse(
teamID: teamID,
expectedUserID: capturedUserID,
scope: .current
) else { return nil }
let primary = primaryResponse.snapshot
guard let legacyClientScopeProvider else {
return primary
}
let legacyScope = await legacyClientScopeProvider()
let currentScope = await clientScope()
guard legacyScope != currentScope else {
return primary
}
let requestedTeamID = teamID?
.trimmingCharacters(in: .whitespacesAndNewlines)
let migrationTeamID = primary.resolvedTeamID
?? ((requestedTeamID?.isEmpty ?? true) ? nil : requestedTeamID)
guard migrationTeamID != nil else {
pairedMacBackupLog.warning(
"paired-mac legacy migration requires a server-verified team"
)
return primary.requiringMigrationRetry()
}
let migrationScope = PairedMacBackupMigrationScope(
currentScope: currentScope,
legacyScope: legacyScope,
teamID: migrationTeamID,
expectedUserID: capturedUserID
)
let migrationKey = migrationScope.key
if let migrationKey,
let lastReconciled = migrationDefaults.object(forKey: migrationKey) as? Date {
let elapsed = migrationClock().timeIntervalSince(lastReconciled)
if elapsed >= 0, elapsed < Self.legacyMigrationRecheckInterval {
return primary
}
}
guard let legacyResponse = await fetchSnapshotResponse(
teamID: migrationTeamID,
expectedUserID: capturedUserID,
scope: .explicit(legacyScope)
) else { return primary.requiringMigrationRetry() }
let legacy = legacyResponse.snapshot
let migration = PairedMacBackupMigrationPlan(
primary: primary,
legacy: legacy
)
let migrationOps = migration.operations
if migrationOps.isEmpty {
if let migrationKey {
migrationDefaults.set(migrationClock(), forKey: migrationKey)
}
return primary
}
let migrationBatch = Array(
migrationOps.prefix(Self.maximumMigrationOperationsPerFetch)
)
if migrationBatch.count < migrationOps.count {
pairedMacBackupLog.warning(
"paired-mac legacy migration deferred after \(Self.maximumMigrationOperationsPerFetch) operations"
)
}
guard let expectedRevision = primaryResponse.revision else {
pairedMacBackupLog.warning(
"paired-mac legacy migration requires server revision support"
)
return primary.requiringMigrationRetry()
}
guard await upload(
ops: migrationBatch,
teamID: migrationTeamID,
expectedUserID: capturedUserID,
routeDisclosureDate: Date(),
expectedRevision: expectedRevision
) else {
return primary.requiringMigrationRetry()
}
guard let refreshedResponse = await fetchSnapshotResponse(
teamID: migrationTeamID,
expectedUserID: capturedUserID,
scope: .current
) else {
return primary.requiringMigrationRetry()
}
let refreshed = refreshedResponse.snapshot
if migration.isFullyReconciled(by: refreshed) {
pairedMacBackupLog.debug("paired-mac legacy migration reconciled")
if let migrationKey {
migrationDefaults.set(migrationClock(), forKey: migrationKey)
}
return refreshed
}
return refreshed.requiringMigrationRetry()
}
private func fetchSnapshotResponse(
teamID: String?,
expectedUserID: String?,
scope: PairedMacBackupClientScopeSelection
) async -> PairedMacFetchedSnapshot? {
guard let request = await makeRequest(
method: "GET",
body: nil,
teamID: teamID,
expectedUserID: expectedUserID
expectedUserID: expectedUserID,
scope: scope
) else { return nil }
do {
let (data, response) = try await session.data(for: request)
@@ -195,7 +330,16 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
return nil
}
// A 2xx with an undecodable body is a real failure, not "no hosts".
return (try? JSONDecoder().decode(PairedMacBackupListResponse.self, from: data))?.snapshot
guard let response = try? JSONDecoder().decode(
PairedMacBackupListResponse.self,
from: data
) else {
return nil
}
return PairedMacFetchedSnapshot(
snapshot: response.snapshot,
revision: response.revision
)
} catch {
pairedMacBackupLog.warning("paired-mac backup fetch error: \(String(describing: error), privacy: .public)")
return nil
@@ -211,7 +355,8 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
method: String,
body: Data?,
teamID: String?,
expectedUserID: String?
expectedUserID: String?,
scope: PairedMacBackupClientScopeSelection = .current
) async -> URLRequest? {
guard let accessToken = await tokenSource.accessToken(expectedUserID: expectedUserID),
let url = Self.endpointURL(serviceBaseURL: serviceBaseURL) else {
@@ -224,8 +369,16 @@ public actor PairedMacBackupClient: PairedMacBackingUp {
if let teamID, !teamID.isEmpty {
request.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id")
}
if let scope = await clientScope() {
request.setValue(scope, forHTTPHeaderField: "X-Cmux-Client-Scope")
let resolvedScope: String?
switch scope {
case .current:
resolvedScope = await clientScope()
case .explicit(let explicit):
let trimmed = explicit?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
resolvedScope = trimmed.isEmpty ? nil : trimmed
}
if let resolvedScope {
request.setValue(resolvedScope, forHTTPHeaderField: "X-Cmux-Client-Scope")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@@ -0,0 +1,4 @@
enum PairedMacBackupClientScopeSelection: Sendable {
case current
case explicit(String?)
}
@@ -4,6 +4,8 @@ import Foundation
struct PairedMacBackupListResponse: Decodable {
let records: [PairedMacBackupRecord]
let deletedMacDeviceIDs: [String]
/// The server-owned collection revision used for conditional migration writes.
let revision: Int?
/// The presence worker's echo of the verified team this collection was read
/// from; nil when the worker predates the echo.
let teamId: String?
@@ -19,6 +21,7 @@ struct PairedMacBackupListResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case records
case deletedMacDeviceIDs
case revision
case teamId
}
@@ -36,6 +39,7 @@ struct PairedMacBackupListResponse: Decodable {
instanceTag: identity.instanceTag
)
}
revision = try c.decodeIfPresent(Int.self, forKey: .revision)
let trimmedTeamID = ((try? c.decodeIfPresent(String.self, forKey: .teamId)) ?? nil)?
.trimmingCharacters(in: .whitespacesAndNewlines)
teamId = (trimmedTeamID?.isEmpty ?? true) ? nil : trimmedTeamID
@@ -0,0 +1,75 @@
internal import CmuxMobilePairedMac
struct PairedMacBackupMigrationPlan {
let primary: PairedMacBackupSnapshot
let legacy: PairedMacBackupSnapshot
var operations: [PairedMacBackupOp] {
missingTombstoneIDs.sorted().map(deleteOp)
+ missingRecords.map { .upsert($0) }
}
func isFullyReconciled(by refreshed: PairedMacBackupSnapshot) -> Bool {
let refreshedIDs = Set(refreshed.records.map(pairingID))
let refreshedTombstones = Set(refreshed.deletedMacDeviceIDs)
return missingRecords.allSatisfy({
refreshedIDs.contains(pairingID($0))
}) && missingTombstoneIDs.allSatisfy({
refreshedTombstones.contains($0)
})
}
private var missingTombstoneIDs: [String] {
let currentIDs = Set(primary.records.map(pairingID))
let currentTombstones = Set(primary.deletedMacDeviceIDs)
return legacy.deletedMacDeviceIDs.filter { legacyTombstone in
!currentIDs.contains(where: {
tombstone(legacyTombstone, covers: $0)
})
&& !currentTombstones.contains(where: {
tombstone($0, covers: legacyTombstone)
})
}
}
private var missingRecords: [PairedMacBackupRecord] {
let currentIDs = Set(primary.records.map(pairingID))
let currentTombstones = Set(primary.deletedMacDeviceIDs)
let legacyTombstones = Set(legacy.deletedMacDeviceIDs)
return legacy.records.filter {
let candidateID = pairingID($0)
return !currentIDs.contains(candidateID)
&& !currentTombstones.contains(where: {
tombstone($0, covers: candidateID)
})
&& !legacyTombstones.contains(where: {
tombstone($0, covers: candidateID)
})
}
}
private func pairingID(_ record: PairedMacBackupRecord) -> String {
MobilePairedMac.pairingID(
macDeviceID: record.macDeviceID,
instanceTag: record.instanceTag
)
}
private func tombstone(_ tombstoneID: String, covers pairingID: String) -> Bool {
let tombstone = MobilePairedMac.pairingIdentity(from: tombstoneID)
let pairing = MobilePairedMac.pairingIdentity(from: pairingID)
guard tombstone.macDeviceID == pairing.macDeviceID else { return false }
return tombstone.instanceTag == nil || tombstone.instanceTag == pairing.instanceTag
}
private func deleteOp(_ pairingID: String) -> PairedMacBackupOp {
let identity = MobilePairedMac.pairingIdentity(from: pairingID)
if let instanceTag = identity.instanceTag {
return .deleteInstance(
macDeviceID: identity.macDeviceID,
instanceTag: instanceTag
)
}
return .delete(macDeviceID: identity.macDeviceID)
}
}
@@ -0,0 +1,24 @@
import Foundation
struct PairedMacBackupMigrationScope {
let currentScope: String?
let legacyScope: String?
let teamID: String?
let expectedUserID: String?
var key: String? {
guard let expectedUserID, !expectedUserID.isEmpty else { return nil }
let identity = [
currentScope ?? "<unscoped>",
legacyScope ?? "<unscoped>",
teamID ?? "<personal>",
expectedUserID,
].joined(separator: "\u{0}")
let encoded = Data(identity.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "cmux.pairedMacBackup.legacyMigration.v2.\(encoded)"
}
}
@@ -1,3 +1,12 @@
struct PairedMacBackupRequestBody: Encodable {
let ops: [PairedMacBackupOpWire]
let expectedRevision: Int?
init(
ops: [PairedMacBackupOpWire],
expectedRevision: Int? = nil
) {
self.ops = ops
self.expectedRevision = expectedRevision
}
}
@@ -16,14 +16,27 @@ public struct PairedMacBackupSnapshot: Sendable, Equatable {
/// restored record's later delete tombstone must route there.
public var resolvedTeamID: String?
/// Whether legacy-scope reconciliation must be retried before this restore
/// can be memoized as complete. Current-scope records remain valid and may
/// be restored while this is true.
public var requiresMigrationRetry: Bool
/// Create a restore snapshot from live records and compatibility tombstones.
public init(
records: [PairedMacBackupRecord],
deletedMacDeviceIDs: [String] = [],
requiresMigrationRetry: Bool = false,
resolvedTeamID: String? = nil
) {
self.records = records
self.deletedMacDeviceIDs = deletedMacDeviceIDs
self.requiresMigrationRetry = requiresMigrationRetry
self.resolvedTeamID = resolvedTeamID
}
func requiringMigrationRetry() -> Self {
var snapshot = self
snapshot.requiresMigrationRetry = true
return snapshot
}
}
@@ -0,0 +1,4 @@
struct PairedMacFetchedSnapshot {
let snapshot: PairedMacBackupSnapshot
let revision: Int?
}
@@ -65,6 +65,7 @@ public struct PairedMacRestore: Sendable {
guard let snapshot = await backup.fetchSnapshot(teamID: teamID, expectedUserID: accountID) else {
return RestoreOutcome(completed: false, restored: 0)
}
let restoreCompleted = !snapshot.requiresMigrationRetry
// Sign-out (or any wipe) can race this restore: if the owning task was
// cancelled while the network fetch was suspended, do NOT write the
// previous account's Macs back into the just-emptied local store. Report
@@ -106,7 +107,7 @@ public struct PairedMacRestore: Sendable {
}
}
guard !liveRecords.isEmpty || !pendingDeleteIDs.isEmpty else {
return RestoreOutcome(completed: true, restored: 0)
return RestoreOutcome(completed: restoreCompleted, restored: 0)
}
let localBeforePendingDeletes = (try? await store.loadAll(
@@ -262,7 +263,7 @@ public struct PairedMacRestore: Sendable {
}
await onResolvedBackupTeam(echoes, resolvedTeamID)
}
return RestoreOutcome(completed: true, restored: restored)
return RestoreOutcome(completed: restoreCompleted, restored: restored)
}
}
@@ -15,18 +15,21 @@ actor FakeBackup: PairedMacBackingUp {
/// unset, one shared list backs every team (the legacy single-bucket mode).
private var recordsByTeam: [String: [PairedMacBackupRecord]]?
private let deletedMacDeviceIDs: [String]
private let requiresMigrationRetry: Bool
private var failNextFetches: Int
private var failNextUploads: Int
init(
records: [PairedMacBackupRecord] = [],
deletedMacDeviceIDs: [String] = [],
requiresMigrationRetry: Bool = false,
failNextFetches: Int = 0,
failNextUploads: Int = 0
) {
self.records = records
self.recordsByTeam = nil
self.deletedMacDeviceIDs = deletedMacDeviceIDs
self.requiresMigrationRetry = requiresMigrationRetry
self.failNextFetches = failNextFetches
self.failNextUploads = failNextUploads
}
@@ -34,12 +37,14 @@ actor FakeBackup: PairedMacBackingUp {
init(
recordsByTeam: [String: [PairedMacBackupRecord]],
deletedMacDeviceIDs: [String] = [],
requiresMigrationRetry: Bool = false,
failNextFetches: Int = 0,
failNextUploads: Int = 0
) {
self.records = []
self.recordsByTeam = recordsByTeam
self.deletedMacDeviceIDs = deletedMacDeviceIDs
self.requiresMigrationRetry = requiresMigrationRetry
self.failNextFetches = failNextFetches
self.failNextUploads = failNextUploads
}
@@ -192,6 +197,7 @@ actor FakeBackup: PairedMacBackingUp {
return PairedMacBackupSnapshot(
records: fetched,
deletedMacDeviceIDs: deletedMacDeviceIDs,
requiresMigrationRetry: requiresMigrationRetry,
// Mirror the worker's echo of its verified resolved team on the
// restore read too, matching uploadReportingResolvedTeam.
resolvedTeamID: echoedResolvedTeamID ?? teamID
@@ -0,0 +1,13 @@
actor MigrationUserProbe {
private var currentValue: String?
init(value: String?) {
currentValue = value
}
func value() -> String? { currentValue }
func setValue(_ value: String?) {
currentValue = value
}
}
@@ -0,0 +1,937 @@
import Foundation
import Testing
import CmuxMobilePairedMac
@testable import CmuxMobileShell
@Suite(.serialized)
struct PairedMacBackupMigrationTests {
@Test func migrationPinsServerVerifiedTeamAfterDefaultTeamRead() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let legacy = PairedMacBackupRecord(
macDeviceID: "legacy-mac",
displayName: "Legacy Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
let primaryResponse = try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: [],
teamId: "team-from-server"
)
)
let migratedResponse = try JSONEncoder().encode(
TestBackupList(
records: [legacy],
deletedMacDeviceIDs: [],
revision: 1,
teamId: "team-from-server"
)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: primaryResponse,
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(
records: [legacy],
deletedMacDeviceIDs: [],
teamId: "team-from-server"
)
),
primaryResponseAfterUpload: migratedResponse
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [legacy])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests().map {
$0.value(forHTTPHeaderField: "X-Cmux-Team-Id")
} == [nil, "team-from-server", "team-from-server", "team-from-server"]
)
}
@Test func emptyV3CollectionAdoptsOneExplicitLegacyCollection() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let record = PairedMacBackupRecord(
macDeviceID: "legacy-mac",
displayName: "Legacy Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
let legacyResponse = try JSONEncoder().encode(
TestBackupList(records: [record], deletedMacDeviceIDs: [])
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: Data(
#"{"records":[],"deletedMacDeviceIDs":[],"revision":0,"teamId":"team-1"}"#.utf8
),
legacyScope: nil,
legacyResponse: legacyResponse,
primaryResponseAfterUpload: legacyResponse
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [record])
let requests = PairedMacBackupMigrationURLProtocol.capturedRequests()
#expect(requests.map(\.httpMethod) == ["GET", "GET", "POST", "GET"])
#expect(requests.map {
$0.value(forHTTPHeaderField: "X-Cmux-Client-Scope")
} == [
"ios:v3:Y29tLmNtdXguYXBw",
nil,
"ios:v3:Y29tLmNtdXguYXBw",
"ios:v3:Y29tLmNtdXguYXBw",
])
}
@Test func partiallyPopulatedV3CollectionReconcilesMissingLegacyRecords() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let current = PairedMacBackupRecord(
macDeviceID: "current-mac",
displayName: "Current Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
let legacy = PairedMacBackupRecord(
macDeviceID: "legacy-mac",
displayName: "Legacy Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: false
)
let combinedResponse = try JSONEncoder().encode(
TestBackupList(
records: [current, legacy],
deletedMacDeviceIDs: []
)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(records: [current], deletedMacDeviceIDs: [])
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [legacy], deletedMacDeviceIDs: [])
),
primaryResponseAfterUpload: combinedResponse
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [current, legacy])
let requests = PairedMacBackupMigrationURLProtocol.capturedRequests()
#expect(requests.map(\.httpMethod) == ["GET", "GET", "POST", "GET"])
}
@Test func currentTombstonePreventsLegacyRecordResurrection() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let legacy = PairedMacBackupRecord(
macDeviceID: "forgotten-mac",
displayName: "Forgotten Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: false,
instanceTag: "nightly"
)
let pairingID = MobilePairedMac.pairingID(
macDeviceID: legacy.macDeviceID,
instanceTag: legacy.instanceTag
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: [pairingID]
)
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [legacy], deletedMacDeviceIDs: [])
)
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records.isEmpty)
#expect(snapshot.deletedMacDeviceIDs == [pairingID])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET"]
)
}
@Test func currentGlobalTombstonePreventsTaggedLegacyRecordResurrection() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let legacy = PairedMacBackupRecord(
macDeviceID: "forgotten-mac",
displayName: "Forgotten Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: false,
instanceTag: "nightly"
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: ["forgotten-mac"])
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [legacy], deletedMacDeviceIDs: [])
)
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records.isEmpty)
#expect(snapshot.deletedMacDeviceIDs == ["forgotten-mac"])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET"]
)
}
@Test func currentTaggedRecordPreventsLegacyGlobalTombstoneDeletingIt() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let current = PairedMacBackupRecord(
macDeviceID: "repaired-mac",
displayName: "Repaired Mac",
routes: [],
createdAt: 3_000,
lastSeenAt: 4_000,
isActive: true,
instanceTag: "nightly"
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(records: [current], deletedMacDeviceIDs: [])
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: ["repaired-mac"])
)
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [current])
#expect(snapshot.deletedMacDeviceIDs.isEmpty)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET"]
)
}
@Test func siblingTaggedLegacyTombstoneIsMigrated() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let current = PairedMacBackupRecord(
macDeviceID: "repaired-mac",
displayName: "Beta Mac",
routes: [],
createdAt: 3_000,
lastSeenAt: 4_000,
isActive: true,
instanceTag: "beta"
)
let legacyTombstone = "repaired-mac:nightly"
let currentResponse = try JSONEncoder().encode(
TestBackupList(records: [current], deletedMacDeviceIDs: [])
)
let migratedResponse = try JSONEncoder().encode(
TestBackupList(
records: [current],
deletedMacDeviceIDs: [legacyTombstone]
)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: currentResponse,
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: [legacyTombstone])
),
primaryResponseAfterUpload: migratedResponse
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [current])
#expect(snapshot.deletedMacDeviceIDs == [legacyTombstone])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "POST", "GET"]
)
}
@Test func legacyTombstoneMigratesAndRejectsLaterStaleUpsert() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let stale = PairedMacBackupRecord(
macDeviceID: "forgotten-mac",
displayName: "Forgotten Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: false,
instanceTag: "nightly"
)
let pairingID = MobilePairedMac.pairingID(
macDeviceID: stale.macDeviceID,
instanceTag: stale.instanceTag
)
let empty = try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: [])
)
let tombstoned = try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: [pairingID]
)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: empty,
legacyScope: nil,
legacyResponse: tombstoned,
primaryResponseAfterUpload: tombstoned
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let migrated = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(migrated.records.isEmpty)
#expect(migrated.deletedMacDeviceIDs == [pairingID])
let migrationRequests =
PairedMacBackupMigrationURLProtocol.capturedRequests()
#expect(
migrationRequests.map(\.httpMethod)
== ["GET", "GET", "POST", "GET"]
)
let migrationBody = try #require(
PairedMacBackupMigrationURLProtocol.capturedRequestBodies()
.dropFirst(2)
.first
)
let unwrappedMigrationBody = try #require(migrationBody)
let object = try #require(
JSONSerialization.jsonObject(with: unwrappedMigrationBody)
as? [String: Any]
)
let ops = try #require(object["ops"] as? [[String: Any]])
#expect(object["expectedRevision"] as? Int == 0)
#expect(ops.count == 1)
#expect(ops[0]["macDeviceID"] as? String == stale.macDeviceID)
#expect(ops[0]["instanceTag"] as? String == "nightly")
#expect(ops[0]["deleted"] as? Bool == true)
#expect(await client.upload(
ops: [.upsert(stale)],
teamID: nil,
expectedUserID: "user-1"
))
let afterStaleUpsert = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(afterStaleUpsert.records.isEmpty)
#expect(afterStaleUpsert.deletedMacDeviceIDs == [pairingID])
}
@Test func currentV3RecordWinsConflictingLegacyTombstone() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let current = PairedMacBackupRecord(
macDeviceID: "repaired-mac",
displayName: "Re-paired Mac",
routes: [],
createdAt: 3_000,
lastSeenAt: 4_000,
isActive: true,
instanceTag: "nightly"
)
let pairingID = MobilePairedMac.pairingID(
macDeviceID: current.macDeviceID,
instanceTag: current.instanceTag
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(records: [current], deletedMacDeviceIDs: [])
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: [pairingID]
)
)
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [current])
#expect(snapshot.deletedMacDeviceIDs.isEmpty)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET"]
)
}
@Test func legacyFetchFailureRemainsRetryable() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let current = PairedMacBackupRecord(
macDeviceID: "current-mac",
displayName: "Current Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(records: [current], deletedMacDeviceIDs: [])
),
legacyScope: nil,
legacyResponse: Data("invalid-json".utf8)
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let first = await client.fetchSnapshot(
teamID: nil,
expectedUserID: "user-1"
)
let second = await client.fetchSnapshot(
teamID: nil,
expectedUserID: "user-1"
)
#expect(first?.records == [current])
#expect(second?.records == [current])
#expect(first?.requiresMigrationRetry == true)
#expect(second?.requiresMigrationRetry == true)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "GET", "GET"]
)
}
@Test func revisionConflictReturnsCurrentSnapshotAndRemainsRetryable() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let pairingID = MobilePairedMac.pairingID(
macDeviceID: "repaired-mac",
instanceTag: "nightly"
)
let current = PairedMacBackupRecord(
macDeviceID: "current-mac",
displayName: "Current Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: try JSONEncoder().encode(
TestBackupList(
records: [current],
deletedMacDeviceIDs: [],
revision: 7
)
),
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: [pairingID]
)
),
uploadStatusCode: 409
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [
PairedMacBackupMigrationURLProtocol.self
]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = await client.fetchSnapshot(
teamID: nil,
expectedUserID: "user-1"
)
#expect(snapshot?.records == [current])
#expect(snapshot?.requiresMigrationRetry == true)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "POST"]
)
let body = try #require(
PairedMacBackupMigrationURLProtocol.capturedRequestBodies()
.compactMap { $0 }
.first
)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["expectedRevision"] as? Int == 7)
}
@Test func legacyAdoptionUsesOneConditionalBatchPerFetch() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let tombstones = (0 ..< 201).map { "forgotten-\($0):nightly" }
let empty = try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: [])
)
let legacy = try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: tombstones
)
)
let migratedTombstones = Array(tombstones.prefix(200))
let migrated = try JSONEncoder().encode(
TestBackupList(
records: [],
deletedMacDeviceIDs: migratedTombstones,
revision: 200
)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: empty,
legacyScope: nil,
legacyResponse: legacy,
primaryResponseAfterUpload: migrated
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [
PairedMacBackupMigrationURLProtocol.self
]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.deletedMacDeviceIDs == migratedTombstones)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod)
== ["GET", "GET", "POST", "GET"]
)
let postBodies = PairedMacBackupMigrationURLProtocol
.capturedRequestBodies()
.compactMap { $0 }
let opCounts = try postBodies.map { body in
let object = try #require(
JSONSerialization.jsonObject(with: body)
as? [String: Any]
)
return try #require(object["ops"] as? [[String: Any]]).count
}
#expect(opCounts == [200])
}
@Test func legacyMigrationCapsUploadsPerFetch() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let tombstones = (0 ..< 401).map { "forgotten-\($0)" }
let empty = try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: [])
)
let legacy = try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: tombstones)
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: empty,
legacyScope: nil,
legacyResponse: legacy,
primaryResponseAfterUpload: empty
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [
PairedMacBackupMigrationURLProtocol.self
]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
_ = await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "POST", "GET"]
)
let postBodies = PairedMacBackupMigrationURLProtocol
.capturedRequestBodies()
.compactMap { $0 }
let opCounts = try postBodies.map { body in
let object = try #require(
JSONSerialization.jsonObject(with: body)
as? [String: Any]
)
return try #require(object["ops"] as? [[String: Any]]).count
}
#expect(opCounts == [200])
}
@Test func legacyUpdatesRemainReconciledAfterInitialMigration() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let clock = PairedMacMigrationClock()
let first = PairedMacBackupRecord(
macDeviceID: "first-legacy-mac",
displayName: "First Legacy Mac",
routes: [],
createdAt: 1_000,
lastSeenAt: 2_000,
isActive: true
)
let second = PairedMacBackupRecord(
macDeviceID: "second-legacy-mac",
displayName: "Second Legacy Mac",
routes: [],
createdAt: 3_000,
lastSeenAt: 4_000,
isActive: true
)
let empty = try JSONEncoder().encode(
TestBackupList(records: [], deletedMacDeviceIDs: [])
)
let firstResponse = try JSONEncoder().encode(
TestBackupList(records: [first], deletedMacDeviceIDs: [])
)
let combinedResponse = try JSONEncoder().encode(
TestBackupList(records: [first, second], deletedMacDeviceIDs: [])
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: empty,
legacyScope: nil,
legacyResponse: firstResponse,
primaryResponseAfterUpload: firstResponse
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { "user-1" }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults,
migrationClock: { clock.now }
)
_ = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: firstResponse,
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [second], deletedMacDeviceIDs: [])
),
primaryResponseAfterUpload: combinedResponse
)
let duringCooldown = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(duringCooldown.records == [first])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET"]
)
clock.advance(by: 61)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: firstResponse,
legacyScope: nil,
legacyResponse: try JSONEncoder().encode(
TestBackupList(records: [second], deletedMacDeviceIDs: [])
),
primaryResponseAfterUpload: combinedResponse
)
let snapshot = try #require(
await client.fetchSnapshot(teamID: nil, expectedUserID: "user-1")
)
#expect(snapshot.records == [first, second])
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "POST", "GET"]
)
}
@Test func migrationCompletionDoesNotCrossAccountsWithoutExpectedUserID() async throws {
let defaultsSuite = "paired-mac-migration-\(UUID().uuidString)"
let migrationDefaults = try #require(
UserDefaults(suiteName: defaultsSuite)
)
let user = MigrationUserProbe(value: "user-a")
let empty = Data(
#"{"records":[],"deletedMacDeviceIDs":[],"revision":0,"teamId":"team-1"}"#.utf8
)
PairedMacBackupMigrationURLProtocol.reset(
primaryScope: "ios:v3:Y29tLmNtdXguYXBw",
primaryResponse: empty,
legacyScope: nil,
legacyResponse: empty
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairedMacBackupMigrationURLProtocol.self]
let client = PairedMacBackupClient(
serviceBaseURL: "https://presence.example",
tokenSource: PresenceTokenSource(
accessToken: { "access-token" },
currentUserID: { await user.value() }
),
clientScopeProvider: { "ios:v3:Y29tLmNtdXguYXBw" },
legacyClientScopeProvider: { nil },
session: URLSession(configuration: configuration),
migrationDefaults: migrationDefaults
)
_ = await client.fetchSnapshot(teamID: nil)
await user.setValue("user-b")
_ = await client.fetchSnapshot(teamID: nil)
#expect(
PairedMacBackupMigrationURLProtocol.capturedRequests()
.map(\.httpMethod) == ["GET", "GET", "GET", "GET"]
)
}
}
@@ -0,0 +1,117 @@
import Foundation
final class PairedMacBackupMigrationURLProtocol:
URLProtocol,
@unchecked Sendable
{
private static let lock = NSLock()
private nonisolated(unsafe) static var primaryScope = ""
private nonisolated(unsafe) static var primaryResponse = Data()
private nonisolated(unsafe) static var legacyScope: String?
private nonisolated(unsafe) static var legacyResponse = Data()
private nonisolated(unsafe) static var primaryResponseAfterUpload: Data?
private nonisolated(unsafe) static var uploadStatusCode = 200
private nonisolated(unsafe) static var didUpload = false
private nonisolated(unsafe) static var requests: [URLRequest] = []
private nonisolated(unsafe) static var requestBodies: [Data?] = []
static func reset(
primaryScope: String,
primaryResponse: Data,
legacyScope: String?,
legacyResponse: Data,
primaryResponseAfterUpload: Data? = nil,
uploadStatusCode: Int = 200
) {
lock.withLock {
self.primaryScope = primaryScope
self.primaryResponse = primaryResponse
self.legacyScope = legacyScope
self.legacyResponse = legacyResponse
self.primaryResponseAfterUpload = primaryResponseAfterUpload
self.uploadStatusCode = uploadStatusCode
didUpload = false
requests = []
requestBodies = []
}
}
static func capturedRequests() -> [URLRequest] {
lock.withLock { requests }
}
static func capturedRequestBodies() -> [Data?] {
lock.withLock { requestBodies }
}
override class func canInit(with _: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
let requestBody = request.httpBody
?? Self.readBodyStream(request.httpBodyStream)
let result = Self.lock.withLock { () -> (Data, Int) in
Self.requests.append(request)
Self.requestBodies.append(requestBody)
guard request.httpMethod == "GET" else {
Self.didUpload = true
return (
Data(#"{"ok":true}"#.utf8),
Self.uploadStatusCode
)
}
let scope = request.value(
forHTTPHeaderField: "X-Cmux-Client-Scope"
)
if scope == Self.primaryScope {
if Self.didUpload,
let primaryResponseAfterUpload =
Self.primaryResponseAfterUpload {
return (primaryResponseAfterUpload, 200)
}
return (Self.primaryResponse, 200)
}
if scope == Self.legacyScope {
return (Self.legacyResponse, 200)
}
return (
Data(
#"{"records":[],"deletedMacDeviceIDs":[],"revision":0}"#.utf8
),
200
)
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: result.1,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!
client?.urlProtocol(
self,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
client?.urlProtocol(self, didLoad: result.0)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
private static func readBodyStream(_ stream: InputStream?) -> Data? {
guard let stream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 4_096)
while stream.hasBytesAvailable {
let count = stream.read(&buffer, maxLength: buffer.count)
guard count > 0 else { break }
data.append(buffer, count: count)
}
return data.isEmpty ? nil : data
}
}
@@ -1026,6 +1026,28 @@ private let backupRouteDisclosureDate = Date(timeIntervalSince1970: 2_000_000_00
#expect(await backup.fetches() == 2) // not memoized after the failure
}
@Test func incompleteMigrationRestoresCurrentRecordsAndRetries() async throws {
let (inner, dir) = try makeInnerStore()
defer { try? FileManager.default.removeItem(at: dir) }
let backup = FakeBackup(
records: [try backupRecord(
"mac-a",
host: "10.0.0.1",
lastSeenMs: 2_000_000,
active: true
)],
requiresMigrationRetry: true
)
let store = BackingUpPairedMacStore(inner: inner, backup: backup)
let firstRead = try await store.loadAll(stackUserID: "user-1")
let secondRead = try await store.loadAll(stackUserID: "user-1")
#expect(firstRead.map(\.macDeviceID) == ["mac-a"])
#expect(secondRead.map(\.macDeviceID) == ["mac-a"])
#expect(await backup.fetches() == 2)
}
@Test func signOutThenSameAccountSignInReRestores() async throws {
let (inner, dir) = try makeInnerStore()
defer { try? FileManager.default.removeItem(at: dir) }

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