@omnixal/openclaw-nats-plugin 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  import { Controller, BaseController, Subscribe, OnQueueReady, type Message } from '@onebun/core';
2
2
  import { PipelineService } from '../pre-handlers/pipeline.service';
3
- import { GatewayClientService } from '../gateway/gateway-client.service';
3
+ import { GatewayClientService, GatewayRpcError } from '../gateway/gateway-client.service';
4
4
  import { PendingService } from '../pending/pending.service';
5
5
  import { RouterService } from '../router/router.service';
6
6
  import { MetricsService } from '../metrics/metrics.service';
@@ -73,6 +73,13 @@ export class ConsumerController extends BaseController {
73
73
  await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify({ eventId: envelope.id, target: route.target }));
74
74
  } catch (routeErr) {
75
75
  await this.logService.logError('route', route.id, envelope.subject, routeErr);
76
+ // Gateway rejected the request (e.g. missing scope) — store in pending, don't nack
77
+ if (routeErr instanceof GatewayRpcError) {
78
+ this.logger.error(`Gateway rejected event ${envelope.id}: ${routeErr.errorCode} — ${routeErr.errorMessage}`);
79
+ await this.pendingService.addPending(envelope);
80
+ await message.ack();
81
+ return;
82
+ }
76
83
  throw routeErr;
77
84
  }
78
85
  }
