Files
openclaw/apps/ios/Sources/Push/PushRegistrationManager.swift
Nimrod Gutman b77b7485e0 feat(push): add iOS APNs relay gateway (#43369)
* feat(push): add ios apns relay gateway

* fix(shared): avoid oslog string concatenation

# Conflicts:
#	apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

* fix(push): harden relay validation and invalidation

* fix(push): persist app attest state before relay registration

* fix(push): harden relay invalidation and url handling

* feat(push): use scoped relay send grants

* feat(push): configure ios relay through gateway config

* feat(push): bind relay registration to gateway identity

* fix(push): tighten ios relay trust flow

* fix(push): bound APNs registration fields (#43369) (thanks @ngutman)
2026-03-12 18:15:35 +02:00

170 lines
6.8 KiB
Swift

import CryptoKit
import Foundation
private struct DirectGatewayPushRegistrationPayload: Encodable {
var transport: String = PushTransportMode.direct.rawValue
var token: String
var topic: String
var environment: String
}
private struct RelayGatewayPushRegistrationPayload: Encodable {
var transport: String = PushTransportMode.relay.rawValue
var relayHandle: String
var sendGrant: String
var gatewayDeviceId: String
var installationId: String
var topic: String
var environment: String
var distribution: String
var tokenDebugSuffix: String?
}
struct PushRelayGatewayIdentity: Codable {
var deviceId: String
var publicKey: String
}
actor PushRegistrationManager {
private let buildConfig: PushBuildConfig
private let relayClient: PushRelayClient?
var usesRelayTransport: Bool {
self.buildConfig.transport == .relay
}
init(buildConfig: PushBuildConfig = .current) {
self.buildConfig = buildConfig
self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) }
}
func makeGatewayRegistrationPayload(
apnsTokenHex: String,
topic: String,
gatewayIdentity: PushRelayGatewayIdentity?)
async throws -> String {
switch self.buildConfig.transport {
case .direct:
return try Self.encodePayload(
DirectGatewayPushRegistrationPayload(
token: apnsTokenHex,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue))
case .relay:
guard let gatewayIdentity else {
throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration")
}
return try await self.makeRelayPayload(
apnsTokenHex: apnsTokenHex,
topic: topic,
gatewayIdentity: gatewayIdentity)
}
}
private func makeRelayPayload(
apnsTokenHex: String,
topic: String,
gatewayIdentity: PushRelayGatewayIdentity)
async throws -> String {
guard self.buildConfig.distribution == .official else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushDistribution=official")
}
guard self.buildConfig.apnsEnvironment == .production else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushAPNsEnvironment=production")
}
guard let relayClient = self.relayClient else {
throw PushRelayError.relayBaseURLMissing
}
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration")
}
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
.trimmingCharacters(in: .whitespacesAndNewlines),
!installationId.isEmpty
else {
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
}
let tokenHashHex = Self.sha256Hex(apnsTokenHex)
let relayOrigin = relayClient.normalizedBaseURLString
if let stored = PushRelayRegistrationStore.loadRegistrationState(),
stored.installationId == installationId,
stored.gatewayDeviceId == gatewayIdentity.deviceId,
stored.relayOrigin == relayOrigin,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: stored.relayHandle,
sendGrant: stored.sendGrant,
gatewayDeviceId: gatewayIdentity.deviceId,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
distribution: self.buildConfig.distribution.rawValue,
tokenDebugSuffix: stored.tokenDebugSuffix))
}
let response = try await relayClient.register(
installationId: installationId,
bundleId: bundleId,
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
distribution: self.buildConfig.distribution,
apnsTokenHex: apnsTokenHex,
gatewayIdentity: gatewayIdentity)
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
relayOrigin: relayOrigin,
gatewayDeviceId: gatewayIdentity.deviceId,
relayHandleExpiresAtMs: response.expiresAtMs,
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
installationId: installationId,
lastTransport: self.buildConfig.transport.rawValue)
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
gatewayDeviceId: gatewayIdentity.deviceId,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
distribution: self.buildConfig.distribution.rawValue,
tokenDebugSuffix: registrationState.tokenDebugSuffix))
}
private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
guard let expiresAtMs else { return true }
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
// Refresh shortly before expiry so reconnect-path republishes a live handle.
return expiresAtMs <= nowMs + 60_000
}
private static func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func normalizeTokenSuffix(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func encodePayload(_ payload: some Encodable) throws -> String {
let data = try JSONEncoder().encode(payload)
guard let json = String(data: data, encoding: .utf8) else {
throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
}
return json
}
}