@imweapp/openclaw-imwe 2026.4.12-alpha.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/index.ts +16 -0
  4. package/openclaw.plugin.json +58 -0
  5. package/package.json +73 -0
  6. package/proto/PbBoxPullProto.proto +43 -0
  7. package/proto/PbChatAudioContent.proto +23 -0
  8. package/proto/PbChatDeliverMsg.proto +38 -0
  9. package/proto/PbChatFileMeta.proto +34 -0
  10. package/proto/PbChatMsg.proto +93 -0
  11. package/proto/PbChatRichMediaContent.proto +31 -0
  12. package/proto/PbChatTextContent.proto +38 -0
  13. package/proto/PbMarkdownContent.proto +18 -0
  14. package/proto/PbMsgReadStampContent.proto +11 -0
  15. package/proto/PbPacket.proto +61 -0
  16. package/proto/PbSingleChatMsg.proto +60 -0
  17. package/setup-entry.ts +17 -0
  18. package/src/accounts.ts +109 -0
  19. package/src/api-client.ts +740 -0
  20. package/src/bot-info-cache.ts +49 -0
  21. package/src/channel.runtime.ts +29 -0
  22. package/src/channel.ts +456 -0
  23. package/src/config-schema.ts +26 -0
  24. package/src/e2ee/api.ts +261 -0
  25. package/src/e2ee/canonical.ts +59 -0
  26. package/src/e2ee/errors.ts +103 -0
  27. package/src/e2ee/index.ts +8 -0
  28. package/src/e2ee/proper-lockfile.d.ts +61 -0
  29. package/src/e2ee/service.ts +1273 -0
  30. package/src/e2ee/store.ts +174 -0
  31. package/src/e2ee/types.ts +113 -0
  32. package/src/e2ee/vodozemac.ts +373 -0
  33. package/src/file-transfer/api.ts +364 -0
  34. package/src/file-transfer/concurrency.ts +77 -0
  35. package/src/file-transfer/download.ts +261 -0
  36. package/src/file-transfer/file-crypto.ts +93 -0
  37. package/src/file-transfer/index.ts +18 -0
  38. package/src/file-transfer/scheduler.ts +185 -0
  39. package/src/file-transfer/types.ts +195 -0
  40. package/src/file-transfer/upload.ts +656 -0
  41. package/src/markdown-detect.ts +119 -0
  42. package/src/media-upload.ts +338 -0
  43. package/src/media-utils.ts +110 -0
  44. package/src/monitor.ts +838 -0
  45. package/src/proto/codec.ts +54 -0
  46. package/src/proto/inbound.codec.ts +624 -0
  47. package/src/proto/proto-types.ts +291 -0
  48. package/src/proto/registry.ts +226 -0
  49. package/src/proto/send.codec.ts +535 -0
  50. package/src/recent-message-cache.ts +350 -0
  51. package/src/send.ts +792 -0
  52. package/src/setup-core.ts +62 -0
  53. package/src/types.ts +153 -0
  54. package/src/vodozemackit/index.ts +297 -0
  55. package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
  56. package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
  57. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
  58. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
  59. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * store.ts — E2EE 本地状态文件(state.json)存储层
