@soyeht/soyeht 0.2.10 → 0.2.12

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.10",
8
+ "version": "0.2.12",
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.10",
3
+ "version": "0.2.12",
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,3 +1,4 @@
1
+ import { createHash, randomBytes } from "node:crypto";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
3
  import type { OpenClawPluginApi, PluginRuntimeChannel } from "openclaw/plugin-sdk";
3
4
  import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
@@ -8,14 +9,22 @@ import type { SecurityV2Deps } from "./service.js";
8
9
  import { PLUGIN_VERSION } from "./version.js";
9
10
  import {
10
11
  base64UrlDecode,
12
+ base64UrlEncode,
11
13
  computeFingerprint,
14
+ ed25519Sign,
12
15
  generateX25519KeyPair,
13
16
  importEd25519PublicKey,
14
17
  importX25519PublicKey,
15
18
  ed25519Verify,
16
19
  isTimestampValid,
17
20
  } from "./crypto.js";
18
- import { buildPairingProofTranscript } from "./pairing.js";
21
+ import {
22
+ buildPairingProofTranscript,
23
+ buildPairingQrTranscript,
24
+ buildPairingQrTranscriptV2,
25
+ resolveGatewayUrl,
26
+ } from "./pairing.js";
27
+ import { renderQrTerminal } from "./qr.js";
19
28
  import {
20
29
  buildHandshakeTranscript,
21
30
  signHandshakeTranscript,
@@ -69,6 +78,11 @@ export type ProcessInboundResult =
69
78
  | { ok: true; plaintext: string; accountId: string; envelope: EnvelopeV2 }
70
79
  | { ok: false; status: number; error: string };
71
80
 
81
+ // Short hash for safe diagnostic logging (no key material leaked)
82
+ function diagHash(buf: Buffer): string {
83
+ return createHash("sha256").update(buf).digest("hex").slice(0, 8);
84
+ }
85
+
72
86
  export function processInboundEnvelope(
73
87
  api: OpenClawPluginApi,
74
88
  v2deps: SecurityV2Deps,
@@ -83,6 +97,7 @@ export function processInboundEnvelope(
83
97
  const accountId = hintedAccountId ?? envelopeAccountId;
84
98
  const session = v2deps.sessions.get(accountId);
85
99
  if (!session) {
100
+ api.logger.warn("[soyeht] DIAG: no session for account", { accountId, knownAccounts: [...v2deps.sessions.keys()] });
86
101
  return { ok: false, status: 401, error: "session_required" };
87
102
  }
88
103
 
@@ -94,9 +109,41 @@ export function processInboundEnvelope(
94
109
  return { ok: false, status: 401, error: "account_mismatch" };
95
110
  }
96
111
 
112
+ // --- Diagnostic logging: envelope + session state before decrypt ---
113
+ let ivLen = 0, ctLen = 0, tagLen = 0;
114
+ let b64DecodeError: string | undefined;
115
+ try {
116
+ ivLen = base64UrlDecode(envelope.iv).length;
117
+ ctLen = base64UrlDecode(envelope.ciphertext).length;
118
+ tagLen = base64UrlDecode(envelope.tag).length;
119
+ } catch (e) {
120
+ b64DecodeError = e instanceof Error ? e.message : String(e);
121
+ }
122
+
123
+ api.logger.info("[soyeht] DIAG inbound", {
124
+ accountId,
125
+ peerExists: v2deps.peers.has(accountId),
126
+ rootKeyHash: diagHash(session.rootKey),
127
+ recvChainKeyHash: diagHash(session.receiving.chainKey),
128
+ sendChainKeyHash: diagHash(session.sending.chainKey),
129
+ sessionRecvCounter: session.receiving.counter,
130
+ sessionSendCounter: session.sending.counter,
131
+ envelopeVersion: envelope.v,
132
+ envelopeDirection: envelope.direction,
133
+ envelopeCounter: envelope.counter,
134
+ envelopeTimestamp: envelope.timestamp,
135
+ hasDhRatchetKey: Boolean(envelope.dhRatchetKey),
136
+ ivLen,
137
+ ctLen,
138
+ tagLen,
139
+ b64DecodeError: b64DecodeError ?? "none",
140
+ aad: `${envelope.v}|${envelope.accountId}|${envelope.direction}|${envelope.counter}|${envelope.timestamp}`,
141
+ });
142
+ // --- End diagnostic logging ---
143
+
97
144
  const validation = validateEnvelopeV2(envelope, session);
98
145
  if (!validation.valid) {
99
- api.logger.warn("[soyeht] Envelope validation failed", { error: validation.error, accountId });
146
+ api.logger.warn("[soyeht] DIAG validation failed", { error: validation.error, accountId });
100
147
  return { ok: false, status: 401, error: validation.error };
101
148
  }
102
149
 
@@ -112,10 +159,23 @@ export function processInboundEnvelope(
112
159
  updatedSession = result.updatedSession;
113
160
  } catch (err) {
114
161
  const msg = err instanceof Error ? err.message : "decryption_failed";
115
- api.logger.warn("[soyeht] Envelope decryption failed", { error: msg, accountId });
162
+ const isAuthFailure = msg.includes("authenticate data") || msg.includes("auth");
163
+ api.logger.error("[soyeht] DIAG decrypt FAILED", {
164
+ accountId,
165
+ error: msg,
166
+ isGcmAuthFailure: isAuthFailure,
167
+ envelopeCounter: envelope.counter,
168
+ sessionRecvCounter: session.receiving.counter,
169
+ hasDhRatchetKey: Boolean(envelope.dhRatchetKey),
170
+ ivLen,
171
+ ctLen,
172
+ tagLen,
173
+ });
116
174
  return { ok: false, status: 401, error: msg };
117
175
  }
118
176
 
177
+ api.logger.info("[soyeht] DIAG decrypt OK", { accountId, plaintextLen: plaintext.length });
178
+
119
179
  // Update session
120
180
  v2deps.sessions.set(accountId, updatedSession);
121
181
 
@@ -537,6 +597,103 @@ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): vo
537
597
  }
538
598
  }
