feat(push): bind relay registration to gateway identity

This commit is contained in:
Nimrod Gutman
2026-03-12 17:11:50 +02:00
parent 93abfc0b22
commit 3fb1604532
12 changed files with 221 additions and 12 deletions

View File

@@ -12,6 +12,12 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
private struct GatewayRelayIdentityResponse: Decodable {
let deviceId: String
let publicKey: String
}
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -2502,9 +2508,16 @@ extension NodeAppModel {
}
do {
let gatewayIdentity: PushRelayGatewayIdentity?
if await self.pushRegistrationManager.usesRelayTransport {
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
} else {
gatewayIdentity = nil
}
let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
apnsTokenHex: token,
topic: topic)
topic: topic,
gatewayIdentity: gatewayIdentity)
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
self.apnsLastRegisteredTokenHex = token
} catch {
@@ -2513,6 +2526,20 @@ extension NodeAppModel {
}
}
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
let response = try await self.operatorGateway.request(
method: "gateway.identity.get",
paramsJSON: "{}",
timeoutSeconds: 8)
let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !deviceId.isEmpty, !publicKey.isEmpty else {
throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
}
return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
}
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
guard let apsAny = userInfo["aps"] else { return false }
if let aps = apsAny as? [AnyHashable: Any] {

View File

@@ -12,6 +12,7 @@ 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
@@ -19,10 +20,19 @@ private struct RelayGatewayPushRegistrationPayload: Encodable {
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) }
@@ -30,7 +40,8 @@ actor PushRegistrationManager {
func makeGatewayRegistrationPayload(
apnsTokenHex: String,
topic: String)
topic: String,
gatewayIdentity: PushRelayGatewayIdentity?)
async throws -> String {
switch self.buildConfig.transport {
case .direct:
@@ -40,11 +51,21 @@ actor PushRegistrationManager {
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue))
case .relay:
return try await self.makeRelayPayload(apnsTokenHex: apnsTokenHex, topic: topic)
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) async throws -> String {
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")
@@ -71,6 +92,7 @@ actor PushRegistrationManager {
let tokenHashHex = Self.sha256Hex(apnsTokenHex)
if let stored = PushRelayRegistrationStore.loadRegistrationState(),
stored.installationId == installationId,
stored.gatewayDeviceId == gatewayIdentity.deviceId,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
@@ -78,6 +100,7 @@ actor PushRegistrationManager {
RelayGatewayPushRegistrationPayload(
relayHandle: stored.relayHandle,
sendGrant: stored.sendGrant,
gatewayDeviceId: gatewayIdentity.deviceId,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
@@ -91,10 +114,12 @@ actor PushRegistrationManager {
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
distribution: self.buildConfig.distribution,
apnsTokenHex: apnsTokenHex)
apnsTokenHex: apnsTokenHex,
gatewayIdentity: gatewayIdentity)
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
gatewayDeviceId: gatewayIdentity.deviceId,
relayHandleExpiresAtMs: response.expiresAtMs,
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
@@ -105,6 +130,7 @@ actor PushRegistrationManager {
RelayGatewayPushRegistrationPayload(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
gatewayDeviceId: gatewayIdentity.deviceId,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,

View File

@@ -41,6 +41,7 @@ private struct PushRelayRegisterSignedPayload: Encodable {
var bundleId: String
var environment: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
}
@@ -63,6 +64,7 @@ private struct PushRelayRegisterRequest: Encodable {
var bundleId: String
var environment: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
var appAttest: PushRelayAppAttestPayload
@@ -233,7 +235,8 @@ final class PushRelayClient: @unchecked Sendable {
appVersion: String,
environment: PushAPNsEnvironment,
distribution: PushDistributionMode,
apnsTokenHex: String)
apnsTokenHex: String,
gatewayIdentity: PushRelayGatewayIdentity)
async throws -> PushRelayRegisterResponse {
let challenge = try await self.fetchChallenge()
let signedPayload = PushRelayRegisterSignedPayload(
@@ -242,6 +245,7 @@ final class PushRelayClient: @unchecked Sendable {
bundleId: bundleId,
environment: environment.rawValue,
distribution: distribution.rawValue,
gateway: gatewayIdentity,
appVersion: appVersion,
apnsToken: apnsTokenHex)
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
@@ -255,6 +259,7 @@ final class PushRelayClient: @unchecked Sendable {
bundleId: signedPayload.bundleId,
environment: signedPayload.environment,
distribution: signedPayload.distribution,
gateway: signedPayload.gateway,
appVersion: signedPayload.appVersion,
apnsToken: signedPayload.apnsToken,
appAttest: PushRelayAppAttestPayload(

View File

@@ -3,6 +3,7 @@ import Foundation
private struct StoredPushRelayRegistrationState: Codable {
var relayHandle: String
var sendGrant: String
var gatewayDeviceId: String
var relayHandleExpiresAtMs: Int64?
var tokenDebugSuffix: String?
var lastAPNsTokenHashHex: String
@@ -19,6 +20,7 @@ enum PushRelayRegistrationStore {
struct RegistrationState: Codable {
var relayHandle: String
var sendGrant: String
var gatewayDeviceId: String
var relayHandleExpiresAtMs: Int64?
var tokenDebugSuffix: String?
var lastAPNsTokenHashHex: String
@@ -38,6 +40,7 @@ enum PushRelayRegistrationStore {
return RegistrationState(
relayHandle: decoded.relayHandle,
sendGrant: decoded.sendGrant,
gatewayDeviceId: decoded.gatewayDeviceId,
relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
tokenDebugSuffix: decoded.tokenDebugSuffix,
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
@@ -50,6 +53,7 @@ enum PushRelayRegistrationStore {
let stored = StoredPushRelayRegistrationState(
relayHandle: state.relayHandle,
sendGrant: state.sendGrant,
gatewayDeviceId: state.gatewayDeviceId,
relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
tokenDebugSuffix: state.tokenDebugSuffix,
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,

View File

@@ -75,6 +75,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"cron.list",
"cron.status",
"cron.runs",
"gateway.identity.get",
"system-presence",
"last-heartbeat",
"node.list",

View File

@@ -91,6 +91,7 @@ const BASE_METHODS = [
"cron.remove",
"cron.run",
"cron.runs",
"gateway.identity.get",
"system-presence",
"system-event",
"send",

View File

@@ -1,4 +1,8 @@
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../../infra/device-identity.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
@@ -8,6 +12,17 @@ import { broadcastPresenceSnapshot } from "../server/presence-events.js";
import type { GatewayRequestHandlers } from "./types.js";
export const systemHandlers: GatewayRequestHandlers = {
"gateway.identity.get": ({ respond }) => {
const identity = loadOrCreateDeviceIdentity();
respond(
true,
{
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
},
undefined,
);
},
"last-heartbeat": ({ respond }) => {
respond(true, getLastHeartbeatEvent(), undefined);
},

View File

@@ -26,6 +26,13 @@ const buildSessionLookup = (
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const registerApnsRegistrationMock = vi.hoisted(() => vi.fn());
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
vi.fn(() => ({
deviceId: "gateway-device-1",
publicKeyPem: "public",
privateKeyPem: "private",
})),
);
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
@@ -47,6 +54,9 @@ vi.mock("../config/sessions.js", () => ({
vi.mock("../infra/push-apns.js", () => ({
registerApnsRegistration: registerApnsRegistrationMock,
}));
vi.mock("../infra/device-identity.js", () => ({
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
}));
vi.mock("./session-utils.js", () => ({
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
pruneLegacyStoreKeys: vi.fn(),
@@ -104,6 +114,7 @@ describe("node exec events", () => {
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
registerApnsRegistrationVi.mockClear();
loadOrCreateDeviceIdentityMock.mockClear();
});
it("enqueues exec.started events", async () => {
@@ -291,6 +302,7 @@ describe("node exec events", () => {
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
gatewayDeviceId: "gateway-device-1",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -311,6 +323,25 @@ describe("node exec events", () => {
tokenDebugSuffix: "abcd1234",
});
});
it("rejects relay registrations bound to a different gateway identity", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-relay", {
event: "push.apns.register",
payloadJSON: JSON.stringify({
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
gatewayDeviceId: "gateway-device-other",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
}),
});
expect(registerApnsRegistrationVi).not.toHaveBeenCalled();
});
});
describe("voice transcript events", () => {

View File

@@ -4,6 +4,7 @@ import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
import { agentCommandFromIngress } from "../commands/agent.js";
import { loadConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
@@ -594,6 +595,15 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
const environment = obj.environment;
try {
if (transport === "relay") {
const gatewayDeviceId =
typeof obj.gatewayDeviceId === "string" ? obj.gatewayDeviceId.trim() : "";
const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) {
ctx.logGateway.warn(
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
);
return;
}
await registerApnsRegistration({
nodeId,
transport: "relay",

View File

@@ -1,5 +1,10 @@
import { URL } from "node:url";
import type { GatewayConfig } from "../config/types.gateway.js";
import {
loadOrCreateDeviceIdentity,
signDevicePayload,
type DeviceIdentity,
} from "./device-identity.js";
export type ApnsRelayPushType = "alert" | "background";
@@ -25,12 +30,19 @@ export type ApnsRelayRequestSender = (params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
gatewayDeviceId: string;
signature: string;
signedAtMs: number;
bodyJson: string;
pushType: ApnsRelayPushType;
priority: "10" | "5";
payload: object;
}) => Promise<ApnsRelayPushResponse>;
const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000;
const GATEWAY_DEVICE_ID_HEADER = "x-openclaw-gateway-device-id";
const GATEWAY_SIGNATURE_HEADER = "x-openclaw-gateway-signature";
const GATEWAY_SIGNED_AT_HEADER = "x-openclaw-gateway-signed-at-ms";
function normalizeNonEmptyString(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
@@ -69,6 +81,19 @@ function parseReason(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function buildRelayGatewaySignaturePayload(params: {
gatewayDeviceId: string;
signedAtMs: number;
bodyJson: string;
}): string {
return [
"openclaw-relay-send-v1",
params.gatewayDeviceId.trim(),
String(Math.trunc(params.signedAtMs)),
params.bodyJson,
].join("\n");
}
export function resolveApnsRelayConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
gatewayConfig?: GatewayConfig,
@@ -132,6 +157,10 @@ async function sendApnsRelayRequest(params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
gatewayDeviceId: string;
signature: string;
signedAtMs: number;
bodyJson: string;
pushType: ApnsRelayPushType;
priority: "10" | "5";
payload: object;
@@ -142,13 +171,11 @@ async function sendApnsRelayRequest(params: {
headers: {
authorization: `Bearer ${params.sendGrant}`,
"content-type": "application/json",
[GATEWAY_DEVICE_ID_HEADER]: params.gatewayDeviceId,
[GATEWAY_SIGNATURE_HEADER]: params.signature,
[GATEWAY_SIGNED_AT_HEADER]: String(params.signedAtMs),
},
body: JSON.stringify({
relayHandle: params.relayHandle,
pushType: params.pushType,
priority: Number(params.priority),
payload: params.payload,
}),
body: params.bodyJson,
signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
});
if (response.status >= 300 && response.status < 400) {
@@ -192,13 +219,34 @@ export async function sendApnsRelayPush(params: {
pushType: ApnsRelayPushType;
priority: "10" | "5";
payload: object;
gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsRelayPushResponse> {
const sender = params.requestSender ?? sendApnsRelayRequest;
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const bodyJson = JSON.stringify({
relayHandle: params.relayHandle,
pushType: params.pushType,
priority: Number(params.priority),
payload: params.payload,
});
const signature = signDevicePayload(
gatewayIdentity.privateKeyPem,
buildRelayGatewaySignaturePayload({
gatewayDeviceId: gatewayIdentity.deviceId,
signedAtMs,
bodyJson,
}),
);
return await sender({
relayConfig: params.relayConfig,
sendGrant: params.sendGrant,
relayHandle: params.relayHandle,
gatewayDeviceId: gatewayIdentity.deviceId,
signature,
signedAtMs,
bodyJson,
pushType: params.pushType,
priority: params.priority,
payload: params.payload,

View File

@@ -3,6 +3,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deriveDeviceIdFromPublicKey,
publicKeyRawBase64UrlFromPem,
verifyDeviceSignature,
} from "./device-identity.js";
import {
clearApnsRegistration,
clearApnsRegistrationIfCurrent,
@@ -23,6 +28,20 @@ const tempDirs: string[] = [];
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" })
.toString();
const relayGatewayIdentity = (() => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
if (!deviceId) {
throw new Error("failed to derive test gateway device id");
}
return {
deviceId,
publicKey: publicKeyRaw,
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
};
})();
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
@@ -545,12 +564,14 @@ describe("push APNs send semantics", () => {
nodeId: "ios-node-relay",
title: "Wake",
body: "Ping",
relayGatewayIdentity: relayGatewayIdentity,
relayRequestSender: send,
});
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0]?.[0]).toMatchObject({
relayHandle: "relay-handle-123",
gatewayDeviceId: relayGatewayIdentity.deviceId,
pushType: "alert",
priority: "10",
payload: {
@@ -560,6 +581,18 @@ describe("push APNs send semantics", () => {
},
},
});
const sent = send.mock.calls[0]?.[0];
expect(typeof sent?.signature).toBe("string");
expect(typeof sent?.signedAtMs).toBe("number");
const signedPayload = [
"openclaw-relay-send-v1",
sent?.gatewayDeviceId,
String(sent?.signedAtMs),
sent?.bodyJson,
].join("\n");
expect(
verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature),
).toBe(true);
expect(result).toMatchObject({
ok: true,
status: 200,
@@ -587,6 +620,7 @@ describe("push APNs send semantics", () => {
payload: { aps: { "content-available": 1 } },
pushType: "background",
priority: "5",
gatewayIdentity: relayGatewayIdentity,
});
expect(fetchMock).toHaveBeenCalledTimes(1);

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import http2 from "node:http2";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import type { DeviceIdentity } from "./device-identity.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
import {
type ApnsRelayConfig,
@@ -826,6 +827,7 @@ async function sendRelayApnsPush(params: {
payload: object;
pushType: ApnsPushType;
priority: "10" | "5";
gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsPushResult> {
const response = await sendApnsRelayPush({
@@ -835,6 +837,7 @@ async function sendRelayApnsPush(params: {
payload: params.payload,
pushType: params.pushType,
priority: params.priority,
gatewayIdentity: params.gatewayIdentity,
requestSender: params.requestSender,
});
return toPushResult({ registration: params.registration, response });
@@ -888,6 +891,7 @@ type RelayApnsAlertParams = ApnsAlertCommonParams & {
registration: RelayApnsRegistration;
relayConfig: ApnsRelayConfig;
relayRequestSender?: ApnsRelayRequestSender;
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
auth?: never;
requestSender?: never;
};
@@ -910,6 +914,7 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
registration: RelayApnsRegistration;
relayConfig: ApnsRelayConfig;
relayRequestSender?: ApnsRelayRequestSender;
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
auth?: never;
requestSender?: never;
};
@@ -931,6 +936,7 @@ export async function sendApnsAlert(
payload,
pushType: "alert",
priority: "10",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
});
}
@@ -962,6 +968,7 @@ export async function sendApnsBackgroundWake(
payload,
pushType: "background",
priority: "5",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
});
}