3
+ *
4
+ * 对应设计:design.md §2.3 / §3.2;requirements R9。
5
+ *
6
+ * 主要职责:
7
+ * 1. 目录路径拼接:`${stateDir}/channel-data/imwe/${botAcctId}/e2ee/state.json`
8
+ * 2. `createE2eeStore(opts)` 工厂 + `E2eeStore` 接口(`snapshot` / `update`)
9
+ * 3. 进程内 Promise 队列串行:所有 `update()` 共享同一 chain,保证先来先到
10
+ * 4. 跨进程 `proper-lockfile` 互斥 + 两阶段原子写(tmp + fsync + rename)
11
+ *
12
+ * 约束:
13
+ * - `snapshot()` 不加锁、仅用于展示/日志;与并发 `update()` 存在天然读旧风险
14
+ * - mutator 必须是纯函数,禁止在其内部发起网络请求
15
+ * - `proper-lockfile` 要求被锁目标路径存在,因此 `update()` 进入锁临界区前
16
+ * 会先以 `wx` 独占标志兜底创建 state.json(已存在时忽略 EEXIST)
17
+ */
18
+
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+
22
+ import lockfile from 'proper-lockfile';
23
+
24
+ import { DEFAULT_EMPTY_STATE, E2eeStateFileSchema, type E2eeStateFile } from './types.js';
25
+
26
+ /**
27
+ * E2EE 状态文件存储接口。
28
+ *
29
+ * `update` 是变更 state.json 的**唯一入口**;`snapshot` 仅用于只读场景。
30
+ */
31
+ export interface E2eeStore {
32
+ /**
33
+ * 加锁 + 读磁盘 + 跑 mutator + 两阶段写 + 释放锁。
34
+ *
35
+ * 实现要点:
36
+ * - 进程内通过 Promise chain 串行化所有 `update`,保证 mutator 不交叉;
37
+ * - 跨进程通过 `proper-lockfile.lock(state.json, { retries: 5, stale: 30_000 })`
38
+ * 兜底,避免两个 openclaw 进程同时写坏同一份 state.json;
39
+ * - 写入走 tmp 文件 + fsync + rename,依赖 POSIX / NTFS 的 rename 原子性
40
+ * 保证崩溃场景下 state.json 要么是旧版本,要么是新版本,不会出现半行。
41
+ */
42
+ update<T>(mutator: (prev: E2eeStateFile) => { next: E2eeStateFile; result: T }): Promise<T>;
43
+
44
+ /**
45
+ * 只读快照(不加锁)。与 `update` 存在并发时可能读到较旧的数据,
46
+ * 仅用于展示/日志,不得作为写入前提。
47
+ */
48
+ snapshot(): Promise<E2eeStateFile>;
49
+ }
50
+
51
+ export function createE2eeStore(opts: {
52
+ accountId: string;
53
+ botAcctId: string;
54
+ stateDir: string;
55
+ log?: { warn?: (...args: unknown[]) => void };
56
+ }): E2eeStore {
57
+ const { accountId, botAcctId, stateDir, log } = opts;
58
+
59
+ // state.json 目录与文件路径(使用 botAcctId 与 pullState 对齐,防止用户修改 accountId 但未改 appKey/appSecret)。
60
+ const stateFileDir = path.join(stateDir, 'channel-data', 'imwe', botAcctId, 'e2ee');
61
+ const stateFilePath = path.join(stateFileDir, 'state.json');
62
+ const stateFileTmpPath = `${stateFilePath}.tmp`;
63
+
64
+ /**
65
+ * 从磁盘加载 state.json:
66
+ * - 目录不存在则 mkdir -p
67
+ * - 文件不存在(ENOENT)→ 返回 DEFAULT_EMPTY_STATE
68
+ * - JSON 解析或 zod schema 校验失败 → 自愈:重命名为 `state.json.corrupt-${ts}`
69
+ * 后回落 DEFAULT_EMPTY_STATE,并 WARN 日志(R9 第 6 条 / R15 第 5 条)
70
+ */
71
+ const load = (): E2eeStateFile => {
72
+ fs.mkdirSync(stateFileDir, { recursive: true });
73
+ let raw: string;
74
+ try {
75
+ raw = fs.readFileSync(stateFilePath, 'utf-8');
76
+ } catch (err) {
77
+ const errno = (err as NodeJS.ErrnoException).code;
78
+ if (errno === 'ENOENT') {
79
+ return DEFAULT_EMPTY_STATE({ accountId, botAcctId });
80
+ }
81
+ throw err;
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(raw) as unknown;
85
+ return E2eeStateFileSchema.parse(parsed);
86
+ } catch {
87
+ const corruptPath = `${stateFilePath}.corrupt-${Date.now()}`;
88
+ try {
89
+ fs.renameSync(stateFilePath, corruptPath);
90
+ } catch {
91
+ // rename 失败也不阻断自愈:后续 ensureStateFileExists 会按 wx 兜底
92
+ }
93
+ log?.warn?.(`[e2ee][${accountId}] state.json 损坏已自愈 → ${corruptPath}`);
94
+ return DEFAULT_EMPTY_STATE({ accountId, botAcctId });
95
+ }
96
+ };
97
+
98
+ /**
99
+ * 确保 state.json 存在:`proper-lockfile` 以 `${file}.lock` 目录作为锁标记,
100
+ * 但仍要求被锁目标路径存在,否则 `realpath` 解析会抛 ENOENT。
101
+ *
102
+ * 使用 `wx` 独占标志:多进程竞态下仅首个创建者成功,其它进程遇到 EEXIST
103
+ * 会被忽略——初始内容相同,不影响正确性。
104
+ */
105
+ const ensureStateFileExists = (): void => {
106
+ fs.mkdirSync(stateFileDir, { recursive: true });
107
+ const init = DEFAULT_EMPTY_STATE({ accountId, botAcctId });
108
+ try {
109
+ fs.writeFileSync(stateFilePath, JSON.stringify(init), { encoding: 'utf-8', flag: 'wx' });
110
+ } catch (err) {
111
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
112
+ throw err;
113
+ }
114
+ // 已由其它进程/先前调用创建,OK。
115
+ }
116
+ };
117
+
118
+ /**
119
+ * 两阶段原子写:
120
+ * 1. open tmp 文件 → writeFile(JSON) → fsync → close
121
+ * 2. rename tmp → state.json(POSIX/NTFS 均为原子)
122
+ *
123
+ * fsync 保证 JSON bytes 落盘;rename 保证替换指针瞬间完成,
124
+ * 进程崩溃时 state.json 要么是旧版本完整副本,要么是新版本完整副本。
125
+ */
126
+ const writeAtomic = async (state: E2eeStateFile): Promise<void> => {
127
+ fs.mkdirSync(stateFileDir, { recursive: true });
128
+ const json = JSON.stringify(state);
129
+ const fh = await fs.promises.open(stateFileTmpPath, 'w');
130
+ try {
131
+ await fh.writeFile(json, 'utf-8');
132
+ await fh.sync();
133
+ } finally {
134
+ await fh.close();
135
+ }
136
+ await fs.promises.rename(stateFileTmpPath, stateFilePath);
137
+ };
138
+
139
+ /**
140
+ * 进程内 Promise 队列:通过单一 chain 保证所有 `update` 依序执行。
141
+ *
142
+ * 错误不传染下一次排队:每次 task 的 `.catch(() => undefined)` 只用来推进
143
+ * 队列指针,原始错误仍会以 `task` 的 rejection 抛回调用方。
144
+ */
145
+ let queue: Promise<unknown> = Promise.resolve();
146
+
147
+ const update = <T>(
148
+ mutator: (prev: E2eeStateFile) => { next: E2eeStateFile; result: T },
149
+ ): Promise<T> => {
150
+ const task = queue.then(async (): Promise<T> => {
151
+ // proper-lockfile 要求目标路径存在:先行确保 state.json 就位。
152
+ ensureStateFileExists();
153
+
154
+ const release = await lockfile.lock(stateFilePath, {
155
+ retries: 5,
156
+ stale: 30_000,
157
+ });
158
+ try {
159
+ const prev = load();
160
+ const { next, result } = mutator(prev);
161
+ await writeAtomic(next);
162
+ return result;
163
+ } finally {
164
+ await release();
165
+ }
166
+ });
167
+ queue = task.catch(() => undefined);
168
+ return task;
169
+ };
170
+
171
+ const snapshot = async (): Promise<E2eeStateFile> => load();
172
+
173
+ return { update, snapshot };
174
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * e2ee/types.ts — E2EE 本地状态文件(state.json)类型与 zod schema
3
+ *
4
+ * 字段与 design.md §2.3 的 `E2eeStateFile` 对齐。所有写入必须经 `E2eeStore.update`,
5
+ * 本文件只负责类型与 schema 定义,不包含落盘逻辑。
6
+ */
7
+
8
+ import { z } from 'zod';
9
+
10
+ /** 当前 state.json schema 版本;bump 时需同步迁移策略 */
11
+ export const E2EE_STATE_FILE_VERSION = 1 as const;
12
+
13
+ export const E2EE_DEVICE_STATUS = {
14
+ UNINITIALIZED: 'UNINITIALIZED',
15
+ NORMAL: 'NORMAL',
16
+ NEEDS_SUPPLEMENT: 'NEEDS_SUPPLEMENT',
17
+ } as const;
18
+
19
+ export type E2eeDeviceStatus = (typeof E2EE_DEVICE_STATUS)[keyof typeof E2EE_DEVICE_STATUS];
20
+
21
+ /** 本地设备信息(bootstrap 完成后写入) */
22
+ export const LocalDeviceSchema = z.object({
23
+ /** = Account.curve25519Key() 归一化后的稳定值 */
24
+ e2eeId: z.string(),
25
+ /** 冗余字段,= e2eeId,便于日志/调试 */
26
+ identityKey: z.string(),
27
+ /** pickleKey = sha256(e2eeId).prefix(32) */
28
+ accountPickle: z.string(),
29
+ createdAt: z.number(),
30
+ updatedAt: z.number(),
31
+ });
32
+
33
+ /** 对端 recipient(以 contactId = 对端 imAcctId 为 key) */
34
+ export const RecipientSchema = z.object({
35
+ contactId: z.string(),
36
+ /** 对端 e2eeId 列表 */
37
+ deviceIds: z.array(z.string()),
38
+ unregisteredAt: z.number().optional(),
39
+ updatedAt: z.number(),
40
+ });
41
+
42
+ /** 单条 session(key = `${contactId}:${peerE2eeId}:${sessionId}`) */
43
+ export const SessionSchema = z.object({
44
+ contactId: z.string(),
45
+ peerE2eeId: z.string(),
46
+ sessionId: z.string(),
47
+ /** pickleKey = sha256(sessionId).prefix(32) */
48
+ sessionPickle: z.string(),
49
+ isActive: z.boolean(),
50
+ createdAt: z.number(),
51
+ updatedAt: z.number(),
52
+ /** >0 表示不可用(staleTime 语义) */
53
+ staleAt: z.number().optional(),
54
+ });
55
+
56
+ /** PreKey 批次中的单个 key(OTK 或 FBK) */
57
+ export const PreKeyEntrySchema = z.object({
58
+ keyType: z.enum(['otk', 'fallback']),
59
+ /** 来自 vodozemac OneTimeKeyInfo.keyId */
60
+ keyId: z.string(),
61
+ /** base64;用于隐式确认按 publicKey 匹配 */
62
+ publicKey: z.string(),
63
+ });
64
+
65
+ /** 单次 PreKey 上传批次(key = uploadId) */
66
+ export const PreKeyBatchSchema = z.object({
67
+ uploadId: z.string(),
68
+ mode: z.enum(['create', 'replenish']),
69
+ state: z.enum(['pending', 'uploading', 'synced']),
70
+ keys: z.array(PreKeyEntrySchema),
71
+ retryCount: z.number(),
72
+ createdAt: z.number(),
73
+ updatedAt: z.number(),
74
+ });
75
+
76
+ /** E2EE 本地状态文件完整 schema */
77
+ export const E2eeStateFileSchema = z.object({
78
+ version: z.literal(E2EE_STATE_FILE_VERSION),
79
+ accountId: z.string(),
80
+ /** 即 imAcctId */
81
+ botAcctId: z.string(),
82
+ local: LocalDeviceSchema.optional(),
83
+ recipients: z.record(z.string(), RecipientSchema),
84
+ sessions: z.record(z.string(), SessionSchema),
85
+ preKeyBatches: z.record(z.string(), PreKeyBatchSchema),
86
+ });
87
+
88
+ // ── 导出 TypeScript 类型(由 zod schema 推导,保持单一事实源) ──
89
+
90
+ export type LocalDevice = z.infer<typeof LocalDeviceSchema>;
91
+ export type Recipient = z.infer<typeof RecipientSchema>;
92
+ export type Session = z.infer<typeof SessionSchema>;
93
+ export type PreKeyEntry = z.infer<typeof PreKeyEntrySchema>;
94
+ export type PreKeyBatch = z.infer<typeof PreKeyBatchSchema>;
95
+ export type E2eeStateFile = z.infer<typeof E2eeStateFileSchema>;
96
+
97
+ /**
98
+ * 空 state 工厂。首次启动或 state.json 损坏自愈后使用。
99
+ * 仅填必填字段,`local` 留空等待 bootstrap 写入。
100
+ */
101
+ export function DEFAULT_EMPTY_STATE(params: {
102
+ accountId: string;
103
+ botAcctId: string;
104
+ }): E2eeStateFile {
105
+ return {
106
+ version: E2EE_STATE_FILE_VERSION,
107
+ accountId: params.accountId,
108
+ botAcctId: params.botAcctId,
109
+ recipients: {},
110
+ sessions: {},
111
+ preKeyBatches: {},
112
+ };
113
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * e2ee/vodozemac.ts — vodozemac 薄封装(插件侧)
3
+ *
4
+ * 职责(对应 design.md §3.3 与 requirements R10):
5
+ * - 暴露 `pickleKey(input)` pickle 密钥派生(与 iOS `String.pickleKey` 对齐)。
6
+ * - 暴露 `createVodozemacBinding()` 外壳:统一构造 Account / Session handle
7
+ * 的入口,并提供幂等的 `ready()` 懒加载钩子。
8
+ * - 定义 `AccountHandle` / `SessionHandle` / `EncryptedBundle` 接口并通过
9
+ * `wrapAccount` / `wrapSession` 复用实现;outbound / inbound / restore
10
+ * 路径共享同一套包装逻辑。
11
+ *
12
+ * 设计约束:
13
+ * - 字节类型统一使用 `Uint8Array`,避免 base64 互转带来的歧义;
14
+ * - 底层 vodozemac 实现严格从插件内嵌的 `../vodozemackit` 载入,
15
+ * 禁止旁路直接实例化 `WebAssembly.Module`(对应 requirement R12);
16
+ * - 本模块为纯 TypeScript 逻辑,不应被 codec 层顶层 import(requirement R12)。
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+
21
+ import {
22
+ Account,
23
+ EncryptedMessage,
24
+ Session,
25
+ accountFromPickle,
26
+ sessionFromPickle,
27
+ } from '../vodozemackit/index.js';
28
+ import type { KeyMap, OneTimeKeyInfo } from '../vodozemackit/index.js';
29
+
30
+ import { E2eeAccountNormalizeError } from './errors.js';
31
+
32
+ // ─── 共享字节类型 ───────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * vodozemac EncryptedMessage 的插件侧表示。
36
+ *
37
+ * `body` 对应 `PbSingleChatDeviceMsg.envelope` 字段,即 vodozemac
38
+ * `EncryptedMessage.body`(OlmMessage JSON 的 UTF-8 bytes)。
39
+ */
40
+ export type EncryptedBundle = { body: Uint8Array };
41
+
42
+ // ─── AccountHandle ──────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * 单个本地 Account 的生命周期封装。
46
+ *
47
+ * 主要职责:
48
+ * - 暴露稳定的 `e2eeId`(= `normalizeFreshAccount` 归一化后的 curve25519 公钥)
49
+ * - 生成 OTK / FBK、标记已发布
50
+ * - 构造 outbound / inbound session
51
+ * - pickle / dispose 释放资源
52
+ */
53
+ export interface AccountHandle {
54
+ /** = 归一化后的稳定 curve25519 公钥(base64) */
55
+ readonly e2eeId: string;
56
+
57
+ /** 返回 identity key(= `e2eeId`,便于调用侧语义区分) */
58
+ identityKey(): string;
59
+
60
+ /**
61
+ * 生成 `n` 个一次性密钥,返回新增列表与被挤出的 publicKey 列表。
62
+ * 行为对齐 `../vodozemackit` `Account.generateOneTimeKeysWithInfo`。
63
+ */
64
+ generateOneTimeKeysWithInfo(n: number): {
65
+ created: OneTimeKeyInfo[];
66
+ removed: string[];
67
+ };
68
+
69
+ /** 当前尚未发布的一次性密钥(含 keyId) */
70
+ oneTimeKeysWithInfo(): OneTimeKeyInfo[];
71
+
72
+ /** 尚未调用 `markKeysAsPublished` 的 OTK 数量 */
73
+ unpublishedOneTimeKeyCount(): number;
74
+
75
+ /** 生成一个兜底密钥(FBK),返回其 keyInfo */
76
+ generateFallbackKey(): OneTimeKeyInfo;
77
+
78
+ /** 标记当前所有已生成的 OTK / FBK 为已发布 */
79
+ markKeysAsPublished(): void;
80
+
81
+ /** 使用对端 identityKey + oneTimeKey 构造一个 outbound session */
82
+ createOutboundSession(peerIdentity: string, peerOneTimeKey: string): SessionHandle;
83
+
84
+ /**
85
+ * 接受对端发来的 PreKey 消息构造 inbound session,
86
+ * 返回 `{ session, plaintext, oneTimeKey(base64) }`。
87
+ * `oneTimeKey` 用于隐式确认对应 OTK 批次已送达。
88
+ */
89
+ createInboundSession(msg: EncryptedBundle): {
90
+ session: SessionHandle;
91
+ plaintext: Uint8Array;
92
+ oneTimeKey: string;
93
+ };
94
+
95
+ /** pickle 当前 Account 状态;内部使用 `pickleKey(e2eeId)` 派生密钥 */
96
+ pickle(): string;
97
+
98
+ /** 释放底层资源;调用后 handle 不可再用 */
99
+ dispose(): void;
100
+ }
101
+
102
+ // ─── SessionHandle ──────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * 单条会话 session 的生命周期封装。
106
+ */
107
+ export interface SessionHandle {
108
+ /** vodozemac session id */
109
+ readonly sessionId: string;
110
+
111
+ /** 对明文字节串加密,返回 EncryptedBundle */
112
+ encrypt(plainBytes: Uint8Array): EncryptedBundle;
113
+
114
+ /** 对密文 EncryptedBundle 解密,返回明文字节串 */
115
+ decrypt(msg: EncryptedBundle): Uint8Array;
116
+
117
+ /** 判断某条密文是否匹配当前 session */
118
+ matches(msg: EncryptedBundle): boolean;
119
+
120
+ /** pickle 当前 session 状态;内部使用 `pickleKey(sessionId)` 派生密钥 */
121
+ pickle(): string;
122
+
123
+ /** 释放底层资源;调用后 handle 不可再用 */
124
+ dispose(): void;
125
+ }
126
+
127
+ // ─── VodozemacBinding ───────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * vodozemac 绑定的顶层外壳。由 `createVodozemacBinding()` 创建;
131
+ * 在 `E2eeService` 构造时注入,所有 Account / Session 的生命周期由此入口管理。
132
+ */
133
+ export interface VodozemacBinding {
134
+ /**
135
+ * 懒加载 WASM 的幂等入口。
136
+ *
137
+ * `../vodozemackit` 当前以 ESM 静态 import 形式加载 WASM;
138
+ * 此 `ready()` 保留为语义占位,便于未来切换到动态 import 时
139
+ * 在 `E2eeService.ensureLocalDevice` 内部以 `await binding.ready()` 统一阻塞。
140
+ * 多次调用必须幂等。
141
+ */
142
+ ready(): Promise<void>;
143
+
144
+ /**
145
+ * 新建 Account 并在内部完成 `normalizeFreshAccount` 归一化
146
+ * (≤4 轮 pickle/unpickle 直到 `curve25519Key()` 稳定)。
147
+ *
148
+ * 注:本 task 2.2 只返回 `curve25519Key()` 的直接取值作为 `e2eeId`;
149
+ * task 2.4 会在此处前置调用 `normalizeFreshAccount` 保证稳定性。
150
+ */
151
+ newAccount(): AccountHandle;
152
+
153
+ /** 从 pickle 字符串恢复 Account;pickle key = `pickleKey(e2eeId)` */
154
+ restoreAccount(e2eeId: string, pickle: string): AccountHandle;
155
+
156
+ /** 从 pickle 字符串恢复 Session;pickle key = `pickleKey(sessionId)` */
157
+ restoreSession(sessionId: string, pickle: string): SessionHandle;
158
+ }
159
+
160
+ /**
161
+ * `normalizeFreshAccount` — 自举 pickle/unpickle ≤4 轮直到 `curve25519Key()` 稳定。
162
+ *
163
+ * 对应 design.md §3.3.2 与 requirement R10:
164
+ * 新建 Account 在首次读取 curve25519Key() 后,再做一次 pickle→unpickle 有概率
165
+ * 返回与先前不同的 curve25519Key;iOS 通过"循环 pickle/unpickle 直到稳定"来收敛。
166
+ *
167
+ * 算法:
168
+ * FOR round ∈ 1..4:
169
+ * oldKey = account.curve25519Key()
170
+ * pickle = account.pickle(pickleKey(oldKey))
171
+ * account = accountFromPickle(pickle, pickleKey(oldKey))
172
+ * newKey = account.curve25519Key()
173
+ * if newKey == oldKey → return { account, stableE2eeId: newKey }
174
+ * 抛 E2eeAccountNormalizeError
175
+ *
176
+ * 前置条件:account 尚未 persist 过、尚未生成 OTK/FBK。
177
+ * 后置条件:返回的 account 满足 `pickle(pickleKey(e2eeId)) → unpickle → curve25519Key()` 与自身相等。
178
+ */
179
+ export function normalizeFreshAccount(initial: Account): {
180
+ account: Account;
181
+ stableE2eeId: string;
182
+ } {
183
+ let account = initial;
184
+ for (let round = 0; round < 4; round += 1) {
185
+ const oldKey = account.curve25519Key();
186
+ const pickle = account.pickle(pickleKey(oldKey));
187
+ account = accountFromPickle(pickle, pickleKey(oldKey));
188
+ const newKey = account.curve25519Key();
189
+ if (newKey === oldKey) {
190
+ return { account, stableE2eeId: newKey };
191
+ }
192
+ }
193
+ throw new E2eeAccountNormalizeError();
194
+ }
195
+
196
+ // ─── pickleKey 派生 ─────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * pickle 密钥派生:`sha256(utf8(input))[0..32]`。
200
+ *
201
+ * - 与 iOS `String.pickleKey` 完全一致,确保跨端 pickle/unpickle 互通
202
+ * (requirement R10.2)。
203
+ * - 返回的 Uint8Array 长度恒为 32(requirement R10.1)。
204
+ * - 相同 input 恒返回相同 key,调用之间无状态(requirement R10.2)。
205
+ *
206
+ * @param input 非空字符串,通常是 `e2eeId` 或 `sessionId`
207
+ * @returns 32 字节 Uint8Array
208
+ */
209
+ export function pickleKey(input: string): Uint8Array {
210
+ const digest = createHash('sha256').update(input, 'utf8').digest();
211
+ // Node Buffer 继承自 Uint8Array,这里拷贝出独立的 Uint8Array 视图并截取前 32 字节,
212
+ // 避免消费侧意外写入共享 Buffer 造成状态污染。
213
+ const out = new Uint8Array(32);
214
+ out.set(digest.subarray(0, 32));
215
+ return out;
216
+ }
217
+
218
+ // ─── 内部:fallback key 解析 ────────────────────────────────────────────────
219
+
220
+ /**
221
+ * 从 `Account.fallbackKey()` 返回的 KeyMap 中提取唯一一项 fallback 信息。
222
+ *
223
+ * vodozemac 底层 `generateFallbackKey()` 返回 void,需要再调 `fallbackKey()`
224
+ * 读取当前未发布的 fallback key(`{ keyId → publicKey }`)。
225
+ * 实际场景下 map 至多只有一条未发布的 fallback;取第一条即可。
226
+ */
227
+ function extractFallbackKeyInfo(map: KeyMap): OneTimeKeyInfo {
228
+ const entries = Object.entries(map);
229
+ if (entries.length === 0) {
230
+ throw new Error('vodozemac: fallbackKey() 为空,generateFallbackKey 未生效');
231
+ }
232
+ const [keyId, publicKey] = entries[0]!;
233
+ return { keyId, publicKey };
234
+ }
235
+
236
+ // ─── 内部:wrapSession ──────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * 把底层 `Session` 包装成 `SessionHandle`。
240
+ *
241
+ * 被 outbound / inbound / restore 三路共享,保证加解密 I/O 类型一致。
242
+ */
243
+ function wrapSession(inner: Session): SessionHandle {
244
+ // sessionId 在 session 生命周期内恒定,提前捕获避免每次 pickle 都回 WASM 查询。
245
+ const sessionId = inner.sessionId();
246
+
247
+ return {
248
+ get sessionId(): string {
249
+ return sessionId;
250
+ },
251
+
252
+ encrypt(plainBytes: Uint8Array): EncryptedBundle {
253
+ const em = inner.encrypt(plainBytes);
254
+ return { body: em.body };
255
+ },
256
+
257
+ decrypt(msg: EncryptedBundle): Uint8Array {
258
+ return inner.decrypt(new EncryptedMessage(msg.body));
259
+ },
260
+
261
+ matches(msg: EncryptedBundle): boolean {
262
+ return inner.sessionMatches(new EncryptedMessage(msg.body));
263
+ },
264
+
265
+ pickle(): string {
266
+ return inner.pickle(pickleKey(sessionId));
267
+ },
268
+
269
+ dispose(): void {
270
+ // 占位:底层 Session 由 JS GC 回收 wasm handle;后续若暴露显式 free 再补上。
271
+ },
272
+ };
273
+ }
274
+
275
+ // ─── 内部:wrapAccount ──────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * 把底层 `Account` 包装成 `AccountHandle`。
279
+ *
280
+ * `e2eeId` 由调用方传入(`newAccount` 来自 `curve25519Key()`,
281
+ * `restoreAccount` 来自 state.json 中的 `local.e2eeId`);
282
+ * 后续 task 2.4 会在 `newAccount` 入口加上 `normalizeFreshAccount` 保证稳定。
283
+ */
284
+ function wrapAccount(inner: Account, e2eeId: string): AccountHandle {
285
+ return {
286
+ get e2eeId(): string {
287
+ return e2eeId;
288
+ },
289
+
290
+ identityKey(): string {
291
+ // 对外暴露归一化后的稳定值,避免不同路径调用 `curve25519Key()` 出现瞬时差异。
292
+ return e2eeId;
293
+ },
294
+
295
+ generateOneTimeKeysWithInfo(n: number) {
296
+ return inner.generateOneTimeKeysWithInfo(n);
297
+ },
298
+
299
+ oneTimeKeysWithInfo(): OneTimeKeyInfo[] {
300
+ return inner.oneTimeKeysWithInfo();
301
+ },
302
+
303
+ unpublishedOneTimeKeyCount(): number {
304
+ return inner.unpublishedOneTimeKeyCount();
305
+ },
306
+
307
+ generateFallbackKey(): OneTimeKeyInfo {
308
+ inner.generateFallbackKey();
309
+ return extractFallbackKeyInfo(inner.fallbackKey());
310
+ },
311
+
312
+ markKeysAsPublished(): void {
313
+ inner.markKeysAsPublished();
314
+ },
315
+
316
+ createOutboundSession(peerIdentity: string, peerOneTimeKey: string): SessionHandle {
317
+ return wrapSession(inner.createOutboundSession(peerIdentity, peerOneTimeKey));
318
+ },
319
+
320
+ createInboundSession(msg: EncryptedBundle) {
321
+ const result = inner.createInboundSession(new EncryptedMessage(msg.body));
322
+ return {
323
+ session: wrapSession(result.session),
324
+ plaintext: result.plaintext,
325
+ oneTimeKey: result.oneTimeKey,
326
+ };
327
+ },
328
+
329
+ pickle(): string {
330
+ return inner.pickle(pickleKey(e2eeId));
331
+ },
332
+
333
+ dispose(): void {
334
+ // 占位:底层 Account 由 JS GC 回收 wasm handle;后续若暴露显式 free 再补上。
335
+ },
336
+ };
337
+ }
338
+
339
+ // ─── createVodozemacBinding ─────────────────────────────────────────────────
340
+
341
+ /**
342
+ * 构造 VodozemacBinding 实例。
343
+ *
344
+ * `newAccount` 内部调用 `normalizeFreshAccount` 保证 `curve25519Key()` 收敛稳定
345
+ * (requirement R10),避免 state.json 写入非稳定值。
346
+ */
347
+ export function createVodozemacBinding(): VodozemacBinding {
348
+ return {
349
+ async ready(): Promise<void> {
350
+ // vodozemackit 当前走静态 ESM import,WASM 在模块初始化时已就绪;
351
+ // 保留此方法以对齐 design.md §3.3 的契约,未来可替换为实际懒加载。
352
+ return;
353
+ },
354
+
355
+ newAccount(): AccountHandle {
356
+ const { account, stableE2eeId } = normalizeFreshAccount(new Account());
357
+ return wrapAccount(account, stableE2eeId);
358
+ },
359
+
360
+ restoreAccount(e2eeId: string, pickle: string): AccountHandle {
361
+ const inner = accountFromPickle(pickle, pickleKey(e2eeId));
362
+ return wrapAccount(inner, e2eeId);
363
+ },
364
+
365
+ restoreSession(sessionId: string, pickle: string): SessionHandle {
366
+ const inner = sessionFromPickle(pickle, pickleKey(sessionId));
367
+ // 包装后的 handle 会用 `inner.sessionId()` 作为 pickle key 来源;
368
+ // 此值与传入的 `sessionId` 一致,无需再校验。
369
+ void sessionId;
370
+ return wrapSession(inner);
371
+ },
372
+ };
373
+ }