539
599
 
600
+ // ---------------------------------------------------------------------------
601
+ // GET /soyeht/pairing/start — generate a pairing QR via HTTP (no RPC needed)
602
+ // ---------------------------------------------------------------------------
603
+
604
+ const DEFAULT_PAIRING_TTL_MS = 90_000;
605
+
606
+ export function pairingStartHandler(
607
+ api: OpenClawPluginApi,
608
+ v2deps: SecurityV2Deps,
609
+ ) {
610
+ return async (req: IncomingMessage, res: ServerResponse) => {
611
+ if (req.method !== "GET") {
612
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
613
+ return;
614
+ }
615
+
616
+ if (!v2deps.ready || !v2deps.identity) {
617
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
618
+ return;
619
+ }
620
+
621
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
622
+ const accountId = normalizeAccountId(url.searchParams.get("accountId") ?? "default");
623
+ const allowOverwrite = url.searchParams.get("allowOverwrite") !== "false";
624
+ const format = url.searchParams.get("format") ?? "json";
625
+
626
+ const { allowed } = v2deps.rateLimiter.check(`pairing:start:http:${accountId}`);
627
+ if (!allowed) {
628
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
629
+ return;
630
+ }
631
+
632
+ if (v2deps.peers.has(accountId) && !allowOverwrite) {
633
+ sendJson(res, 409, { ok: false, error: "peer_already_paired" });
634
+ return;
635
+ }
636
+
637
+ // Clear stale pairing sessions for this account
638
+ for (const [token, session] of v2deps.pairingSessions) {
639
+ if (session.accountId === accountId) {
640
+ v2deps.pairingSessions.delete(token);
641
+ }
642
+ }
643
+
644
+ const pairingToken = base64UrlEncode(randomBytes(32));
645
+ const expiresAt = Date.now() + DEFAULT_PAIRING_TTL_MS;
646
+ const fingerprint = computeFingerprint(v2deps.identity);
647
+
648
+ const cfg = await api.runtime.config.loadConfig();
649
+ const account = resolveSoyehtAccount(cfg, accountId);
650
+ const gatewayUrl = resolveGatewayUrl(api, account.gatewayUrl);
651
+
652
+ const basePayload = {
653
+ accountId,
654
+ pairingToken,
655
+ expiresAt,
656
+ allowOverwrite,
657
+ pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
658
+ pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
659
+ fingerprint,
660
+ };
661
+
662
+ let qrPayload: Record<string, unknown>;
663
+ if (gatewayUrl) {
664
+ const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
665
+ const signature = base64UrlEncode(ed25519Sign(v2deps.identity.signKey.privateKey, transcript));
666
+ qrPayload = { version: 2, type: "soyeht_pairing_qr", gatewayUrl, ...basePayload, signature };
667
+ } else {
668
+ const transcript = buildPairingQrTranscript(basePayload);
669
+ const signature = base64UrlEncode(ed25519Sign(v2deps.identity.signKey.privateKey, transcript));
670
+ qrPayload = { version: 1, type: "soyeht_pairing_qr", ...basePayload, signature };
671
+ }
672
+
673
+ v2deps.pairingSessions.set(pairingToken, {
674
+ token: pairingToken,
675
+ accountId,
676
+ expiresAt,
677
+ allowOverwrite,
678
+ });
679
+
680
+ const qrUrl = `soyeht://pair?g=${gatewayUrl}&t=${pairingToken}&fp=${fingerprint}`;
681
+ api.logger.info("[soyeht] Pairing started via HTTP", { accountId, expiresAt });
682
+
683
+ if (format === "terminal") {
684
+ const rendered = renderQrTerminal(qrUrl);
685
+ const text = rendered
686
+ ? `\n${rendered}\n\n${qrUrl}\n\nExpires in ${DEFAULT_PAIRING_TTL_MS / 1000}s\n`
687
+ : `QR too large for terminal.\n\n${qrUrl}\n\nExpires in ${DEFAULT_PAIRING_TTL_MS / 1000}s\n`;
688
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
689
+ res.end(text);
690
+ return;
691
+ }
692
+
693
+ sendJson(res, 200, { ok: true, qrUrl, qrPayload, expiresAt });
694
+ };
695
+ }
696
+
540
697
  // ---------------------------------------------------------------------------
