@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/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Soyeht OpenClaw Plugin
2
+
3
+ Channel plugin for connecting the Soyeht Flutter mobile app to an OpenClaw gateway.
4
+
5
+ ## Install in OpenClaw
6
+
7
+ After this package is published to npm, install it on the OpenClaw host with:
8
+
9
+ ```bash
10
+ openclaw plugins install @soyeht/soyeht@0.1.1 --pin
11
+ openclaw plugins enable soyeht
12
+ ```
13
+
14
+ Then verify:
15
+
16
+ ```bash
17
+ openclaw plugins list
18
+ openclaw plugins info soyeht
19
+ openclaw plugins doctor
20
+ ```
21
+
22
+ ## Minimal configuration
23
+
24
+ Add a Soyeht account under `channels.soyeht.accounts.default`:
25
+
26
+ ```yaml
27
+ channels:
28
+ soyeht:
29
+ accounts:
30
+ default:
31
+ enabled: true
32
+ backendBaseUrl: https://your-backend.example
33
+ pluginAuthToken: your-plugin-auth-token
34
+ security:
35
+ enabled: true
36
+ ```
37
+
38
+ ## Local validation
39
+
40
+ ```bash
41
+ npm ci
42
+ npm run validate
43
+ ```
44
+
45
+ ## Publish to npm
46
+
47
+ 1. Make sure the package name and scope are available on npm.
48
+ 2. Log in:
49
+
50
+ ```bash
51
+ npm login
52
+ ```
53
+
54
+ 3. Bump the version:
55
+
56
+ ```bash
57
+ npm version patch
58
+ ```
59
+
60
+ This also syncs `openclaw.plugin.json`.
61
+
62
+ 4. Publish publicly:
63
+
64
+ ```bash
65
+ npm publish
66
+ ```
67
+
68
+ The package is configured with `publishConfig.access=public`, so the first publish of the scoped package does not need an extra `--access public`.
69
+
70
+ ## Protocol docs
71
+
72
+ - [Protocol](docs/PROTOCOL.md)
73
+ - [Flutter agent prompt](docs/FLUTTER_AGENT_PROMPT.md)
@@ -0,0 +1,388 @@
1
+ # Soyeht Plugin Security Protocol
2
+
3
+ This document describes the pairing and secure-session contract currently implemented by the plugin.
4
+
5
+ ## Scope
6
+
7
+ The security flow has 3 phases:
8
+
9
+ 1. QR pairing bootstrap
10
+ 2. Two-step authenticated X3DH handshake
11
+ 3. Envelope V2 messaging with symmetric and DH ratchet state
12
+
13
+ The canonical implementation lives in:
14
+
15
+ - `src/pairing.ts`
16
+ - `src/rpc.ts`
17
+ - `src/x3dh.ts`
18
+ - `src/envelope-v2.ts`
19
+ - `src/ratchet.ts`
20
+
21
+ ## Encoding Rules
22
+
23
+ - All binary fields are base64url without padding.
24
+ - All timestamps are Unix epoch milliseconds.
25
+ - `accountId` should be normalized to lowercase and trimmed.
26
+ - Nonces must be base64url and decode to 16-64 bytes.
27
+
28
+ ## Long-Term Keys
29
+
30
+ Plugin:
31
+
32
+ - Ed25519 identity key pair
33
+ - X25519 static DH key pair
34
+
35
+ App:
36
+
37
+ - Ed25519 identity key pair
38
+ - X25519 static DH key pair
39
+
40
+ The app should generate and persist its long-term keys locally. Do not send or embed app private keys in QR codes.
41
+
42
+ ## Fingerprint
43
+
44
+ Fingerprint algorithm:
45
+
46
+ - `SHA-256(signPubRaw || dhPubRaw)`
47
+ - truncate to the first 16 bytes
48
+ - hex encode lowercase
49
+
50
+ The plugin implementation is in `src/crypto.ts`.
51
+
52
+ ## RPC Methods
53
+
54
+ ### `soyeht.security.pairing.start`
55
+
56
+ Purpose:
57
+
58
+ - Called by the host/plugin side to create a short-lived pairing payload for QR rendering.
59
+
60
+ Request params:
61
+
62
+ ```json
63
+ {
64
+ "accountId": "default",
65
+ "allowOverwrite": false,
66
+ "ttlMs": 90000
67
+ }
68
+ ```
69
+
70
+ Success payload:
71
+
72
+ ```json
73
+ {
74
+ "qrPayload": {
75
+ "version": 1,
76
+ "type": "soyeht_pairing_qr",
77
+ "accountId": "default",
78
+ "pairingToken": "<base64url-32-bytes>",
79
+ "expiresAt": 1730000000000,
80
+ "allowOverwrite": false,
81
+ "pluginIdentityKey": "<base64url-32-bytes>",
82
+ "pluginDhKey": "<base64url-32-bytes>",
83
+ "fingerprint": "<32-char-hex>",
84
+ "signature": "<base64url-ed25519-signature>"
85
+ },
86
+ "qrText": "{...same JSON stringified...}",
87
+ "expiresAt": 1730000000000
88
+ }
89
+ ```
90
+
91
+ QR signature transcript:
92
+
93
+ ```text
94
+ pairing_qr_v1|{accountId}|{pairingToken}|{expiresAt}|{allowOverwrite?1:0}|{pluginIdentityKey}|{pluginDhKey}|{fingerprint}
95
+ ```
96
+
97
+ The signature is Ed25519 with the plugin identity private key.
98
+
99
+ ### `soyeht.security.pair`
100
+
101
+ Purpose:
102
+
103
+ - Called by the app after scanning the QR to register its public keys and consume the one-time `pairingToken`.
104
+
105
+ Request params:
106
+
107
+ ```json
108
+ {
109
+ "accountId": "default",
110
+ "pairingToken": "<from QR>",
111
+ "appIdentityKey": "<base64url-ed25519-public-key>",
112
+ "appDhKey": "<base64url-x25519-public-key>",
113
+ "appSignature": "<base64url-ed25519-signature>"
114
+ }
115
+ ```
116
+
117
+ App pairing proof transcript:
118
+
119
+ ```text
120
+ pairing_proof_v1|{accountId}|{pairingToken}|{expiresAt}|{appIdentityKey}|{appDhKey}
121
+ ```
122
+
123
+ The app signs this transcript with its Ed25519 identity private key.
124
+
125
+ Success payload:
126
+
127
+ ```json
128
+ {
129
+ "accountId": "default",
130
+ "handshakeRequired": true,
131
+ "pluginIdentityKey": "<base64url-32-bytes>",
132
+ "pluginDhKey": "<base64url-32-bytes>",
133
+ "fingerprint": "<32-char-hex>"
134
+ }
135
+ ```
136
+
137
+ Important behavior:
138
+
139
+ - `pairingToken` is single-use.
140
+ - Expired tokens are rejected.
141
+ - If `allowOverwrite=true`, existing peer and session state for that account may be replaced.
142
+ - On peer replacement, old session state is cleared.
143
+
144
+ ### `soyeht.security.handshake.init`
145
+
146
+ Purpose:
147
+
148
+ - Step 1 of the secure-session bootstrap.
149
+
150
+ Request params:
151
+
152
+ ```json
153
+ {
154
+ "accountId": "default",
155
+ "appEphemeralKey": "<base64url-x25519-public-key>",
156
+ "nonce": "<base64url-random-16-to-64-bytes>",
157
+ "timestamp": 1730000000000
158
+ }
159
+ ```
160
+
161
+ Behavior:
162
+
163
+ - Validates peer exists
164
+ - Validates nonce/timestamp
165
+ - Rejects nonce reuse
166
+ - Generates plugin ephemeral X25519 key
167
+ - Builds the full handshake transcript
168
+ - Signs it with the plugin Ed25519 identity key
169
+ - Stores a pending handshake challenge in memory
170
+
171
+ Success payload:
172
+
173
+ ```json
174
+ {
175
+ "version": 2,
176
+ "phase": "init",
177
+ "complete": false,
178
+ "pluginEphemeralKey": "<base64url-x25519-public-key>",
179
+ "nonce": "<same nonce>",
180
+ "timestamp": 1730000000000,
181
+ "serverTimestamp": 1730000000100,
182
+ "challengeExpiresAt": 1730000030100,
183
+ "expiresAt": 1730086400100,
184
+ "pluginSignature": "<base64url-ed25519-signature>"
185
+ }
186
+ ```
187
+
188
+ Full handshake transcript:
189
+
190
+ ```text
191
+ x3dh_v2|{accountId}|{appEphemeralKey}|{pluginEphemeralKey}|{nonce}|{timestamp}|{expiresAt}
192
+ ```
193
+
194
+ ### `soyeht.security.handshake.finish`
195
+
196
+ Purpose:
197
+
198
+ - Step 2 of the secure-session bootstrap.
199
+
200
+ Request params:
201
+
202
+ ```json
203
+ {
204
+ "version": 2,
205
+ "accountId": "default",
206
+ "nonce": "<same nonce from init>",
207
+ "appSignature": "<base64url-ed25519-signature>"
208
+ }
209
+ ```
210
+
211
+ The app signs the full handshake transcript returned implicitly by `handshake.init`:
212
+
213
+ ```text
214
+ x3dh_v2|{accountId}|{appEphemeralKey}|{pluginEphemeralKey}|{nonce}|{timestamp}|{expiresAt}
215
+ ```
216
+
217
+ Success payload:
218
+
219
+ ```json
220
+ {
221
+ "version": 2,
222
+ "phase": "finish",
223
+ "complete": true,
224
+ "expiresAt": 1730086400100
225
+ }
226
+ ```
227
+
228
+ Important behavior:
229
+
230
+ - The plugin verifies `appSignature` against the app identity key registered during pairing.
231
+ - The pending handshake must still be unexpired.
232
+ - On success, the plugin creates and stores a ratchet session for that account.
233
+
234
+ ### Legacy alias
235
+
236
+ `soyeht.security.handshake` is currently registered as a compatibility alias for `soyeht.security.handshake.init`.
237
+
238
+ New clients should use:
239
+
240
+ - `soyeht.security.handshake.init`
241
+ - `soyeht.security.handshake.finish`
242
+
243
+ ## App-Side Verification Requirements
244
+
245
+ After scanning the QR:
246
+
247
+ 1. Parse `qrPayload`
248
+ 2. Check `type == "soyeht_pairing_qr"`
249
+ 3. Check `expiresAt > now`
250
+ 4. Verify the QR signature using `pluginIdentityKey`
251
+ 5. Recompute fingerprint from `pluginIdentityKey` + `pluginDhKey` and compare with `fingerprint`
252
+
253
+ After `handshake.init`:
254
+
255
+ 1. Build the full handshake transcript exactly as above
256
+ 2. Verify `pluginSignature` using the plugin Ed25519 identity public key from the paired plugin
257
+ 3. Only then sign the transcript with the app Ed25519 identity private key
258
+ 4. Send `handshake.finish`
259
+
260
+ ## X3DH Derivation
261
+
262
+ Plugin/responder side implementation:
263
+
264
+ - `DH1 = X25519(pluginStaticDhPriv, appEphemeralPub)`
265
+ - `DH2 = X25519(pluginEphemeralPriv, appStaticDhPub)`
266
+ - `DH3 = X25519(pluginEphemeralPriv, appEphemeralPub)`
267
+ - `ikm = DH1 || DH2 || DH3`
268
+ - `root = HKDF-SHA256(ikm, nonceBytes, "soyeht-v2-root", 32)`
269
+ - `pluginSend = HKDF-SHA256(root, empty, "soyeht-v2-chain-send", 32)`
270
+ - `pluginRecv = HKDF-SHA256(root, empty, "soyeht-v2-chain-recv", 32)`
271
+
272
+ App/initiator side must derive the same root but swap directional chains:
273
+
274
+ - `DH1 = X25519(appEphemeralPriv, pluginStaticDhPub)`
275
+ - `DH2 = X25519(appStaticDhPriv, pluginEphemeralPub)`
276
+ - `DH3 = X25519(appEphemeralPriv, pluginEphemeralPub)`
277
+ - `ikm = DH1 || DH2 || DH3`
278
+ - `root = HKDF-SHA256(ikm, nonceBytes, "soyeht-v2-root", 32)`
279
+ - `appSend = HKDF-SHA256(root, empty, "soyeht-v2-chain-recv", 32)`
280
+ - `appRecv = HKDF-SHA256(root, empty, "soyeht-v2-chain-send", 32)`
281
+
282
+ Reference:
283
+
284
+ - `src/x3dh.ts`
285
+ - `test/protocol-v2-integration.test.ts`
286
+
287
+ ## Envelope V2
288
+
289
+ Envelope shape:
290
+
291
+ ```json
292
+ {
293
+ "v": 2,
294
+ "accountId": "default",
295
+ "direction": "app_to_plugin",
296
+ "counter": 0,
297
+ "timestamp": 1730000000000,
298
+ "dhRatchetKey": "<optional base64url x25519 public key>",
299
+ "ciphertext": "<base64url>",
300
+ "iv": "<base64url 12 bytes>",
301
+ "tag": "<base64url 16 bytes>"
302
+ }
303
+ ```
304
+
305
+ AAD string:
306
+
307
+ ```text
308
+ {v}|{accountId}|{direction}|{counter}|{timestamp}
309
+ ```
310
+
311
+ Symmetric ratchet step:
312
+
313
+ - `messageKey = HKDF-SHA256(chainKey, empty, "soyeht-v2-msg-key", 32)`
314
+ - `nextChainKey = HKDF-SHA256(chainKey, empty, "soyeht-v2-chain-advance", 32)`
315
+
316
+ Encryption:
317
+
318
+ - AES-256-GCM
319
+ - key = `messageKey`
320
+ - iv = random 12 bytes
321
+ - aad = UTF-8 bytes of the AAD string above
322
+
323
+ Direction rules:
324
+
325
+ - App -> plugin envelopes must use `direction = "app_to_plugin"`
326
+ - Plugin -> app envelopes use `direction = "plugin_to_app"`
327
+
328
+ ## App State To Persist
329
+
330
+ Persist on the app side:
331
+
332
+ - app Ed25519 identity key pair
333
+ - app X25519 static DH key pair
334
+ - paired plugin public keys
335
+ - fingerprint
336
+ - current ratchet session state
337
+ - root key
338
+ - send chain key + counter
339
+ - recv chain key + counter
340
+ - current app ephemeral DH key
341
+ - peer last ephemeral DH key
342
+ - createdAt / expiresAt
343
+
344
+ Do not persist used pairing tokens.
345
+
346
+ ## Common Error Codes
347
+
348
+ Pairing:
349
+
350
+ - `PAIRING_REQUIRED`
351
+ - `PAIRING_EXPIRED`
352
+ - `PAIRING_ACCOUNT_MISMATCH`
353
+ - `PEER_ALREADY_PAIRED`
354
+ - `INVALID_SIGNATURE`
355
+ - `INVALID_KEY`
356
+
357
+ Handshake:
358
+
359
+ - `PEER_NOT_FOUND`
360
+ - `INVALID_NONCE`
361
+ - `NONCE_REUSED`
362
+ - `TIMESTAMP_OUT_OF_RANGE`
363
+ - `HANDSHAKE_NOT_FOUND`
364
+ - `HANDSHAKE_EXPIRED`
365
+ - `INVALID_SIGNATURE`
366
+
367
+ Webhook/envelope:
368
+
369
+ - `session_required`
370
+ - `session_expired`
371
+ - `account_mismatch`
372
+ - `counter_mismatch`
373
+ - `direction_mismatch`
374
+ - `version_mismatch`
375
+
376
+ ## Recommended Client Flow
377
+
378
+ 1. Plugin/host calls `soyeht.security.pairing.start`
379
+ 2. Plugin/host renders `qrText` as QR
380
+ 3. App scans QR and verifies it
381
+ 4. App generates long-term keys if needed
382
+ 5. App calls `soyeht.security.pair`
383
+ 6. App generates a fresh ephemeral X25519 key + nonce
384
+ 7. App calls `soyeht.security.handshake.init`
385
+ 8. App verifies `pluginSignature`
386
+ 9. App signs the same transcript and calls `soyeht.security.handshake.finish`
387
+ 10. App derives the same X3DH session locally and starts Envelope V2 messaging
388
+
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "soyeht",
3
+ "channels": [
4
+ "soyeht"
5
+ ],
6
+ "name": "Soyeht",
7
+ "description": "Channel plugin for the Soyeht Flutter mobile app",
8
+ "version": "0.1.1",
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {}
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@soyeht/soyeht",
3
+ "version": "0.1.1",
4
+ "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "files": [
8
+ "src/",
9
+ "docs/PROTOCOL.md",
10
+ "openclaw.plugin.json",
11
+ "README.md"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "openclaw": {
17
+ "extensions": [
18
+ "./src/index.ts"
19
+ ],
20
+ "channel": {
21
+ "id": "soyeht",
22
+ "label": "Soyeht",
23
+ "selectionLabel": "Soyeht (Flutter app)",
24
+ "blurb": "Connects a Soyeht Flutter app to OpenClaw with QR-based secure pairing."
25
+ },
26
+ "install": {
27
+ "npmSpec": "@soyeht/soyeht",
28
+ "defaultChoice": "npm"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "check:package": "node scripts/check-package-metadata.mjs",
33
+ "validate": "npm run check:package && npm run typecheck && npm test",
34
+ "pack:check": "npm pack --dry-run",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "prepublishOnly": "npm run validate && npm run pack:check",
39
+ "version": "node scripts/sync-plugin-manifest-version.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@sinclair/typebox": "^0.34.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.3.5",
46
+ "typescript": "^5.7.0",
47
+ "vitest": "^3.0.0"
48
+ }
49
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
3
+ import type { ResolvedSoyehtAccount } from "./config.js";
4
+ import {
5
+ resolveSoyehtAccount,
6
+ listSoyehtAccountIds,
7
+ } from "./config.js";
8
+ import { SoyehtChannelConfigSchema } from "./types.js";
9
+ import {
10
+ buildOutboundEnvelope,
11
+ postToBackend,
12
+ } from "./outbound.js";
13
+ import type { SecurityV2Deps } from "./service.js";
14
+
15
+ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<ResolvedSoyehtAccount> {
16
+ return {
17
+ id: "soyeht",
18
+
19
+ meta: {
20
+ id: "soyeht",
21
+ label: "Soyeht",
22
+ selectionLabel: "Soyeht (Mobile App)",
23
+ docsPath: "/channels/soyeht",
24
+ blurb:
25
+ "Connect the Soyeht Flutter mobile app to exchange text, audio, and file messages.",
26
+ systemImage: "iphone",
27
+ },
28
+
29
+ capabilities: {
30
+ chatTypes: ["direct"],
31
+ media: true,
32
+ },
33
+
34
+ reload: {
35
+ configPrefixes: ["channels.soyeht"],
36
+ },
37
+
38
+ configSchema: {
39
+ schema: SoyehtChannelConfigSchema,
40
+ },
41
+
42
+ config: {
43
+ listAccountIds: (cfg) => listSoyehtAccountIds(cfg),
44
+ resolveAccount: (cfg, accountId) => resolveSoyehtAccount(cfg, accountId),
45
+ defaultAccountId: () => "default",
46
+ isEnabled: (account) => account.enabled,
47
+ isConfigured: (account) => Boolean(account.backendBaseUrl && account.pluginAuthToken),
48
+ describeAccount: (account) => ({
49
+ accountId: account.accountId,
50
+ enabled: account.enabled,
51
+ configured: Boolean(account.backendBaseUrl && account.pluginAuthToken),
52
+ backendConfigured: Boolean(account.backendBaseUrl),
53
+ securityEnabled: account.security.enabled,
54
+ allowProactive: account.allowProactive,
55
+ }),
56
+ },
57
+
58
+ outbound: {
59
+ deliveryMode: "direct",
60
+
61
+ async sendText(ctx) {
62
+ const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
63
+ if (!account.backendBaseUrl) {
64
+ return {
65
+ channel: "soyeht",
66
+ messageId: randomUUID(),
67
+ meta: { error: true, reason: "no_backend_url" },
68
+ };
69
+ }
70
+
71
+ const ratchetSession = v2deps?.sessions.get(account.accountId);
72
+
73
+ if (account.security.enabled && !ratchetSession) {
74
+ return {
75
+ channel: "soyeht",
76
+ messageId: randomUUID(),
77
+ meta: { error: true, reason: "session_required" },
78
+ };
79
+ }
80
+
81
+ if (ratchetSession && ratchetSession.expiresAt < Date.now()) {
82
+ return {
83
+ channel: "soyeht",
84
+ messageId: randomUUID(),
85
+ meta: { error: true, reason: "session_expired" },
86
+ };
87
+ }
88
+
89
+ const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
90
+ contentType: "text",
91
+ text: ctx.text,
92
+ });
93
+
94
+ return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
95
+ ratchetSession,
96
+ dhRatchetCfg: {
97
+ intervalMessages: account.security.dhRatchetIntervalMessages,
98
+ intervalMs: account.security.dhRatchetIntervalMs,
99
+ },
100
+ onSessionUpdated: (updated) => {
101
+ v2deps?.sessions.set(account.accountId, updated);
102
+ },
103
+ securityEnabled: account.security.enabled,
104
+ });
105
+ },
106
+
107
+ async sendMedia(ctx) {
108
+ const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
109
+ if (!account.backendBaseUrl || !ctx.mediaUrl) {
110
+ return {
111
+ channel: "soyeht",
112
+ messageId: randomUUID(),
113
+ meta: { error: true, reason: !ctx.mediaUrl ? "no_media_url" : "no_backend_url" },
114
+ };
115
+ }
116
+
117
+ const ratchetSession = v2deps?.sessions.get(account.accountId);
118
+
119
+ if (account.security.enabled && !ratchetSession) {
120
+ return {
121
+ channel: "soyeht",
122
+ messageId: randomUUID(),
123
+ meta: { error: true, reason: "session_required" },
124
+ };
125
+ }
126
+
127
+ if (ratchetSession && ratchetSession.expiresAt < Date.now()) {
128
+ return {
129
+ channel: "soyeht",
130
+ messageId: randomUUID(),
131
+ meta: { error: true, reason: "session_expired" },
132
+ };
133
+ }
134
+
135
+ const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
136
+ contentType: "audio",
137
+ renderStyle: "voice_note",
138
+ mimeType: "audio/wav",
139
+ filename: "voice.wav",
140
+ url: ctx.mediaUrl,
141
+ });
142
+
143
+ return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
144
+ ratchetSession,
145
+ dhRatchetCfg: {
146
+ intervalMessages: account.security.dhRatchetIntervalMessages,
147
+ intervalMs: account.security.dhRatchetIntervalMs,
148
+ },
149
+ onSessionUpdated: (updated) => {
150
+ v2deps?.sessions.set(account.accountId, updated);
151
+ },
152
+ securityEnabled: account.security.enabled,
153
+ });
154
+ },
155
+ },
156
+ };
157
+ }