@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/pairing.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { GatewayRequestHandler } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import {
|
|
5
|
+
base64UrlDecode,
|
|
6
|
+
base64UrlEncode,
|
|
7
|
+
computeFingerprint,
|
|
8
|
+
ed25519Sign,
|
|
9
|
+
ed25519Verify,
|
|
10
|
+
importEd25519PublicKey,
|
|
11
|
+
importX25519PublicKey,
|
|
12
|
+
} from "./crypto.js";
|
|
13
|
+
import { normalizeAccountId } from "./config.js";
|
|
14
|
+
import { deleteSession, savePeer, type PeerIdentity } from "./identity.js";
|
|
15
|
+
import { zeroBuffer } from "./ratchet.js";
|
|
16
|
+
import type { SecurityV2Deps } from "./service.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PAIRING_TTL_MS = 90_000;
|
|
19
|
+
const MIN_PAIRING_TTL_MS = 30_000;
|
|
20
|
+
const MAX_PAIRING_TTL_MS = 300_000;
|
|
21
|
+
|
|
22
|
+
function clampPairingTtlMs(value: unknown): number {
|
|
23
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
24
|
+
return DEFAULT_PAIRING_TTL_MS;
|
|
25
|
+
}
|
|
26
|
+
return Math.min(Math.max(Math.trunc(value), MIN_PAIRING_TTL_MS), MAX_PAIRING_TTL_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildPairingQrTranscript(params: {
|
|
30
|
+
accountId: string;
|
|
31
|
+
pairingToken: string;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
allowOverwrite: boolean;
|
|
34
|
+
pluginIdentityKey: string;
|
|
35
|
+
pluginDhKey: string;
|
|
36
|
+
fingerprint: string;
|
|
37
|
+
}): Buffer {
|
|
38
|
+
const {
|
|
39
|
+
accountId,
|
|
40
|
+
pairingToken,
|
|
41
|
+
expiresAt,
|
|
42
|
+
allowOverwrite,
|
|
43
|
+
pluginIdentityKey,
|
|
44
|
+
pluginDhKey,
|
|
45
|
+
fingerprint,
|
|
46
|
+
} = params;
|
|
47
|
+
return Buffer.from(
|
|
48
|
+
`pairing_qr_v1|${accountId}|${pairingToken}|${expiresAt}|${allowOverwrite ? 1 : 0}|${pluginIdentityKey}|${pluginDhKey}|${fingerprint}`,
|
|
49
|
+
"utf8",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildPairingProofTranscript(params: {
|
|
54
|
+
accountId: string;
|
|
55
|
+
pairingToken: string;
|
|
56
|
+
expiresAt: number;
|
|
57
|
+
appIdentityKey: string;
|
|
58
|
+
appDhKey: string;
|
|
59
|
+
}): Buffer {
|
|
60
|
+
const { accountId, pairingToken, expiresAt, appIdentityKey, appDhKey } = params;
|
|
61
|
+
return Buffer.from(
|
|
62
|
+
`pairing_proof_v1|${accountId}|${pairingToken}|${expiresAt}|${appIdentityKey}|${appDhKey}`,
|
|
63
|
+
"utf8",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function signPairingQrPayload(
|
|
68
|
+
privateKey: Parameters<typeof ed25519Sign>[0],
|
|
69
|
+
payload: Parameters<typeof buildPairingQrTranscript>[0],
|
|
70
|
+
): string {
|
|
71
|
+
return base64UrlEncode(ed25519Sign(privateKey, buildPairingQrTranscript(payload)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): void {
|
|
75
|
+
const existingSession = v2deps.sessions.get(accountId);
|
|
76
|
+
if (existingSession) {
|
|
77
|
+
zeroBuffer(existingSession.rootKey);
|
|
78
|
+
zeroBuffer(existingSession.sending.chainKey);
|
|
79
|
+
zeroBuffer(existingSession.receiving.chainKey);
|
|
80
|
+
v2deps.sessions.delete(accountId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [key, pending] of v2deps.pendingHandshakes) {
|
|
84
|
+
if (pending.accountId === accountId) {
|
|
85
|
+
v2deps.pendingHandshakes.delete(key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// soyeht.security.identity — expose plugin public keys
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function handleSecurityIdentity(
|
|
95
|
+
_api: OpenClawPluginApi,
|
|
96
|
+
v2deps: SecurityV2Deps,
|
|
97
|
+
): GatewayRequestHandler {
|
|
98
|
+
return async ({ params, respond }) => {
|
|
99
|
+
if (!v2deps.ready) {
|
|
100
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!v2deps.identity) {
|
|
104
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const callerKey = normalizeAccountId(params["accountId"] as string | undefined);
|
|
109
|
+
const { allowed } = v2deps.rateLimiter.check(`identity:${callerKey}`);
|
|
110
|
+
if (!allowed) {
|
|
111
|
+
respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
respond(true, {
|
|
116
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
117
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// soyeht.security.pairing.start — create a single-use QR payload
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export function handleSecurityPairingStart(
|
|
127
|
+
_api: OpenClawPluginApi,
|
|
128
|
+
v2deps: SecurityV2Deps,
|
|
129
|
+
): GatewayRequestHandler {
|
|
130
|
+
return async ({ params, respond }) => {
|
|
131
|
+
if (!v2deps.ready) {
|
|
132
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!v2deps.identity) {
|
|
136
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const accountId = normalizeAccountId(params["accountId"] as string | undefined);
|
|
141
|
+
const allowOverwrite = params["allowOverwrite"] === true;
|
|
142
|
+
const ttlMs = clampPairingTtlMs(params["ttlMs"]);
|
|
143
|
+
const { allowed } = v2deps.rateLimiter.check(`pairing:start:${accountId}`);
|
|
144
|
+
if (!allowed) {
|
|
145
|
+
respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (v2deps.peers.has(accountId) && !allowOverwrite) {
|
|
150
|
+
respond(false, undefined, {
|
|
151
|
+
code: "PEER_ALREADY_PAIRED",
|
|
152
|
+
message: "Account already paired. Start a new QR with allowOverwrite=true to replace it.",
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const [token, session] of v2deps.pairingSessions) {
|
|
158
|
+
if (session.accountId === accountId) {
|
|
159
|
+
v2deps.pairingSessions.delete(token);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pairingToken = base64UrlEncode(randomBytes(32));
|
|
164
|
+
const expiresAt = Date.now() + ttlMs;
|
|
165
|
+
const fingerprint = computeFingerprint(v2deps.identity);
|
|
166
|
+
const payload = {
|
|
167
|
+
version: 1 as const,
|
|
168
|
+
type: "soyeht_pairing_qr" as const,
|
|
169
|
+
accountId,
|
|
170
|
+
pairingToken,
|
|
171
|
+
expiresAt,
|
|
172
|
+
allowOverwrite,
|
|
173
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
174
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
175
|
+
fingerprint,
|
|
176
|
+
};
|
|
177
|
+
const signature = signPairingQrPayload(v2deps.identity.signKey.privateKey, payload);
|
|
178
|
+
const qrPayload = { ...payload, signature };
|
|
179
|
+
|
|
180
|
+
v2deps.pairingSessions.set(pairingToken, {
|
|
181
|
+
token: pairingToken,
|
|
182
|
+
accountId,
|
|
183
|
+
expiresAt,
|
|
184
|
+
allowOverwrite,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
respond(true, {
|
|
188
|
+
qrPayload,
|
|
189
|
+
qrText: JSON.stringify(qrPayload),
|
|
190
|
+
expiresAt,
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// soyeht.security.pair — consume a QR token and register the peer keys
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export function handleSecurityPair(
|
|
200
|
+
api: OpenClawPluginApi,
|
|
201
|
+
v2deps: SecurityV2Deps,
|
|
202
|
+
): GatewayRequestHandler {
|
|
203
|
+
return async ({ params, respond }) => {
|
|
204
|
+
if (!v2deps.ready) {
|
|
205
|
+
respond(false, undefined, { code: "NOT_READY", message: "Service not ready" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!v2deps.identity) {
|
|
209
|
+
respond(false, undefined, { code: "NO_IDENTITY", message: "Identity not initialized" });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const accountId = normalizeAccountId(params["accountId"] as string | undefined);
|
|
214
|
+
const { allowed } = v2deps.rateLimiter.check(`pair:${accountId}`);
|
|
215
|
+
if (!allowed) {
|
|
216
|
+
respond(false, undefined, { code: "RATE_LIMITED", message: "Too many requests" });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const pairingToken = params["pairingToken"] as string | undefined;
|
|
221
|
+
const appIdentityKey = params["appIdentityKey"] as string | undefined;
|
|
222
|
+
const appDhKey = params["appDhKey"] as string | undefined;
|
|
223
|
+
const appSignature = params["appSignature"] as string | undefined;
|
|
224
|
+
|
|
225
|
+
if (!pairingToken || !appIdentityKey || !appDhKey || !appSignature) {
|
|
226
|
+
respond(false, undefined, {
|
|
227
|
+
code: "INVALID_PARAMS",
|
|
228
|
+
message: "Missing required params: pairingToken, appIdentityKey, appDhKey, appSignature",
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const pairingSession = v2deps.pairingSessions.get(pairingToken);
|
|
234
|
+
if (!pairingSession) {
|
|
235
|
+
respond(false, undefined, {
|
|
236
|
+
code: "PAIRING_REQUIRED",
|
|
237
|
+
message: "No active pairing session. Scan a fresh QR code first.",
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (pairingSession.expiresAt <= Date.now()) {
|
|
243
|
+
v2deps.pairingSessions.delete(pairingToken);
|
|
244
|
+
respond(false, undefined, {
|
|
245
|
+
code: "PAIRING_EXPIRED",
|
|
246
|
+
message: "Pairing QR expired. Scan a fresh QR code first.",
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (pairingSession.accountId !== accountId) {
|
|
252
|
+
respond(false, undefined, {
|
|
253
|
+
code: "PAIRING_ACCOUNT_MISMATCH",
|
|
254
|
+
message: "Pairing token is bound to a different accountId.",
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let appIdentityPub;
|
|
260
|
+
try {
|
|
261
|
+
appIdentityPub = importEd25519PublicKey(appIdentityKey);
|
|
262
|
+
} catch {
|
|
263
|
+
respond(false, undefined, { code: "INVALID_KEY", message: "Invalid appIdentityKey" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
importX25519PublicKey(appDhKey);
|
|
269
|
+
} catch {
|
|
270
|
+
respond(false, undefined, { code: "INVALID_KEY", message: "Invalid appDhKey" });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const proofTranscript = buildPairingProofTranscript({
|
|
275
|
+
accountId,
|
|
276
|
+
pairingToken,
|
|
277
|
+
expiresAt: pairingSession.expiresAt,
|
|
278
|
+
appIdentityKey,
|
|
279
|
+
appDhKey,
|
|
280
|
+
});
|
|
281
|
+
if (!ed25519Verify(appIdentityPub, proofTranscript, base64UrlDecode(appSignature))) {
|
|
282
|
+
respond(false, undefined, {
|
|
283
|
+
code: "INVALID_SIGNATURE",
|
|
284
|
+
message: "App signature verification failed",
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const existingPeer = v2deps.peers.get(accountId);
|
|
290
|
+
if (existingPeer && !pairingSession.allowOverwrite) {
|
|
291
|
+
respond(false, undefined, {
|
|
292
|
+
code: "PEER_ALREADY_PAIRED",
|
|
293
|
+
message: "Account already paired. Scan a QR with allowOverwrite=true to replace it.",
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
clearAccountSessionState(v2deps, accountId);
|
|
299
|
+
if (v2deps.stateDir) {
|
|
300
|
+
await deleteSession(v2deps.stateDir, accountId).catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const peer: PeerIdentity = { accountId, identityKeyB64: appIdentityKey, dhKeyB64: appDhKey };
|
|
304
|
+
v2deps.peers.set(accountId, peer);
|
|
305
|
+
v2deps.pairingSessions.delete(pairingToken);
|
|
306
|
+
|
|
307
|
+
if (v2deps.stateDir) {
|
|
308
|
+
await savePeer(v2deps.stateDir, peer);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
api.logger.info("[soyeht] Security pairing completed", {
|
|
312
|
+
accountId,
|
|
313
|
+
allowOverwrite: pairingSession.allowOverwrite,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
respond(true, {
|
|
317
|
+
accountId,
|
|
318
|
+
handshakeRequired: true,
|
|
319
|
+
pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
|
|
320
|
+
pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
|
|
321
|
+
fingerprint: computeFingerprint(v2deps.identity),
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
}
|
package/src/ratchet.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { KeyObject } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
generateX25519KeyPair,
|
|
4
|
+
computeSharedSecret,
|
|
5
|
+
hkdfDeriveSync,
|
|
6
|
+
exportPrivateKeyPkcs8,
|
|
7
|
+
exportPublicKeyRaw,
|
|
8
|
+
importX25519PublicKey,
|
|
9
|
+
importPrivateKeyPkcs8,
|
|
10
|
+
base64UrlEncode,
|
|
11
|
+
base64UrlDecode,
|
|
12
|
+
type X25519KeyPair,
|
|
13
|
+
} from "./crypto.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type ChainState = {
|
|
20
|
+
chainKey: Buffer;
|
|
21
|
+
counter: number; // monotonic, per direction
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type RatchetState = {
|
|
25
|
+
accountId: string;
|
|
26
|
+
rootKey: Buffer;
|
|
27
|
+
sending: ChainState;
|
|
28
|
+
receiving: ChainState;
|
|
29
|
+
myCurrentEphDhKey: X25519KeyPair;
|
|
30
|
+
peerLastEphDhKeyB64: string; // peer's last ephemeral pub, raw b64url
|
|
31
|
+
dhRatchetSendCount: number; // messages sent since last DH ratchet emitted
|
|
32
|
+
dhRatchetRecvCount: number;
|
|
33
|
+
createdAt: number;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DhRatchetConfig = {
|
|
38
|
+
intervalMessages: number; // default 50
|
|
39
|
+
intervalMs: number; // default 3_600_000
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// KDF chain advancement (sync, per-message)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const EMPTY = Buffer.alloc(0);
|
|
47
|
+
|
|
48
|
+
export function advanceChain(state: ChainState): { messageKey: Buffer; next: ChainState } {
|
|
49
|
+
const messageKey = hkdfDeriveSync(state.chainKey, EMPTY, "soyeht-v2-msg-key", 32);
|
|
50
|
+
const nextChainKey = hkdfDeriveSync(state.chainKey, EMPTY, "soyeht-v2-chain-advance", 32);
|
|
51
|
+
// Zero the consumed chain key
|
|
52
|
+
state.chainKey.fill(0);
|
|
53
|
+
return {
|
|
54
|
+
messageKey,
|
|
55
|
+
next: { chainKey: nextChainKey, counter: state.counter + 1 },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// DH ratchet trigger
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export function needsDhRatchet(
|
|
64
|
+
session: RatchetState,
|
|
65
|
+
cfg: DhRatchetConfig,
|
|
66
|
+
): boolean {
|
|
67
|
+
if (session.dhRatchetSendCount >= cfg.intervalMessages) return true;
|
|
68
|
+
if (Date.now() - session.createdAt >= cfg.intervalMs) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// DH ratchet — SENDER side
|
|
74
|
+
// Called inside encryptEnvelopeV2 when needsDhRatchet() is true.
|
|
75
|
+
// Returns updated session + the new ephemeral pub to include in envelope.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export function applySendDhRatchet(session: RatchetState): {
|
|
79
|
+
updatedSession: RatchetState;
|
|
80
|
+
newEphPubB64: string;
|
|
81
|
+
} {
|
|
82
|
+
const newEphKp = generateX25519KeyPair();
|
|
83
|
+
const peerPub = importX25519PublicKey(session.peerLastEphDhKeyB64);
|
|
84
|
+
const dhShared = computeSharedSecret(newEphKp.privateKey, peerPub);
|
|
85
|
+
|
|
86
|
+
const newRoot = hkdfDeriveSync(
|
|
87
|
+
Buffer.concat([session.rootKey, dhShared]),
|
|
88
|
+
EMPTY,
|
|
89
|
+
"soyeht-v2-dh-ratchet",
|
|
90
|
+
32,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Sender: non-swapped (send = "chain-send", recv = "chain-recv")
|
|
94
|
+
const newSendChain = hkdfDeriveSync(newRoot, EMPTY, "soyeht-v2-chain-send", 32);
|
|
95
|
+
const newRecvChain = hkdfDeriveSync(newRoot, EMPTY, "soyeht-v2-chain-recv", 32);
|
|
96
|
+
|
|
97
|
+
// Zero old secrets
|
|
98
|
+
session.rootKey.fill(0);
|
|
99
|
+
dhShared.fill(0);
|
|
100
|
+
|
|
101
|
+
const updatedSession: RatchetState = {
|
|
102
|
+
...session,
|
|
103
|
+
rootKey: newRoot,
|
|
104
|
+
sending: { chainKey: newSendChain, counter: session.sending.counter },
|
|
105
|
+
receiving: { chainKey: newRecvChain, counter: session.receiving.counter },
|
|
106
|
+
myCurrentEphDhKey: newEphKp,
|
|
107
|
+
dhRatchetSendCount: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return { updatedSession, newEphPubB64: newEphKp.publicKeyB64 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// DH ratchet — RECEIVER side
|
|
115
|
+
// Called when an incoming envelope has dhRatchetKey.
|
|
116
|
+
// Uses SWAPPED labels (recv of envelope sender = sender's "send" chain).
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export function executeDhRatchet(
|
|
120
|
+
session: RatchetState,
|
|
121
|
+
peerNewEphPub: KeyObject,
|
|
122
|
+
): {
|
|
123
|
+
newSession: RatchetState;
|
|
124
|
+
myNewEphPubB64: string;
|
|
125
|
+
} {
|
|
126
|
+
const dhShared = computeSharedSecret(session.myCurrentEphDhKey.privateKey, peerNewEphPub);
|
|
127
|
+
|
|
128
|
+
const newRoot = hkdfDeriveSync(
|
|
129
|
+
Buffer.concat([session.rootKey, dhShared]),
|
|
130
|
+
EMPTY,
|
|
131
|
+
"soyeht-v2-dh-ratchet",
|
|
132
|
+
32,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Receiver: SWAPPED (my recv = sender's "chain-send", my send = "chain-recv")
|
|
136
|
+
const newRecvChain = hkdfDeriveSync(newRoot, EMPTY, "soyeht-v2-chain-send", 32);
|
|
137
|
+
const newSendChain = hkdfDeriveSync(newRoot, EMPTY, "soyeht-v2-chain-recv", 32);
|
|
138
|
+
|
|
139
|
+
// Zero old secrets
|
|
140
|
+
session.rootKey.fill(0);
|
|
141
|
+
dhShared.fill(0);
|
|
142
|
+
|
|
143
|
+
// Generate new ephemeral for our next DH ratchet
|
|
144
|
+
const myNewEphKp = generateX25519KeyPair();
|
|
145
|
+
const peerPubB64 = base64UrlEncode(exportPublicKeyRaw(peerNewEphPub));
|
|
146
|
+
|
|
147
|
+
const newSession: RatchetState = {
|
|
148
|
+
...session,
|
|
149
|
+
rootKey: newRoot,
|
|
150
|
+
sending: { chainKey: newSendChain, counter: session.sending.counter },
|
|
151
|
+
receiving: { chainKey: newRecvChain, counter: session.receiving.counter },
|
|
152
|
+
myCurrentEphDhKey: myNewEphKp,
|
|
153
|
+
peerLastEphDhKeyB64: peerPubB64,
|
|
154
|
+
dhRatchetRecvCount: 0,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return { newSession, myNewEphPubB64: myNewEphKp.publicKeyB64 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Zeroize a buffer
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export function zeroBuffer(buf: Buffer): void {
|
|
165
|
+
buf.fill(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Deep clone — protects stored session from in-place Buffer mutations
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function cloneRatchetSession(session: RatchetState): RatchetState {
|
|
173
|
+
return {
|
|
174
|
+
...session,
|
|
175
|
+
rootKey: Buffer.from(session.rootKey),
|
|
176
|
+
sending: {
|
|
177
|
+
chainKey: Buffer.from(session.sending.chainKey),
|
|
178
|
+
counter: session.sending.counter,
|
|
179
|
+
},
|
|
180
|
+
receiving: {
|
|
181
|
+
chainKey: Buffer.from(session.receiving.chainKey),
|
|
182
|
+
counter: session.receiving.counter,
|
|
183
|
+
},
|
|
184
|
+
// myCurrentEphDhKey contains KeyObjects (immutable) + publicKeyRaw Buffer
|
|
185
|
+
myCurrentEphDhKey: {
|
|
186
|
+
...session.myCurrentEphDhKey,
|
|
187
|
+
publicKeyRaw: Buffer.from(session.myCurrentEphDhKey.publicKeyRaw),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Serialization (for disk persistence)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
type SerializedX25519KeyPair = {
|
|
197
|
+
publicSpki: string; // base64url SPKI DER
|
|
198
|
+
privatePkcs8: string; // base64url PKCS8 DER
|
|
199
|
+
publicKeyB64: string; // base64url raw 32 bytes
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
function serializeX25519KeyPair(kp: X25519KeyPair): SerializedX25519KeyPair {
|
|
203
|
+
return {
|
|
204
|
+
publicSpki: base64UrlEncode(Buffer.from(kp.publicKey.export({ type: "spki", format: "der" }))),
|
|
205
|
+
privatePkcs8: base64UrlEncode(exportPrivateKeyPkcs8(kp.privateKey)),
|
|
206
|
+
publicKeyB64: kp.publicKeyB64,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function deserializeX25519KeyPair(s: SerializedX25519KeyPair): X25519KeyPair {
|
|
211
|
+
const publicKey = importX25519PublicKey(s.publicKeyB64);
|
|
212
|
+
const privateKey = importPrivateKeyPkcs8(base64UrlDecode(s.privatePkcs8));
|
|
213
|
+
const publicKeyRaw = base64UrlDecode(s.publicKeyB64);
|
|
214
|
+
return { publicKey, privateKey, publicKeyRaw, publicKeyB64: s.publicKeyB64 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function serializeSession(session: RatchetState): Record<string, unknown> {
|
|
218
|
+
return {
|
|
219
|
+
accountId: session.accountId,
|
|
220
|
+
rootKey: base64UrlEncode(session.rootKey),
|
|
221
|
+
sending: {
|
|
222
|
+
chainKey: base64UrlEncode(session.sending.chainKey),
|
|
223
|
+
counter: session.sending.counter,
|
|
224
|
+
},
|
|
225
|
+
receiving: {
|
|
226
|
+
chainKey: base64UrlEncode(session.receiving.chainKey),
|
|
227
|
+
counter: session.receiving.counter,
|
|
228
|
+
},
|
|
229
|
+
myCurrentEphDhKey: serializeX25519KeyPair(session.myCurrentEphDhKey),
|
|
230
|
+
peerLastEphDhKeyB64: session.peerLastEphDhKeyB64,
|
|
231
|
+
dhRatchetSendCount: session.dhRatchetSendCount,
|
|
232
|
+
dhRatchetRecvCount: session.dhRatchetRecvCount,
|
|
233
|
+
createdAt: session.createdAt,
|
|
234
|
+
expiresAt: session.expiresAt,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function deserializeSession(raw: unknown): RatchetState {
|
|
239
|
+
const r = raw as Record<string, unknown>;
|
|
240
|
+
const s = r["sending"] as Record<string, unknown>;
|
|
241
|
+
const rv = r["receiving"] as Record<string, unknown>;
|
|
242
|
+
const ephRaw = r["myCurrentEphDhKey"] as SerializedX25519KeyPair;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
accountId: r["accountId"] as string,
|
|
246
|
+
rootKey: base64UrlDecode(r["rootKey"] as string),
|
|
247
|
+
sending: {
|
|
248
|
+
chainKey: base64UrlDecode(s["chainKey"] as string),
|
|
249
|
+
counter: s["counter"] as number,
|
|
250
|
+
},
|
|
251
|
+
receiving: {
|
|
252
|
+
chainKey: base64UrlDecode(rv["chainKey"] as string),
|
|
253
|
+
counter: rv["counter"] as number,
|
|
254
|
+
},
|
|
255
|
+
myCurrentEphDhKey: deserializeX25519KeyPair(ephRaw),
|
|
256
|
+
peerLastEphDhKeyB64: r["peerLastEphDhKeyB64"] as string,
|
|
257
|
+
dhRatchetSendCount: r["dhRatchetSendCount"] as number,
|
|
258
|
+
dhRatchetRecvCount: r["dhRatchetRecvCount"] as number,
|
|
259
|
+
createdAt: r["createdAt"] as number,
|
|
260
|
+
expiresAt: r["expiresAt"] as number,
|
|
261
|
+
};
|
|
262
|
+
}
|