@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/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
+ }