@soyeht/soyeht 0.2.2 → 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.2",
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.2",
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,
@@ -127,6 +130,23 @@ const soyehtPlugin: OpenClawPluginDefinition = {
127
130
  handler: sseHandler(api, v2deps),
128
131
  });
129
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
+
130
150
  // Background service (manages state lifecycle)
131
151
  api.registerService(createSoyehtService(api, v2deps));
132
152
 
package/src/service.ts CHANGED
@@ -15,6 +15,7 @@ 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
+ import { resolveSoyehtAccount } from "./config.js";
18
19
 
19
20
  const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
20
21
 
@@ -90,14 +91,10 @@ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Pr
90
91
  const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
91
92
  const fingerprint = computeFingerprint(identity);
92
93
 
93
- // Resolve gatewayUrl from config or runtime
94
- let gatewayUrl = "";
95
- const runtime = api.runtime as Record<string, unknown>;
96
- if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
97
- gatewayUrl = runtime["gatewayUrl"];
98
- } else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
99
- gatewayUrl = runtime["baseUrl"];
100
- }
94
+ // Resolve gatewayUrl from config
95
+ const cfg = await api.runtime.config.loadConfig();
96
+ const account = resolveSoyehtAccount(cfg, accountId);
97
+ const gatewayUrl = account.gatewayUrl;
101
98
 
102
99
  v2deps.pairingSessions.set(pairingToken, {
103
100
  token: pairingToken,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.2";
1
+ export const PLUGIN_VERSION = "0.2.3";