@@ -0,0 +1,146 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export interface DeviceIdentity {
6
+ deviceId: string;
7
+ publicKeyPem: string;
8
+ privateKeyPem: string;
9
+ }
10
+
11
+ interface StoredIdentity {
12
+ version: 1;
13
+ deviceId: string;
14
+ publicKeyPem: string;
15
+ privateKeyPem: string;
16
+ createdAtMs: number;
17
+ }
18
+
19
+ export interface SignChallengeParams {
20
+ deviceId: string;
21
+ clientId: string;
22
+ clientMode: string;
23
+ role: string;
24
+ scopes: string[];
25
+ signedAtMs: number;
26
+ token: string;
27
+ nonce: string;
28
+ platform?: string;
29
+ deviceFamily?: string;
30
+ }
31
+
32
+ /** Ed25519 SPKI DER prefix (12 bytes) before the 32-byte raw public key */
33
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
34
+
35
+ function base64UrlEncode(buf: Buffer): string {
36
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
37
+ }
38
+
39
+ /** Extract raw 32-byte Ed25519 public key from PEM-encoded SPKI */
40
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
41
+ const key = crypto.createPublicKey(publicKeyPem);
42
+ const spki = key.export({ type: 'spki', format: 'der' }) as Buffer;
43
+ if (
44
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
45
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
46
+ ) {
47
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
48
+ }
49
+ return spki;
50
+ }
51
+
52
+ /** Derive deviceId as SHA-256(rawPublicKey).hex — matches OpenClaw's deriveDeviceIdFromPublicKey */
53
+ function fingerprintPublicKey(publicKeyPem: string): string {
54
+ const raw = derivePublicKeyRaw(publicKeyPem);
55
+ return crypto.createHash('sha256').update(raw).digest('hex');
56
+ }
57
+
58
+ /** Normalize metadata for auth payload — ASCII lowercase + trim (matches OpenClaw's normalizeDeviceMetadataForAuth) */
59
+ function normalizeMetadata(value?: string | null): string {
60
+ if (typeof value !== 'string') return '';
61
+ const trimmed = value.trim();
62
+ if (!trimmed) return '';
63
+ return trimmed.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
64
+ }
65
+
66
+ /** Generate a new Ed25519 keypair and derive device identity */
67
+ export function generateIdentity(): DeviceIdentity {
68
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
69
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
70
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
71
+ const deviceId = fingerprintPublicKey(publicKeyPem);
72
+ return { deviceId, publicKeyPem, privateKeyPem };
73
+ }
74
+
75
+ /** Load existing device identity from file, or generate and persist a new one */
76
+ export function loadOrCreateIdentity(filePath: string): DeviceIdentity {
77
+ try {
78
+ if (fs.existsSync(filePath)) {
79
+ const raw = fs.readFileSync(filePath, 'utf8');
80
+ const parsed = JSON.parse(raw) as StoredIdentity;
81
+ if (
82
+ parsed?.version === 1 &&
83
+ typeof parsed.deviceId === 'string' &&
84
+ typeof parsed.publicKeyPem === 'string' &&
85
+ typeof parsed.privateKeyPem === 'string'
86
+ ) {
87
+ // Re-derive deviceId to handle corrupted stored IDs
88
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
89
+ return {
90
+ deviceId: derivedId,
91
+ publicKeyPem: parsed.publicKeyPem,
92
+ privateKeyPem: parsed.privateKeyPem,
93
+ };
94
+ }
95
+ }
96
+ } catch {
97
+ // fall through to regenerate
98
+ }
99
+
100
+ const identity = generateIdentity();
101
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
102
+ const stored: StoredIdentity = {
103
+ version: 1,
104
+ deviceId: identity.deviceId,
105
+ publicKeyPem: identity.publicKeyPem,
106
+ privateKeyPem: identity.privateKeyPem,
107
+ createdAtMs: Date.now(),
108
+ };
109
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
110
+ return identity;
111
+ }
112
+
113
+ /** Get base64url-encoded raw public key (for the connect frame device.publicKey field) */
114
+ export function publicKeyToBase64Url(publicKeyPem: string): string {
115
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
116
+ }
117
+
118
+ /**
119
+ * Build v3 signature payload and sign it with Ed25519 private key.
120
+ * Returns base64url-encoded signature.
121
+ *
122
+ * v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
123
+ */
124
+ export function signChallenge(privateKeyPem: string, params: SignChallengeParams): string {
125
+ const scopes = params.scopes.join(',');
126
+ const token = params.token ?? '';
127
+ const platform = normalizeMetadata(params.platform);
128
+ const deviceFamily = normalizeMetadata(params.deviceFamily);
129
+ const payload = [
130
+ 'v3',
131
+ params.deviceId,
132
+ params.clientId,
133
+ params.clientMode,
134
+ params.role,
135
+ scopes,
136
+ String(params.signedAtMs),
137
+ token,
138
+ params.nonce,
139
+ platform,
140
+ deviceFamily,
141
+ ].join('|');
142
+
143
+ const key = crypto.createPrivateKey(privateKeyPem);
144
+ const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
145
+ return base64UrlEncode(sig);
146
+ }
@@ -1,4 +1,6 @@
1
1
  import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
+ import path from 'node:path';
3
+ import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
2
4
 
