mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +00:00
* 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)
170 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|