mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-21 23:11:01 +00:00
feat(push): bind relay registration to gateway identity
This commit is contained in:
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,6 +91,7 @@ const BASE_METHODS = [
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
"gateway.identity.get",
|
||||
"system-presence",
|
||||
"system-event",
|
||||
"send",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user