3
5
  export interface GatewayInjectPayload {
4
6
  target: string;
@@ -40,12 +42,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
40
42
  private requestId = 0;
41
43
  private wsUrl!: string;
42
44
  private token!: string;
45
+ private identity!: DeviceIdentity;
46
+ private publicKeyBase64Url!: string;
47
+ private challengeNonce = '';
43
48
  private pendingRequests = new Map<string, PendingRequest>();
44
49
 
45
50
  async onModuleInit(): Promise<void> {
46
51
  this.wsUrl = this.config.get('gateway.wsUrl');
47
52
  this.token = this.config.get('gateway.token');
48
53
  if (this.wsUrl && this.token) {
54
+ const dbPath = this.config.get('database.url');
55
+ const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
56
+ this.identity = loadOrCreateIdentity(identityPath);
57
+ this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
58
+ this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
49
59
  this.connect();
50
60
  } else {
51
61
  this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
@@ -99,7 +109,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
99
109
 
100
110
  // Server challenge — respond with connect frame
101
111
  if (frame.type === 'event' && frame.event === 'connect.challenge') {
102
- this.logger.debug('Received connect.challenge from server');
112
+ this.challengeNonce = frame.payload?.nonce ?? '';
113
+ this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
103
114
  this.sendConnectFrame();
104
115
  return;
105
116
  }
@@ -117,7 +128,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
117
128
  if (payload?.type === 'hello-ok') {
118
129
  if (!this.connected) {
119
130
  this.connected = true;
120
- this.logger.info('OpenClaw handshake complete connected');
131
+ const grantedScopes = payload.auth?.scopes ?? [];
132
+ const serverVersion = payload.server?.version ?? 'unknown';
133
+ this.logger.info('OpenClaw handshake complete', {
134
+ protocol: payload.protocol,
135
+ serverVersion,
136
+ grantedScopes,
137
+ connId: payload.server?.connId,
138
+ });
139
+ if (grantedScopes.length > 0 && !grantedScopes.includes('operator.write')) {
140
+ this.logger.error(
141
+ `Gateway did NOT grant operator.write scope! Granted: [${grantedScopes.join(', ')}]. ` +
142
+ 'Message delivery will fail. Rotate the device token with --scope operator.write',
143
+ );
144
+ }
121
145
  }
122
146
  return;
123
147
  }
@@ -149,33 +173,59 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
149
173
  private sendConnectFrame(): void {
150
174
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
151
175
  this.connectSent = true;
152
- this.logger.info('Sending connect frame');
176
+
177
+ const signedAt = Date.now();
178
+ const scopes = ['operator.read', 'operator.write'];
179
+ const client = {
180
+ id: 'gateway-client' as const,
181
+ displayName: 'nats-sidecar',
182
+ version: '1.0.0',
183
+ platform: 'linux',
184
+ mode: 'backend' as const,
185
+ };
186
+
187
+ const signature = signChallenge(this.identity.privateKeyPem, {
188
+ deviceId: this.identity.deviceId,
189
+ clientId: client.id,
190
+ clientMode: client.mode,
191
+ role: 'operator',
192
+ scopes,
193
+ signedAtMs: signedAt,
194
+ token: this.token,
195
+ nonce: this.challengeNonce,
196
+ platform: client.platform,
197
+ });
198
+
199
+ this.logger.info('Sending connect frame with device identity', {
200
+ deviceId: this.identity.deviceId,
201
+ });
153
202
 
154
203
  try {
155
204
  this.send({
156
- type: 'req',
157
- id: `connect-${++this.requestId}`,
158
- method: 'connect',
159
- params: {
160
- minProtocol: 3,
161
- maxProtocol: 3,
162
- client: {
163
- id: 'gateway-client',
164
- displayName: 'nats-sidecar',
165
- version: '1.0.0',
166
- platform: 'linux',
167
- mode: 'backend',
205
+ type: 'req',
206
+ id: `connect-${++this.requestId}`,
207
+ method: 'connect',
208
+ params: {
209
+ minProtocol: 3,
210
+ maxProtocol: 3,
211
+ client,
212
+ role: 'operator',
213
+ scopes,
214
+ caps: [],
215
+ commands: [],
216
+ permissions: {},
217
+ auth: { token: this.token },
218
+ device: {
219
+ id: this.identity.deviceId,
220
+ publicKey: this.publicKeyBase64Url,
221
+ signature,
222
+ signedAt,
223
+ nonce: this.challengeNonce,
224
+ },
225
+ locale: 'en-US',
226
+ userAgent: 'nats-sidecar/1.0.0',
168
227
  },
169
- role: 'operator',
170
- scopes: ['operator.read', 'operator.write'],
171
- caps: [],
172
- commands: [],
173
- permissions: {},
174
- auth: { token: this.token },
175
- locale: 'en-US',
176
- userAgent: 'nats-sidecar/1.0.0',
177
- },
178
- });
228
+ });
179
229
  } catch (err) {
180
230
  this.logger.error('Failed to send connect frame', err);
181
231
  this.connectSent = false;