@soyeht/soyeht 0.1.1

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/src/service.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { readFile, writeFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
4
+ import {
5
+ createNonceCache,
6
+ createRateLimiter,
7
+ type NonceCache,
8
+ type RateLimiter,
9
+ } from "./security.js";
10
+ import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
11
+ import { zeroBuffer } from "./ratchet.js";
12
+ import { computeFingerprint, type X25519KeyPair } from "./crypto.js";
13
+ import type { IdentityBundle, PeerIdentity } from "./identity.js";
14
+ import type { RatchetState } from "./ratchet.js";
15
+
16
+ const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // V2 deps (lazy — filled by service.start())
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type SecurityV2Deps = {
23
+ identity: IdentityBundle | null;
24
+ peers: Map<string, PeerIdentity>;
25
+ sessions: Map<string, RatchetState>;
26
+ pairingSessions: Map<string, PairingSession>;
27
+ pendingHandshakes: Map<string, PendingHandshake>;
28
+ nonceCache: NonceCache;
29
+ rateLimiter: RateLimiter;
30
+ ready: boolean;
31
+ stateDir?: string;
32
+ };
33
+
34
+ export type PairingSession = {
35
+ token: string;
36
+ accountId: string;
37
+ expiresAt: number;
38
+ allowOverwrite: boolean;
39
+ };
40
+
41
+ export type PendingHandshake = {
42
+ key: string;
43
+ accountId: string;
44
+ nonce: string;
45
+ appEphemeralKey: string;
46
+ pluginEphemeralKey: X25519KeyPair;
47
+ transcript: Buffer;
48
+ challengeExpiresAt: number;
49
+ sessionExpiresAt: number;
50
+ };
51
+
52
+ export function createSecurityV2Deps(): SecurityV2Deps {
53
+ return {
54
+ identity: null,
55
+ peers: new Map(),
56
+ sessions: new Map(),
57
+ pairingSessions: new Map(),
58
+ pendingHandshakes: new Map(),
59
+ nonceCache: createNonceCache(),
60
+ rateLimiter: createRateLimiter(),
61
+ ready: false,
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Service
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export function createSoyehtService(
70
+ api: OpenClawPluginApi,
71
+ v2deps?: SecurityV2Deps,
72
+ ): OpenClawPluginService {
73
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
74
+
75
+ return {
76
+ id: "soyeht",
77
+
78
+ async start(ctx?: OpenClawPluginServiceContext) {
79
+ if (v2deps && ctx?.stateDir) {
80
+ try {
81
+ v2deps.stateDir = ctx.stateDir;
82
+ v2deps.identity = await loadOrGenerateIdentity(ctx.stateDir);
83
+
84
+ // Detect identity change — clear stale peers/sessions if fingerprint changed
85
+ const currentFp = computeFingerprint(v2deps.identity);
86
+ const fpPath = join(ctx.stateDir, "soyeht-security", ".fingerprint");
87
+ const storedFp = await readFile(fpPath, "utf8").catch(() => null);
88
+ if (storedFp && storedFp !== currentFp) {
89
+ v2deps.peers.clear();
90
+ v2deps.sessions.clear();
91
+ // Delete stale files from disk so they are not reloaded
92
+ const peersDir = join(ctx.stateDir, "soyeht-security", "peers");
93
+ const sessionsDir = join(ctx.stateDir, "soyeht-security", "sessions");
94
+ await rm(peersDir, { recursive: true, force: true }).catch(() => {});
95
+ await rm(sessionsDir, { recursive: true, force: true }).catch(() => {});
96
+ api.logger.warn("[soyeht] Identity changed — cleared peers and sessions from memory and disk");
97
+ }
98
+ await writeFile(fpPath, currentFp, "utf8").catch(() => {});
99
+
100
+ const peers = await loadPeers(ctx.stateDir);
101
+ peers.forEach((p, id) => v2deps.peers.set(id, p));
102
+ const sessions = await loadSessions(ctx.stateDir);
103
+ sessions.forEach((s, id) => v2deps.sessions.set(id, s));
104
+ } catch (err) {
105
+ api.logger.error("[soyeht] Failed to load V2 state", { err });
106
+ }
107
+ }
108
+ if (v2deps) v2deps.ready = true;
109
+
110
+ api.logger.info("[soyeht] Service started");
111
+
112
+ heartbeatTimer = setInterval(async () => {
113
+ api.logger.debug("[soyeht] Heartbeat tick");
114
+ if (v2deps) {
115
+ const now = Date.now();
116
+ v2deps.nonceCache.prune();
117
+ v2deps.rateLimiter.prune();
118
+ for (const [token, session] of v2deps.pairingSessions) {
119
+ if (session.expiresAt <= now) {
120
+ v2deps.pairingSessions.delete(token);
121
+ }
122
+ }
123
+ for (const [key, pending] of v2deps.pendingHandshakes) {
124
+ if (pending.challengeExpiresAt <= now) {
125
+ v2deps.pendingHandshakes.delete(key);
126
+ }
127
+ }
128
+ // Persist sessions
129
+ if (v2deps.stateDir) {
130
+ for (const [, s] of v2deps.sessions) {
131
+ await saveSession(v2deps.stateDir, s).catch((err) => {
132
+ api.logger.error("[soyeht] Failed to persist session", { accountId: s.accountId, err });
133
+ });
134
+ }
135
+ }
136
+ }
137
+ }, HEARTBEAT_INTERVAL_MS);
138
+
139
+ if (heartbeatTimer && typeof heartbeatTimer === "object" && "unref" in heartbeatTimer) {
140
+ heartbeatTimer.unref();
141
+ }
142
+ },
143
+
144
+ async stop(ctx?: OpenClawPluginServiceContext) {
145
+ if (heartbeatTimer) {
146
+ clearInterval(heartbeatTimer);
147
+ heartbeatTimer = undefined;
148
+ }
149
+
150
+ // Persist final state
151
+ const stateDir = v2deps?.stateDir ?? ctx?.stateDir;
152
+ if (v2deps && stateDir) {
153
+ for (const [, s] of v2deps.sessions) {
154
+ await saveSession(stateDir, s).catch((err) => {
155
+ api.logger.error("[soyeht] Failed to persist session on stop", { accountId: s.accountId, err });
156
+ });
157
+ }
158
+ }
159
+
160
+ // Zeroize sensitive material
161
+ if (v2deps) {
162
+ for (const s of v2deps.sessions.values()) {
163
+ zeroBuffer(s.rootKey);
164
+ zeroBuffer(s.sending.chainKey);
165
+ zeroBuffer(s.receiving.chainKey);
166
+ }
167
+ v2deps.sessions.clear();
168
+ v2deps.peers.clear();
169
+ v2deps.pairingSessions.clear();
170
+ v2deps.pendingHandshakes.clear();
171
+ v2deps.ready = false;
172
+ }
173
+
174
+ api.logger.info("[soyeht] Service stopped");
175
+ },
176
+ };
177
+ }
package/src/types.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Account config schema (per-account settings under channels.soyeht.accounts.*)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export const SoyehtAccountConfigSchema = Type.Object({
8
+ enabled: Type.Optional(Type.Boolean()),
9
+ backendBaseUrl: Type.Optional(Type.String()),
10
+ pluginAuthToken: Type.Optional(Type.String()),
11
+ allowProactive: Type.Optional(Type.Boolean()),
12
+ audio: Type.Optional(
13
+ Type.Object({
14
+ transcribeInbound: Type.Optional(Type.Boolean()),
15
+ ttsOutbound: Type.Optional(Type.Boolean()),
16
+ }),
17
+ ),
18
+ files: Type.Optional(
19
+ Type.Object({
20
+ acceptInbound: Type.Optional(Type.Boolean()),
21
+ maxBytes: Type.Optional(Type.Number()),
22
+ }, { additionalProperties: false }),
23
+ ),
24
+ security: Type.Optional(
25
+ Type.Object({
26
+ enabled: Type.Optional(Type.Boolean()),
27
+ timestampToleranceMs: Type.Optional(Type.Number()),
28
+ dhRatchetIntervalMessages: Type.Optional(Type.Number()),
29
+ dhRatchetIntervalMs: Type.Optional(Type.Number()),
30
+ sessionMaxAgeMs: Type.Optional(Type.Number()),
31
+ rateLimit: Type.Optional(
32
+ Type.Object({
33
+ maxRequests: Type.Optional(Type.Number()),
34
+ windowMs: Type.Optional(Type.Number()),
35
+ }, { additionalProperties: false }),
36
+ ),
37
+ }, { additionalProperties: false }),
38
+ ),
39
+ }, { additionalProperties: false });
40
+
41
+ export type SoyehtAccountConfig = Static<typeof SoyehtAccountConfigSchema>;
42
+
43
+ export const SoyehtChannelConfigSchema = Type.Object({
44
+ accounts: Type.Optional(Type.Record(Type.String(), SoyehtAccountConfigSchema)),
45
+ }, { additionalProperties: false });
46
+
47
+ export type SoyehtChannelConfig = Static<typeof SoyehtChannelConfigSchema>;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Outbound message payloads
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export const TextMessagePayloadSchema = Type.Object({
54
+ contentType: Type.Literal("text"),
55
+ text: Type.String({ minLength: 1 }),
56
+ });
57
+
58
+ export type TextMessagePayload = Static<typeof TextMessagePayloadSchema>;
59
+
60
+ export const AudioMessagePayloadSchema = Type.Object({
61
+ contentType: Type.Literal("audio"),
62
+ renderStyle: Type.Literal("voice_note"),
63
+ mimeType: Type.String(),
64
+ filename: Type.String(),
65
+ durationMs: Type.Optional(Type.Number()),
66
+ url: Type.String(),
67
+ transcript: Type.Optional(Type.String()),
68
+ });
69
+
70
+ export type AudioMessagePayload = Static<typeof AudioMessagePayloadSchema>;
71
+
72
+ export const FileMessagePayloadSchema = Type.Object({
73
+ contentType: Type.Literal("file"),
74
+ mimeType: Type.String(),
75
+ filename: Type.String(),
76
+ sizeBytes: Type.Optional(Type.Number()),
77
+ url: Type.String(),
78
+ });
79
+
80
+ export type FileMessagePayload = Static<typeof FileMessagePayloadSchema>;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Outbound envelope (wraps any payload for backend delivery)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export const OutboundEnvelopeSchema = Type.Object({
87
+ accountId: Type.String(),
88
+ sessionId: Type.String(),
89
+ deliveryId: Type.String(),
90
+ timestamp: Type.Number(),
91
+ message: Type.Union([
92
+ TextMessagePayloadSchema,
93
+ AudioMessagePayloadSchema,
94
+ FileMessagePayloadSchema,
95
+ ]),
96
+ });
97
+
98
+ export type OutboundEnvelope = Static<typeof OutboundEnvelopeSchema>;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Delivery ack (webhook response from backend)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export const DeliveryAckSchema = Type.Object({
105
+ deliveryId: Type.String(),
106
+ status: Type.Union([Type.Literal("delivered"), Type.Literal("failed")]),
107
+ error: Type.Optional(Type.String()),
108
+ });
109
+
110
+ export type DeliveryAck = Static<typeof DeliveryAckSchema>;
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Security schemas
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export const HandshakeInitV2Schema = Type.Object({
117
+ version: Type.Literal(2),
118
+ accountId: Type.String(),
119
+ appEphemeralKey: Type.String(), // X25519 pub raw b64url
120
+ nonce: Type.String(),
121
+ timestamp: Type.Number(),
122
+ });
123
+
124
+ export type HandshakeInitV2 = Static<typeof HandshakeInitV2Schema>;
125
+
126
+ export const HandshakeResponseV2Schema = Type.Object({
127
+ version: Type.Literal(2),
128
+ phase: Type.Literal("init"),
129
+ complete: Type.Boolean(),
130
+ pluginEphemeralKey: Type.String(), // X25519 pub raw b64url
131
+ nonce: Type.String(),
132
+ timestamp: Type.Number(),
133
+ serverTimestamp: Type.Number(),
134
+ challengeExpiresAt: Type.Number(),
135
+ expiresAt: Type.Number(),
136
+ pluginSignature: Type.String(), // Ed25519 sig over transcript, b64url
137
+ });
138
+
139
+ export type HandshakeResponseV2 = Static<typeof HandshakeResponseV2Schema>;
140
+
141
+ export const EnvelopeV2Schema = Type.Object({
142
+ v: Type.Literal(2),
143
+ accountId: Type.String(),
144
+ direction: Type.Union([Type.Literal("plugin_to_app"), Type.Literal("app_to_plugin")]),
145
+ counter: Type.Number(),
146
+ timestamp: Type.Number(),
147
+ dhRatchetKey: Type.Optional(Type.String()),
148
+ ciphertext: Type.String(),
149
+ iv: Type.String(),
150
+ tag: Type.String(),
151
+ });
152
+
153
+ export type EnvelopeV2Type = Static<typeof EnvelopeV2Schema>;
154
+
155
+ export const PairRequestSchema = Type.Object({
156
+ accountId: Type.String(),
157
+ pairingToken: Type.String(),
158
+ appIdentityKey: Type.String(), // Ed25519 pub raw b64url
159
+ appDhKey: Type.String(), // X25519 pub raw b64url
160
+ appSignature: Type.String(), // Ed25519 signature over pairing proof transcript
161
+ });
162
+
163
+ export type PairRequest = Static<typeof PairRequestSchema>;
164
+
165
+ export const PairingQrPayloadSchema = Type.Object({
166
+ version: Type.Literal(1),
167
+ type: Type.Literal("soyeht_pairing_qr"),
168
+ accountId: Type.String(),
169
+ pairingToken: Type.String(),
170
+ expiresAt: Type.Number(),
171
+ allowOverwrite: Type.Boolean(),
172
+ pluginIdentityKey: Type.String(),
173
+ pluginDhKey: Type.String(),
174
+ fingerprint: Type.String(),
175
+ signature: Type.String(),
176
+ });
177
+
178
+ export type PairingQrPayload = Static<typeof PairingQrPayloadSchema>;
179
+
180
+ export const HandshakeFinishV2Schema = Type.Object({
181
+ version: Type.Literal(2),
182
+ accountId: Type.String(),
183
+ nonce: Type.String(),
184
+ appSignature: Type.String(),
185
+ });
186
+
187
+ export type HandshakeFinishV2 = Static<typeof HandshakeFinishV2Schema>;
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Channel capabilities
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export const SOYEHT_CAPABILITIES = {
194
+ chatTypes: ["direct"] as const,
195
+ media: true,
196
+ livekit: false,
197
+ voiceContractVersion: 1,
198
+ pipeline: "stt->llm->tts" as const,
199
+ supportedContentTypes: ["text", "audio", "file"] as const,
200
+ security: {
201
+ version: 2,
202
+ pairingMode: "qr_token" as const,
203
+ handshakeMode: "two_step" as const,
204
+ fingerprintAlgorithm: "SHA256-trunc-16bytes-hex",
205
+ algorithms: {
206
+ keyAgreement: "X3DH (X25519)",
207
+ keyDerivation: "HKDF-SHA256",
208
+ authentication: "Ed25519",
209
+ encryption: "AES-256-GCM",
210
+ ratchet: "Double Ratchet (symmetric + DH)",
211
+ },
212
+ },
213
+ } as const;
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const PLUGIN_VERSION = "0.1.1";
package/src/x3dh.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import {
3
+ computeSharedSecret,
4
+ hkdfDerive,
5
+ hkdfDeriveSync,
6
+ ed25519Sign,
7
+ ed25519Verify,
8
+ base64UrlEncode,
9
+ base64UrlDecode,
10
+ type X25519KeyPair,
11
+ } from "./crypto.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // X3DH key agreement (plugin is always the responder)
15
+ //
16
+ // Triple DH (from plugin/responder perspective):
17
+ // DH1 = X25519(pluginStaticDhPriv, appEphemeralPub)
18
+ // DH2 = X25519(pluginEphemeralPriv, appStaticDhPub)
19
+ // DH3 = X25519(pluginEphemeralPriv, appEphemeralPub)
20
+ // ikm = DH1 || DH2 || DH3
21
+ // root = HKDF(ikm, nonce_bytes, "soyeht-v2-root", 32)
22
+ // send = HKDF(root, 0x, "soyeht-v2-chain-send", 32) [plugin→app]
23
+ // recv = HKDF(root, 0x, "soyeht-v2-chain-recv", 32) [app→plugin]
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export type X3DHResult = {
27
+ rootKey: Buffer;
28
+ sendChainKey: Buffer; // plugin's send = app's recv
29
+ recvChainKey: Buffer; // plugin's recv = app's send
30
+ };
31
+
32
+ export async function performX3DH(params: {
33
+ myStaticDhKey: X25519KeyPair; // plugin's static DH key pair
34
+ myEphemeralKey: X25519KeyPair; // plugin's ephemeral DH key pair (just generated)
35
+ peerStaticDhPub: KeyObject; // app's static DH pub (from pairing)
36
+ peerEphemeralPub: KeyObject; // app's ephemeral DH pub (from handshake init)
37
+ nonce: string; // handshake nonce (b64url) — used as HKDF salt
38
+ }): Promise<X3DHResult> {
39
+ const { myStaticDhKey, myEphemeralKey, peerStaticDhPub, peerEphemeralPub, nonce } = params;
40
+
41
+ const dh1 = computeSharedSecret(myStaticDhKey.privateKey, peerEphemeralPub);
42
+ const dh2 = computeSharedSecret(myEphemeralKey.privateKey, peerStaticDhPub);
43
+ const dh3 = computeSharedSecret(myEphemeralKey.privateKey, peerEphemeralPub);
44
+
45
+ const ikm = Buffer.concat([dh1, dh2, dh3]);
46
+ const salt = base64UrlDecode(nonce);
47
+
48
+ const rootKey = await hkdfDerive(ikm, salt, "soyeht-v2-root", 32);
49
+
50
+ const empty = Buffer.alloc(0);
51
+ const sendChainKey = hkdfDeriveSync(rootKey, empty, "soyeht-v2-chain-send", 32);
52
+ const recvChainKey = hkdfDeriveSync(rootKey, empty, "soyeht-v2-chain-recv", 32);
53
+
54
+ // Zero intermediate secrets
55
+ dh1.fill(0);
56
+ dh2.fill(0);
57
+ dh3.fill(0);
58
+ ikm.fill(0);
59
+
60
+ return { rootKey, sendChainKey, recvChainKey };
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Handshake transcripts
65
+ //
66
+ // The app signs a subset (it doesn't know pluginEphKey or expiresAt yet).
67
+ // The plugin signs the full transcript after generating its ephemeral key.
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** What the app signs — only values known at request time. */
71
+ export function buildAppHandshakeTranscript(params: {
72
+ accountId: string;
73
+ appEphKeyB64: string;
74
+ nonce: string;
75
+ timestamp: number;
76
+ }): Buffer {
77
+ const { accountId, appEphKeyB64, nonce, timestamp } = params;
78
+ return Buffer.from(`x3dh_v2|${accountId}|${appEphKeyB64}|${nonce}|${timestamp}`, "utf8");
79
+ }
80
+
81
+ /** Full transcript — signed by the plugin, verified by the app. */
82
+ export function buildHandshakeTranscript(params: {
83
+ accountId: string;
84
+ appEphKeyB64: string;
85
+ pluginEphKeyB64: string;
86
+ nonce: string;
87
+ timestamp: number;
88
+ expiresAt: number;
89
+ }): Buffer {
90
+ const { accountId, appEphKeyB64, pluginEphKeyB64, nonce, timestamp, expiresAt } = params;
91
+ const str = `x3dh_v2|${accountId}|${appEphKeyB64}|${pluginEphKeyB64}|${nonce}|${timestamp}|${expiresAt}`;
92
+ return Buffer.from(str, "utf8");
93
+ }
94
+
95
+ export function signHandshakeTranscript(privKey: KeyObject, transcript: Buffer): string {
96
+ return base64UrlEncode(ed25519Sign(privKey, transcript));
97
+ }
98
+
99
+ export function verifyHandshakeTranscript(
100
+ pubKey: KeyObject,
101
+ transcript: Buffer,
102
+ sigB64: string,
103
+ ): boolean {
104
+ return ed25519Verify(pubKey, transcript, base64UrlDecode(sigB64));
105
+ }