@soyeht/soyeht 0.2.1 → 0.2.3

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.
@@ -5,7 +5,7 @@
5
5
  ],
6
6
  "name": "Soyeht",
7
7
  "description": "Channel plugin for the Soyeht Flutter mobile app",
8
- "version": "0.2.1",
8
+ "version": "0.2.3",
9
9
  "configSchema": {
10
10
  "type": "object",
11
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soyeht/soyeht",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/http.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
- import { normalizeAccountId } from "./config.js";
3
+ import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
4
4
  import { decryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
5
- import { cloneRatchetSession } from "./ratchet.js";
5
+ import { cloneRatchetSession, zeroBuffer, type RatchetState } from "./ratchet.js";
6
6
  import type { SecurityV2Deps } from "./service.js";
7
7
  import { PLUGIN_VERSION } from "./version.js";
8
+ import {
9
+ base64UrlDecode,
10
+ computeFingerprint,
11
+ generateX25519KeyPair,
12
+ importEd25519PublicKey,
13
+ importX25519PublicKey,
14
+ ed25519Verify,
15
+ isTimestampValid,
16
+ } from "./crypto.js";
17
+ import { buildPairingProofTranscript } from "./pairing.js";
18
+ import {
19
+ buildHandshakeTranscript,
20
+ signHandshakeTranscript,
21
+ verifyHandshakeTranscript,
22
+ performX3DH,
23
+ } from "./x3dh.js";
24
+ import { savePeer, saveSession, deleteSession, type PeerIdentity } from "./identity.js";
8
25
 
9
26
  // ---------------------------------------------------------------------------
10
27
  // Helpers
@@ -341,6 +358,442 @@ export function sseHandler(
341
358
  };
342
359
  }
343
360
 
361
+ // ---------------------------------------------------------------------------
362
+ // Helpers for HTTP pairing
363
+ // ---------------------------------------------------------------------------
364
+
365
+ function isWellFormedNonce(nonce: string): boolean {
366
+ try {
367
+ const decoded = base64UrlDecode(nonce);
368
+ return decoded.length >= 16 && decoded.length <= 64;
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): void {
375
+ const existing = v2deps.sessions.get(accountId);
376
+ if (existing) {
377
+ zeroBuffer(existing.rootKey);
378
+ zeroBuffer(existing.sending.chainKey);
379
+ zeroBuffer(existing.receiving.chainKey);
380
+ v2deps.sessions.delete(accountId);
381
+ }
382
+ for (const [key, pending] of v2deps.pendingHandshakes) {
383
+ if (pending.accountId === accountId) {
384
+ v2deps.pendingHandshakes.delete(key);
385
+ }
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // GET /soyeht/pairing/info?t=<pairingToken>
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export function pairingInfoHandler(
394
+ _api: OpenClawPluginApi,
395
+ v2deps: SecurityV2Deps,
396
+ ) {
397
+ return async (req: IncomingMessage, res: ServerResponse) => {
398
+ if (req.method !== "GET") {
399
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
400
+ return;
401
+ }
402
+
403
+ if (!v2deps.ready || !v2deps.identity) {
404
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
405
+ return;
406
+ }
407
+
408
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
409
+ const pairingToken = url.searchParams.get("t") ?? "";
410
+ if (!pairingToken) {
411
+ sendJson(res, 400, { ok: false, error: "missing_pairing_token" });
412
+ return;
413
+ }
414
+
415
+ const { allowed } = v2deps.rateLimiter.check(`pairing:info:${pairingToken}`);
416
+ if (!allowed) {
417
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
418
+ return;
419
+ }
420
+
421
+ const session = v2deps.pairingSessions.get(pairingToken);
422
+ if (!session) {
423
+ sendJson(res, 404, { ok: false, error: "pairing_not_found" });
424
+ return;
425
+ }
426
+
427
+ if (session.expiresAt <= Date.now()) {
428
+ sendJson(res, 410, { ok: false, error: "pairing_expired" });
429
+ return;
430
+ }
431
+
432
+ sendJson(res, 200, {
433
+ ok: true,
434
+ accountId: session.accountId,
435
+ pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
436
+ pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
437
+ fingerprint: computeFingerprint(v2deps.identity),
438
+ expiresAt: session.expiresAt,
439
+ allowOverwrite: session.allowOverwrite,
440
+ });
441
+ };
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // POST /soyeht/pairing/pair — register peer + start handshake in one step
446
+ // ---------------------------------------------------------------------------
447
+
448
+ const PAIRING_HANDSHAKE_TOLERANCE_MS = 120_000; // 2 minutes for HTTP round-trip
449
+
450
+ export function pairingPairHandler(
451
+ api: OpenClawPluginApi,
452
+ v2deps: SecurityV2Deps,
453
+ ) {
454
+ return async (req: IncomingMessage, res: ServerResponse) => {
455
+ if (req.method !== "POST") {
456
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
457
+ return;
458
+ }
459
+
460
+ if (!v2deps.ready || !v2deps.identity) {
461
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
462
+ return;
463
+ }
464
+
465
+ let body: Record<string, unknown>;
466
+ try {
467
+ const raw = await readRawBodyBuffer(req);
468
+ body = JSON.parse(raw.toString("utf8"));
469
+ } catch {
470
+ sendJson(res, 400, { ok: false, error: "invalid_json" });
471
+ return;
472
+ }
473
+
474
+ const pairingToken = body["pairingToken"] as string | undefined;
475
+ const accountId = normalizeAccountId(body["accountId"] as string | undefined);
476
+ const appIdentityKey = body["appIdentityKey"] as string | undefined;
477
+ const appDhKey = body["appDhKey"] as string | undefined;
478
+ const appSignature = body["appSignature"] as string | undefined;
479
+ const appEphemeralKey = body["appEphemeralKey"] as string | undefined;
480
+ const nonce = body["nonce"] as string | undefined;
481
+ const timestamp = body["timestamp"] as number | undefined;
482
+
483
+ if (!pairingToken || !appIdentityKey || !appDhKey || !appSignature ||
484
+ !appEphemeralKey || !nonce || typeof timestamp !== "number") {
485
+ sendJson(res, 400, { ok: false, error: "missing_params" });
486
+ return;
487
+ }
488
+
489
+ const { allowed } = v2deps.rateLimiter.check(`pairing:pair:${accountId}`);
490
+ if (!allowed) {
491
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
492
+ return;
493
+ }
494
+
495
+ // --- Validate pairing session ---
496
+
497
+ const pairingSession = v2deps.pairingSessions.get(pairingToken);
498
+ if (!pairingSession) {
499
+ sendJson(res, 404, { ok: false, error: "pairing_not_found" });
500
+ return;
501
+ }
502
+
503
+ if (pairingSession.expiresAt <= Date.now()) {
504
+ v2deps.pairingSessions.delete(pairingToken);
505
+ sendJson(res, 410, { ok: false, error: "pairing_expired" });
506
+ return;
507
+ }
508
+
509
+ if (pairingSession.accountId !== accountId) {
510
+ sendJson(res, 403, { ok: false, error: "account_mismatch" });
511
+ return;
512
+ }
513
+
514
+ // --- Validate keys ---
515
+
516
+ let appIdentityPub;
517
+ try {
518
+ appIdentityPub = importEd25519PublicKey(appIdentityKey);
519
+ } catch {
520
+ sendJson(res, 400, { ok: false, error: "invalid_identity_key" });
521
+ return;
522
+ }
523
+
524
+ try {
525
+ importX25519PublicKey(appDhKey);
526
+ } catch {
527
+ sendJson(res, 400, { ok: false, error: "invalid_dh_key" });
528
+ return;
529
+ }
530
+
531
+ try {
532
+ importX25519PublicKey(appEphemeralKey);
533
+ } catch {
534
+ sendJson(res, 400, { ok: false, error: "invalid_ephemeral_key" });
535
+ return;
536
+ }
537
+
538
+ // --- Verify pairing signature ---
539
+
540
+ const proofTranscript = buildPairingProofTranscript({
541
+ accountId,
542
+ pairingToken,
543
+ expiresAt: pairingSession.expiresAt,
544
+ appIdentityKey,
545
+ appDhKey,
546
+ });
547
+ if (!ed25519Verify(appIdentityPub, proofTranscript, base64UrlDecode(appSignature))) {
548
+ sendJson(res, 403, { ok: false, error: "invalid_signature" });
549
+ return;
550
+ }
551
+
552
+ // --- Validate nonce + timestamp ---
553
+
554
+ if (!isWellFormedNonce(nonce)) {
555
+ sendJson(res, 400, { ok: false, error: "invalid_nonce" });
556
+ return;
557
+ }
558
+
559
+ if (!isTimestampValid(timestamp, PAIRING_HANDSHAKE_TOLERANCE_MS)) {
560
+ sendJson(res, 400, { ok: false, error: "timestamp_out_of_range" });
561
+ return;
562
+ }
563
+
564
+ if (!v2deps.nonceCache.add(`pairing:http:${accountId}:${nonce}`)) {
565
+ sendJson(res, 409, { ok: false, error: "nonce_reused" });
566
+ return;
567
+ }
568
+
569
+ // --- Check existing peer ---
570
+
571
+ if (v2deps.peers.has(accountId) && !pairingSession.allowOverwrite) {
572
+ sendJson(res, 409, { ok: false, error: "peer_already_paired" });
573
+ return;
574
+ }
575
+
576
+ // --- Register peer (clear old state first) ---
577
+
578
+ clearAccountSessionState(v2deps, accountId);
579
+ if (v2deps.stateDir) {
580
+ await deleteSession(v2deps.stateDir, accountId).catch(() => {});
581
+ }
582
+
583
+ const peer: PeerIdentity = {
584
+ accountId,
585
+ identityKeyB64: appIdentityKey,
586
+ dhKeyB64: appDhKey,
587
+ };
588
+ v2deps.peers.set(accountId, peer);
589
+ v2deps.pairingSessions.delete(pairingToken);
590
+
591
+ if (v2deps.stateDir) {
592
+ await savePeer(v2deps.stateDir, peer).catch(() => {});
593
+ }
594
+
595
+ // --- Start handshake ---
596
+
597
+ const cfg = await api.runtime.config.loadConfig();
598
+ const account = resolveSoyehtAccount(cfg, accountId);
599
+ const now = Date.now();
600
+ const challengeExpiresAt = now + PAIRING_HANDSHAKE_TOLERANCE_MS;
601
+ const sessionExpiresAt = now + account.security.sessionMaxAgeMs;
602
+ const pluginEphemeralKey = generateX25519KeyPair();
603
+
604
+ const transcript = buildHandshakeTranscript({
605
+ accountId,
606
+ appEphKeyB64: appEphemeralKey,
607
+ pluginEphKeyB64: pluginEphemeralKey.publicKeyB64,
608
+ nonce,
609
+ timestamp,
610
+ expiresAt: sessionExpiresAt,
611
+ });
612
+ const pluginSignature = signHandshakeTranscript(
613
+ v2deps.identity.signKey.privateKey,
614
+ transcript,
615
+ );
616
+
617
+ const pendingKey = `${accountId}:${nonce}`;
618
+ v2deps.pendingHandshakes.set(pendingKey, {
619
+ key: pendingKey,
620
+ accountId,
621
+ nonce,
622
+ appEphemeralKey,
623
+ pluginEphemeralKey,
624
+ transcript,
625
+ challengeExpiresAt,
626
+ sessionExpiresAt,
627
+ });
628
+
629
+ api.logger.info("[soyeht] HTTP pairing + handshake init completed", { accountId });
630
+
631
+ sendJson(res, 200, {
632
+ ok: true,
633
+ accountId,
634
+ pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
635
+ pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
636
+ fingerprint: computeFingerprint(v2deps.identity),
637
+ pluginEphemeralKey: pluginEphemeralKey.publicKeyB64,
638
+ pluginSignature,
639
+ nonce,
640
+ timestamp,
641
+ serverTimestamp: now,
642
+ challengeExpiresAt,
643
+ sessionExpiresAt,
644
+ });
645
+ };
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // POST /soyeht/pairing/finish — complete handshake, derive session, return streamToken
650
+ // ---------------------------------------------------------------------------
651
+
652
+ export function pairingFinishHandler(
653
+ api: OpenClawPluginApi,
654
+ v2deps: SecurityV2Deps,
655
+ ) {
656
+ return async (req: IncomingMessage, res: ServerResponse) => {
657
+ if (req.method !== "POST") {
658
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
659
+ return;
660
+ }
661
+
662
+ if (!v2deps.ready || !v2deps.identity) {
663
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
664
+ return;
665
+ }
666
+
667
+ let body: Record<string, unknown>;
668
+ try {
669
+ const raw = await readRawBodyBuffer(req);
670
+ body = JSON.parse(raw.toString("utf8"));
671
+ } catch {
672
+ sendJson(res, 400, { ok: false, error: "invalid_json" });
673
+ return;
674
+ }
675
+
676
+ const accountId = normalizeAccountId(body["accountId"] as string | undefined);
677
+ const nonce = body["nonce"] as string | undefined;
678
+ const appSignature = body["appSignature"] as string | undefined;
679
+
680
+ if (!nonce || !appSignature) {
681
+ sendJson(res, 400, { ok: false, error: "missing_params" });
682
+ return;
683
+ }
684
+
685
+ const { allowed } = v2deps.rateLimiter.check(`pairing:finish:${accountId}`);
686
+ if (!allowed) {
687
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
688
+ return;
689
+ }
690
+
691
+ const pendingKey = `${accountId}:${nonce}`;
692
+ const pending = v2deps.pendingHandshakes.get(pendingKey);
693
+ if (!pending) {
694
+ sendJson(res, 404, { ok: false, error: "handshake_not_found" });
695
+ return;
696
+ }
697
+
698
+ if (pending.challengeExpiresAt <= Date.now()) {
699
+ v2deps.pendingHandshakes.delete(pendingKey);
700
+ sendJson(res, 410, { ok: false, error: "handshake_expired" });
701
+ return;
702
+ }
703
+
704
+ const peer = v2deps.peers.get(accountId);
705
+ if (!peer) {
706
+ sendJson(res, 404, { ok: false, error: "peer_not_found" });
707
+ return;
708
+ }
709
+
710
+ let appIdentityPub;
711
+ try {
712
+ appIdentityPub = importEd25519PublicKey(peer.identityKeyB64);
713
+ } catch {
714
+ sendJson(res, 500, { ok: false, error: "invalid_peer_key" });
715
+ return;
716
+ }
717
+
718
+ if (!verifyHandshakeTranscript(appIdentityPub, pending.transcript, appSignature)) {
719
+ sendJson(res, 403, { ok: false, error: "invalid_signature" });
720
+ return;
721
+ }
722
+
723
+ let appEphemeralPub;
724
+ try {
725
+ appEphemeralPub = importX25519PublicKey(pending.appEphemeralKey);
726
+ } catch {
727
+ v2deps.pendingHandshakes.delete(pendingKey);
728
+ sendJson(res, 500, { ok: false, error: "invalid_ephemeral_key" });
729
+ return;
730
+ }
731
+
732
+ let peerStaticDhPub;
733
+ try {
734
+ peerStaticDhPub = importX25519PublicKey(peer.dhKeyB64);
735
+ } catch {
736
+ sendJson(res, 500, { ok: false, error: "invalid_peer_dh_key" });
737
+ return;
738
+ }
739
+
740
+ const x3dhResult = await performX3DH({
741
+ myStaticDhKey: v2deps.identity.dhKey,
742
+ myEphemeralKey: pending.pluginEphemeralKey,
743
+ peerStaticDhPub,
744
+ peerEphemeralPub: appEphemeralPub,
745
+ nonce,
746
+ });
747
+
748
+ // Clear any existing session for this account
749
+ const existingSession = v2deps.sessions.get(accountId);
750
+ if (existingSession) {
751
+ zeroBuffer(existingSession.rootKey);
752
+ zeroBuffer(existingSession.sending.chainKey);
753
+ zeroBuffer(existingSession.receiving.chainKey);
754
+ v2deps.sessions.delete(accountId);
755
+ }
756
+
757
+ const session: RatchetState = {
758
+ accountId,
759
+ rootKey: x3dhResult.rootKey,
760
+ sending: { chainKey: x3dhResult.sendChainKey, counter: 0 },
761
+ receiving: { chainKey: x3dhResult.recvChainKey, counter: 0 },
762
+ myCurrentEphDhKey: pending.pluginEphemeralKey,
763
+ peerLastEphDhKeyB64: pending.appEphemeralKey,
764
+ dhRatchetSendCount: 0,
765
+ dhRatchetRecvCount: 0,
766
+ createdAt: Date.now(),
767
+ expiresAt: pending.sessionExpiresAt,
768
+ };
769
+
770
+ v2deps.sessions.set(accountId, session);
771
+ v2deps.pendingHandshakes.delete(pendingKey);
772
+
773
+ if (v2deps.stateDir) {
774
+ await saveSession(v2deps.stateDir, session).catch((err) => {
775
+ api.logger.error("[soyeht] Failed to persist session", { accountId, err });
776
+ });
777
+ }
778
+
779
+ // Issue stream token for SSE auth
780
+ v2deps.outboundQueue.revokeStreamTokensForAccount(accountId);
781
+ const streamToken = v2deps.outboundQueue.createStreamToken(
782
+ accountId,
783
+ pending.sessionExpiresAt,
784
+ );
785
+
786
+ api.logger.info("[soyeht] HTTP pairing handshake completed", { accountId });
787
+
788
+ sendJson(res, 200, {
789
+ ok: true,
790
+ complete: true,
791
+ sessionExpiresAt: pending.sessionExpiresAt,
792
+ streamToken,
793
+ });
794
+ };
795
+ }
796
+
344
797
  // ---------------------------------------------------------------------------
345
798
  // POST /soyeht/livekit/token — stub
346
799
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  livekitTokenHandler,
16
16
  inboundHandler,
17
17
  sseHandler,
18
+ pairingInfoHandler,
19
+ pairingPairHandler,
20
+ pairingFinishHandler,
18
21
  } from "./http.js";
19
22
  import {
20
23
  createSoyehtService,
@@ -23,6 +26,7 @@ import {
23
26
  import {
24
27
  handleSecurityIdentity,
25
28
  handleSecurityPair,
29
+ handleSecurityPairingInfo,
26
30
  handleSecurityPairingStart,
27
31
  } from "./pairing.js";
28
32
  import { PLUGIN_VERSION } from "./version.js";
@@ -89,6 +93,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
89
93
  // Security RPC
90
94
  api.registerGatewayMethod("soyeht.security.identity", handleSecurityIdentity(api, v2deps));
91
95
  api.registerGatewayMethod("soyeht.security.pairing.start", handleSecurityPairingStart(api, v2deps));
96
+ api.registerGatewayMethod("soyeht.security.pairing.info", handleSecurityPairingInfo(api, v2deps));
92
97
  api.registerGatewayMethod("soyeht.security.pair", handleSecurityPair(api, v2deps));
93
98
  api.registerGatewayMethod("soyeht.security.handshake.init", handleSecurityHandshake(api, v2deps));
94
99
  api.registerGatewayMethod("soyeht.security.handshake.finish", handleSecurityHandshakeFinish(api, v2deps));
@@ -125,6 +130,23 @@ const soyehtPlugin: OpenClawPluginDefinition = {
125
130
  handler: sseHandler(api, v2deps),
126
131
  });
127
132
 
133
+ // HTTP pairing routes (app pairs via HTTP, no WebSocket needed)
134
+ api.registerHttpRoute({
135
+ path: "/soyeht/pairing/info",
136
+ auth: "plugin",
137
+ handler: pairingInfoHandler(api, v2deps),
138
+ });
139
+ api.registerHttpRoute({
140
+ path: "/soyeht/pairing/pair",
141
+ auth: "plugin",
142
+ handler: pairingPairHandler(api, v2deps),
143
+ });
144
+ api.registerHttpRoute({
145
+ path: "/soyeht/pairing/finish",
146
+ auth: "plugin",
147
+ handler: pairingFinishHandler(api, v2deps),
148
+ });
149
+
128
150
  // Background service (manages state lifecycle)
129
151
  api.registerService(createSoyehtService(api, v2deps));
130
152
 
package/src/pairing.ts CHANGED
@@ -136,6 +136,61 @@ function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): st
136
136
  return "";
137
137
  }
138
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // soyeht.security.pairing.info — return session data for a given pairing token
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export function handleSecurityPairingInfo(
144
+ _api: OpenClawPluginApi,
145
+ v2deps: SecurityV2Deps,
146
+ ): GatewayRequestHandler {
147
+ return async ({ params, respond }) => {
148
+ if (!v2deps.ready) {
149
+ respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
150
+ return;
151
+ }
152
+ if (!v2deps.identity) {
153
+ respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
154
+ return;
155
+ }
156
+
157
+ const pairingToken = params["pairingToken"] as string | undefined;
158
+ if (!pairingToken) {
159
+ respond(false, undefined, {
160
+ code: "INVALID_PARAMS",
161
+ message: "Missing required param: pairingToken",
162
+ });
163
+ return;
164
+ }
165
+
166
+ const session = v2deps.pairingSessions.get(pairingToken);
167
+ if (!session) {
168
+ respond(false, undefined, {
169
+ code: "PAIRING_REQUIRED",
170
+ message: "No active pairing session. Scan a fresh QR code first.",
171
+ });
172
+ return;
173
+ }
174
+
175
+ if (session.expiresAt <= Date.now()) {
176
+ respond(false, undefined, {
177
+ code: "PAIRING_EXPIRED",
178
+ message: "Pairing QR expired. Scan a fresh QR code first.",
179
+ });
180
+ return;
181
+ }
182
+
183
+ respond(true, {
184
+ accountId: session.accountId,
185
+ expiresAt: session.expiresAt,
186
+ allowOverwrite: session.allowOverwrite,
187
+ pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
188
+ pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
189
+ fingerprint: computeFingerprint(v2deps.identity),
190
+ });
191
+ };
192
+ }
193
+
139
194
  // ---------------------------------------------------------------------------
140
195
  // soyeht.security.identity — expose plugin public keys
141
196
  // ---------------------------------------------------------------------------
package/src/service.ts CHANGED
@@ -10,13 +10,12 @@ import {
10
10
  } from "./security.js";
11
11
  import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
12
12
  import { zeroBuffer } from "./ratchet.js";
13
- import { base64UrlEncode, computeFingerprint, ed25519Sign, type X25519KeyPair } from "./crypto.js";
13
+ import { base64UrlEncode, computeFingerprint, type X25519KeyPair } from "./crypto.js";
14
14
  import type { IdentityBundle, PeerIdentity } from "./identity.js";
15
15
  import type { RatchetState } from "./ratchet.js";
16
16
  import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
17
17
  import { renderQrTerminal } from "./qr.js";
18
18
  import { resolveSoyehtAccount } from "./config.js";
19
- import { buildPairingQrTranscript, buildPairingQrTranscriptV2 } from "./pairing.js";
20
19
 
21
20
  const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
22
21
 
@@ -92,51 +91,10 @@ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Pr
92
91
  const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
93
92
  const fingerprint = computeFingerprint(identity);
94
93
 
95
- // Resolve gatewayUrl from config or runtime
94
+ // Resolve gatewayUrl from config
96
95
  const cfg = await api.runtime.config.loadConfig();
97
96
  const account = resolveSoyehtAccount(cfg, accountId);
98
- let gatewayUrl = account.gatewayUrl;
99
- if (!gatewayUrl) {
100
- const runtime = api.runtime as Record<string, unknown>;
101
- if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
102
- gatewayUrl = runtime["gatewayUrl"];
103
- } else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
104
- gatewayUrl = runtime["baseUrl"];
105
- }
106
- }
107
-
108
- const basePayload = {
109
- accountId,
110
- pairingToken,
111
- expiresAt,
112
- allowOverwrite: false,
113
- pluginIdentityKey: identity.signKey.publicKeyB64,
114
- pluginDhKey: identity.dhKey.publicKeyB64,
115
- fingerprint,
116
- };
117
-
118
- let qrPayload: Record<string, unknown>;
119
-
120
- if (gatewayUrl) {
121
- const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
122
- const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
123
- qrPayload = {
124
- version: 2,
125
- type: "soyeht_pairing_qr",
126
- gatewayUrl,
127
- ...basePayload,
128
- signature,
129
- };
130
- } else {
131
- const transcript = buildPairingQrTranscript(basePayload);
132
- const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
133
- qrPayload = {
134
- version: 1,
135
- type: "soyeht_pairing_qr",
136
- ...basePayload,
137
- signature,
138
- };
139
- }
97
+ const gatewayUrl = account.gatewayUrl;
140
98
 
141
99
  v2deps.pairingSessions.set(pairingToken, {
142
100
  token: pairingToken,
@@ -145,11 +103,15 @@ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Pr
145
103
  allowOverwrite: false,
146
104
  });
147
105
 
148
- const qrText = JSON.stringify(qrPayload);
106
+ // Compact QR: soyeht://pair?g=<gatewayUrl>&t=<token>&fp=<fingerprint>
107
+ // App fetches full key material via RPC soyeht.security.pairing.info
108
+ const qrText = `soyeht://pair?g=${encodeURIComponent(gatewayUrl)}&t=${pairingToken}&fp=${fingerprint}`;
149
109
  const rendered = renderQrTerminal(qrText);
150
110
 
151
111
  if (rendered) {
152
- api.logger.info("[soyeht] Scan this QR code with the Soyeht app to pair:\n\n" + rendered);
112
+ // Write QR directly to stdout to avoid logger prefixes breaking ANSI escape codes
113
+ process.stdout.write("\n" + rendered + "\n\n");
114
+ api.logger.info(`[soyeht] Scan the QR code above with the Soyeht app to pair`);
153
115
  api.logger.info(`[soyeht] Fingerprint: ${fingerprint}`);
154
116
  api.logger.info(`[soyeht] QR expires in ${AUTO_PAIRING_TTL_MS / 1000}s — restart plugin to generate a new one`);
155
117
  } else {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.1";
1
+ export const PLUGIN_VERSION = "0.2.3";