541
698
  // GET /soyeht/pairing/info?t=<pairingToken>
542
699
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  livekitTokenHandler,
16
16
  inboundHandler,
17
17
  sseHandler,
18
+ pairingStartHandler,
18
19
  pairingInfoHandler,
19
20
  pairingPairHandler,
20
21
  pairingFinishHandler,
@@ -131,6 +132,11 @@ const soyehtPlugin: OpenClawPluginDefinition = {
131
132
  });
132
133
 
133
134
  // HTTP pairing routes (app pairs via HTTP, no WebSocket needed)
135
+ api.registerHttpRoute({
136
+ path: "/soyeht/pairing/start",
137
+ auth: "plugin",
138
+ handler: pairingStartHandler(api, v2deps),
139
+ });
134
140
  api.registerHttpRoute({
135
141
  path: "/soyeht/pairing/info",
136
142
  auth: "plugin",
package/src/pairing.ts CHANGED
@@ -119,7 +119,7 @@ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): vo
119
119
  // Resolve the gateway URL for QR V2
120
120
  // ---------------------------------------------------------------------------
121
121
 
122
- function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): string {
122
+ export function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): string {
123
123
  // 1) Explicit config
124
124
  if (configGatewayUrl) return configGatewayUrl;
125
125
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.10";
1
+ export const PLUGIN_VERSION = "0.2.12";