Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ public struct MobilePhonePushKeyExchangeResponse: Codable, Equatable, Sendable {
clientNamespace == "mac:" + macBuildID.lowercased()
}

/// The fields of this reply that contradict the host status the phone read
/// on the same authenticated connection; empty when the reply is usable.
///
/// `mac_device_id` is deliberately not compared. The status carries the
/// team-directory computer identity that names the saved computer, while
/// this reply carries the physical device identity that push tuples use;
/// they differ by design, so comparing them rejected every exchange.
public func mismatchedFields(
accountID: String,
macInstanceTag: String,
macClientNamespace: String
) -> [String] {
[
self.accountID == accountID ? nil : "account",
self.macInstanceTag == macInstanceTag ? nil : "mac_instance_tag",
matchesMacClientNamespace(macClientNamespace) ? nil : "mac_namespace",
].compactMap { $0 }
}

public func validate() throws {
guard version == MobilePhonePushKeyExchangeRequest.currentVersion,
hpkeEnvelopeVersion == MobilePhonePushKeyExchangeRequest.hpkeEnvelopeVersion,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import Testing
@testable import CMUXMobileCore

@Suite struct MobilePhonePushKeyExchangeResponseTests {
private func reply(
accountID: String = "account-1",
macDeviceID: String = "physical-device",
macInstanceTag: String = "nightly",
macBuildID: String = "com.cmuxterm.app.nightly"
) -> MobilePhonePushKeyExchangeResponse {
MobilePhonePushKeyExchangeResponse(
descriptor: MobilePhonePushPublicKeyDescriptor(
installationID: "mac-install",
keyID: "mac-key",
publicKey: Data(repeating: 1, count: 32)
),
accountID: accountID,
macDeviceID: macDeviceID,
macInstanceTag: macInstanceTag,
macBuildID: macBuildID
)
}

/// The status names the team-directory computer; the reply names the
/// physical device push tuples use. A reply from the same Mac must pass.
@Test func replyFromTheStatusMacIsAccepted() {
#expect(reply().mismatchedFields(
accountID: "account-1",
macInstanceTag: "nightly",
macClientNamespace: "mac:com.cmuxterm.app.nightly"
).isEmpty)
}

@Test func contradictingFieldsAreNamed() {
#expect(reply(accountID: "account-2", macInstanceTag: "default", macBuildID: "com.cmuxterm.app")
.mismatchedFields(
accountID: "account-1",
macInstanceTag: "nightly",
macClientNamespace: "mac:com.cmuxterm.app.nightly"
) == ["account", "mac_instance_tag", "mac_namespace"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ extension MobileShellComposite {
guard phonePushKeyExchangeHooks != nil,
let accountID = identityProvider?.currentUserID,
!accountID.isEmpty,
let macDeviceID = status.macDeviceID,
let macInstanceTag = status.macInstanceTag,
let macClientNamespace = status.macClientNamespace else {
let missing = [
phonePushKeyExchangeHooks == nil ? "hooks" : nil,
(identityProvider?.currentUserID ?? "").isEmpty ? "account" : nil,
status.macInstanceTag == nil ? "mac_instance_tag" : nil,
status.macClientNamespace == nil ? "mac_namespace" : nil,
].compactMap { $0 }
phonePushKeyExchangeLog.error(
"key exchange skipped, missing: \(missing.joined(separator: ","), privacy: .public)"
)
if isPrimaryClient { phonePushKeyExchangeFailed = true }
diagnosticLog?.recordAppEvent(.pushKeyExchangeContextMissing, failure: .credentialUnavailable)
return
Expand All @@ -44,7 +52,6 @@ extension MobileShellComposite {
let exchanged = await self.performPhonePushKeyExchange(
client: client,
accountID: accountID,
macDeviceID: macDeviceID,
macInstanceTag: macInstanceTag,
macClientNamespace: macClientNamespace
)
Expand All @@ -70,7 +77,6 @@ extension MobileShellComposite {
private func performPhonePushKeyExchange(
client: MobileCoreRPCClient,
accountID: String,
macDeviceID: String,
macInstanceTag: String,
macClientNamespace: String
) async -> Bool {
Expand All @@ -83,11 +89,18 @@ extension MobileShellComposite {
)
let response = exchange.response
guard !Task.isCancelled,
identityProvider?.currentUserID == accountID,
response.accountID == accountID,
response.macDeviceID == macDeviceID,
response.macInstanceTag == macInstanceTag,
response.matchesMacClientNamespace(macClientNamespace) else {
identityProvider?.currentUserID == accountID else { return false }
// Name the mismatched field (never its value) so a rejected reply
// is diagnosable from device logs.
let mismatches = response.mismatchedFields(
accountID: accountID,
macInstanceTag: macInstanceTag,
macClientNamespace: macClientNamespace
)
guard mismatches.isEmpty else {
phonePushKeyExchangeLog.error(
"key exchange reply rejected: \(mismatches.joined(separator: ","), privacy: .public)"
)
return false
}
let context = MobilePhonePushKeyExchangeContext(
Expand All @@ -104,7 +117,9 @@ extension MobileShellComposite {
return true
} catch {
guard !Task.isCancelled else { return false }
phonePushKeyExchangeLog.error("key exchange attempt failed")
phonePushKeyExchangeLog.error(
"key exchange attempt failed: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)"
)
return false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,10 @@ public struct MobileAuthComposition {
return previous != resolvedProjectID
}

/// The Simulator only ever mints sandbox device tokens, so a Release build
/// running there must register as sandbox or APNs rejects every push.
private static var apnsEnvironment: String {
#if DEBUG
#if DEBUG || targetEnvironment(simulator)
"sandbox"
#else
"production"
Expand Down
Loading