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:
co-authored by
Claude Fable 5
parent
029d652972
commit
7589f52b81
+10
-3
@@ -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 {}
|
||||
|
||||
+71
@@ -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)
|
||||
}
|
||||
}
|
||||
+16
@@ -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,
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
+10
-5
@@ -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])
|
||||
}
|
||||
|
||||
+137
-36
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+104
-9
@@ -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) {}
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
+4
@@ -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 {
|
||||
|
||||
+31
@@ -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.
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -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
|
||||
}
|
||||
|
||||
+14
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+17
@@ -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 {}
|
||||
|
||||
+2
-1
@@ -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
-1
@@ -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,
|
||||
|
||||
+31
-7
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+10
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+20
-1
@@ -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.
|
||||
|
||||
+14
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) }
|
||||
|
||||
+9
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-15
@@ -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,
|
||||
|
||||
+9
@@ -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
-1
@@ -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,
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+69
-15
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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)
|
||||
}
|
||||
|
||||
+48
-7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+57
-28
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+9
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
struct CmxIrohMacForgetRequest: Encodable {
|
||||
let bindingId: String
|
||||
let intent = "forget_mac"
|
||||
}
|
||||
+1
@@ -68,4 +68,5 @@ public struct CmxIrohPendingRevocation: Codable, Equatable, Sendable {
|
||||
private static func isCanonicalUUID(_ value: String) -> Bool {
|
||||
UUID(uuidString: value)?.uuidString.lowercased() == value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+38
-1
@@ -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(
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
struct CmxIrohPostRegistrationRevocationFailure: Error {
|
||||
let underlying: any Error
|
||||
}
|
||||
+6
@@ -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
|
||||
|
||||
+23
@@ -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: "-")
|
||||
|
||||
+16
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
struct CmxIrohStaleBindingRevocationRequest: Encodable {
|
||||
let bindingId: String
|
||||
let intent = "revoke_stale"
|
||||
}
|
||||
+106
-8
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ struct CmxIrohBrokerCredentialPairTests {
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: tokenSource,
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+218
-2
@@ -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()
|
||||
|
||||
+2
-1
@@ -140,7 +140,8 @@ struct CmxIrohCustomRelayLiveTests {
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
clientNamespace: "legacy"
|
||||
)
|
||||
let runTag = "relay-live-\(UUID().uuidString.lowercased())"
|
||||
let firstSecretKey = try randomSecretKey()
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
-6
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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()
|
||||
|
||||
+48
@@ -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()
|
||||
|
||||
+2
@@ -231,4 +231,6 @@ private actor TestRevisionedHostBroker:
|
||||
}
|
||||
|
||||
func revoke(bindingID _: String) {}
|
||||
|
||||
func revokeStale(bindingID _: String) {}
|
||||
}
|
||||
|
||||
+10
@@ -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",
|
||||
|
||||
+42
-3
@@ -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
|
||||
|
||||
+68
-17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
@@ -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 }
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ struct CmxIrohRelayPolicyBrokerTests {
|
||||
CmxIrohBrokerCredentials(accessToken: "access", refreshToken: "refresh")
|
||||
}
|
||||
),
|
||||
clientNamespace: "legacy",
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
+11
-4
@@ -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,
|
||||
|
||||
+4
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+134
-2
@@ -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 {
|
||||
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
|
||||
+2
-6
@@ -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? {
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
+937
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
+117
@@ -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
Reference in New Issue
Block a user