@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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/http.ts +160 -3
- package/src/index.ts +6 -0
- package/src/pairing.ts +1 -1
- package/src/version.ts +1 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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 {
|
|
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]
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const PLUGIN_VERSION = "0.2.12";
|