@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 +73 -0
- package/docs/PROTOCOL.md +388 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/channel.ts +157 -0
- package/src/config.ts +203 -0
- package/src/crypto.ts +227 -0
- package/src/envelope-v2.ts +201 -0
- package/src/http.ts +175 -0
- package/src/identity.ts +157 -0
- package/src/index.ts +120 -0
- package/src/media.ts +100 -0
- package/src/openclaw-plugin-sdk.d.ts +209 -0
- package/src/outbound.ts +198 -0
- package/src/pairing.ts +324 -0
- package/src/ratchet.ts +262 -0
- package/src/rpc.ts +503 -0
- package/src/security.ts +158 -0
- package/src/service.ts +177 -0
- package/src/types.ts +213 -0
- package/src/version.ts +1 -0
- package/src/x3dh.ts +105 -0
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
|
+
}
|