@anycon/protocol 0.1.0
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/LICENSE +176 -0
- package/README.md +146 -0
- package/README.zh-CN.md +118 -0
- package/dist/constants.d.ts +151 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +159 -0
- package/dist/constants.js.map +1 -0
- package/dist/crypto.d.ts +202 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +218 -0
- package/dist/crypto.js.map +1 -0
- package/dist/envelope.d.ts +105 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +390 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated.d.ts +2007 -0
- package/dist/generated.d.ts.map +1 -0
- package/dist/generated.js +4 -0
- package/dist/generated.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/method-types.d.ts +293 -0
- package/dist/method-types.d.ts.map +1 -0
- package/dist/method-types.js +2 -0
- package/dist/method-types.js.map +1 -0
- package/dist/rpc.d.ts +70 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +310 -0
- package/dist/rpc.js.map +1 -0
- package/package.json +62 -0
- package/src/constants.ts +173 -0
- package/src/crypto.ts +427 -0
- package/src/envelope.ts +469 -0
- package/src/errors.ts +4 -0
- package/src/generated.ts +2094 -0
- package/src/index.ts +18 -0
- package/src/method-types.ts +183 -0
- package/src/rpc.ts +377 -0
- package/wasm/THIRD-PARTY-LICENSES.txt +1957 -0
- package/wasm/anycon_crypto.d.ts +248 -0
- package/wasm/anycon_crypto.js +1250 -0
- package/wasm/anycon_crypto_bg.wasm +0 -0
- package/wasm/anycon_crypto_bg.wasm.d.ts +48 -0
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crates/crypto` 的 wasm 封装。`loadCrypto()` 动态加载 `../wasm/anycon_crypto.js`(`just gen-wasm` 的产物,
|
|
3
|
+
* 已 gitignore),初始化后返回一个 Promise 无关的同步 API:`Uint8Array` 进出,没有 wasm-bindgen 的所有权语义。
|
|
4
|
+
*
|
|
5
|
+
* 为什么包一层:
|
|
6
|
+
* - 密钥对是纯数据(`public` / `secret`),每次握手时才在 wasm 里临时重建,不存在「传进去就被消费」的问题;
|
|
7
|
+
* - 隐藏 wasm-bindgen 产物的细节(getter / 方法、`init` 签名、Node 下读 .wasm 文件);
|
|
8
|
+
* - SDK 和测试可以注入别的实现(`Crypto` 接口),比如不需要真密码学的 fake。
|
|
9
|
+
*
|
|
10
|
+
* 角色:配对(XXpsk3)joiner 是 initiator、host(daemon)是 responder;重连(IK)客户端是 initiator。
|
|
11
|
+
*/
|
|
12
|
+
import { PAIR_CODE_ALPHABET, PAIR_CODE_LEN, PAIR_CODE_LEN_LONG } from './constants.js'
|
|
13
|
+
|
|
14
|
+
export type Role = 'initiator' | 'responder'
|
|
15
|
+
|
|
16
|
+
/** X25519 静态密钥对。纯数据,可序列化保存(`secret` 是敏感信息,0600 / IndexedDB)。 */
|
|
17
|
+
export interface Keypair {
|
|
18
|
+
readonly public: Uint8Array
|
|
19
|
+
readonly secret: Uint8Array
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `derivePairSecrets` 的结果。`psk` 是拷贝,用完 `fill(0)`。 */
|
|
23
|
+
export interface PairSecrets {
|
|
24
|
+
/** 16 个 hex,发给 hub 撮合。 */
|
|
25
|
+
readonly matchId: string
|
|
26
|
+
/** 32 字节预共享密钥,不离开本机。 */
|
|
27
|
+
readonly psk: Uint8Array
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 握手完成后的双向加密通道。一个 Transport 只能单线程顺序使用。 */
|
|
31
|
+
export interface Transport {
|
|
32
|
+
/** 加密任意长度明文(自动分片)。 */
|
|
33
|
+
encrypt(plaintext: Uint8Array): Uint8Array
|
|
34
|
+
/** 解密 `encrypt` 的输出,还原整条明文。乱序 / 篡改会抛错。 */
|
|
35
|
+
decrypt(ciphertext: Uint8Array): Uint8Array
|
|
36
|
+
/** 对端静态公钥。 */
|
|
37
|
+
readonly remotePublic: Uint8Array
|
|
38
|
+
/** 释放 wasm 内存。之后不能再用。 */
|
|
39
|
+
free(): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 进行中的握手。 */
|
|
43
|
+
export interface Handshake {
|
|
44
|
+
/** 现在该我写消息吗。 */
|
|
45
|
+
isMyTurn(): boolean
|
|
46
|
+
/** 写下一条握手消息(可带 payload,通常为空)。 */
|
|
47
|
+
write(payload?: Uint8Array): Uint8Array
|
|
48
|
+
/** 读对方的握手消息,返回其 payload。 */
|
|
49
|
+
read(message: Uint8Array): Uint8Array
|
|
50
|
+
isFinished(): boolean
|
|
51
|
+
/** 对方静态公钥;握手进行到能知道的时候起可用(IK responder 读完第一条后就有)。 */
|
|
52
|
+
remotePublic(): Uint8Array | undefined
|
|
53
|
+
/** 握手完成后转成 Transport,之后这个 Handshake 不能再用。 */
|
|
54
|
+
intoTransport(): Transport
|
|
55
|
+
/** 握手中途放弃时释放。 */
|
|
56
|
+
free(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Crypto {
|
|
60
|
+
generateKeypair(): Keypair
|
|
61
|
+
keypairFromSecret(secret: Uint8Array): Keypair
|
|
62
|
+
/** 配对握手 `Noise_XXpsk3`。 */
|
|
63
|
+
pairing(role: Role, local: Keypair, psk: Uint8Array): Handshake
|
|
64
|
+
/** 重连握手 `Noise_IK`。initiator 必须给 `remotePublic`,responder 不给。 */
|
|
65
|
+
reconnect(role: Role, local: Keypair, remotePublic?: Uint8Array): Handshake
|
|
66
|
+
/**
|
|
67
|
+
* 短码 → `{ matchId, psk }`,**配对路径用它,只算一次**。一次 Argon2id(`salt = "anycon-pair-v2"`,
|
|
68
|
+
* m = 64 MiB、t = 3、p = 1,输出 64 字节):前 8 字节的 hex 是 hub 撮合用的 `matchId`(16 个 hex,hub 永远看不到短码
|
|
69
|
+
* 本身),后 32 字节是 PSK。8 位或 16 位短码都合法,否则抛错。
|
|
70
|
+
*
|
|
71
|
+
* 耗时:Node / 本机 wasm 约 0.1–0.3 s,浏览器 wasm 约 0.1–1 s,会同步占用线程(UI 要先显示「正在派生密钥…」
|
|
72
|
+
* 再调用)。返回的 `psk` 是拷贝,调用方用完 `fill(0)`。
|
|
73
|
+
*/
|
|
74
|
+
derivePairSecrets(code: string): PairSecrets
|
|
75
|
+
/** 短码 → PSK(一次完整 Argon2id,约等于 `derivePairSecrets` 的耗时)。配对请直接用 `derivePairSecrets`,别和 `deriveMatchId` 各算一遍。 */
|
|
76
|
+
derivePsk(code: string): Uint8Array
|
|
77
|
+
/** 短码 → hub 撮合用的 match id(16 个 hex;一次完整 Argon2id)。8 位或 16 位都合法,否则抛错。 */
|
|
78
|
+
deriveMatchId(code: string): string
|
|
79
|
+
/** 大写,去掉 `-` 和空白。 */
|
|
80
|
+
normalizeCode(code: string): string
|
|
81
|
+
/** 生成 `XXXX-XXXX` 形式的 8 位短码(`anycon pair --short`,约 40 bit,只适合自建 hub)。 */
|
|
82
|
+
generatePairCode(): string
|
|
83
|
+
/** 生成 `XXXX-XXXX-XXXX-XXXX` 形式的 16 位短码(约 79 bit,`anycon pair` 的默认长度)。 */
|
|
84
|
+
generatePairCodeLong(): string
|
|
85
|
+
/** 公钥指纹:`3f:a2:…`。 */
|
|
86
|
+
fingerprint(publicKey: Uint8Array): string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 一方的 CPace 状态(账号内接入,ADR-36)。新设备用 `'initiator'`,daemon 用 `'responder'`。 */
|
|
90
|
+
export interface Cpace {
|
|
91
|
+
/** 自己的份额(32 字节),放进 `EnrollFrame.cpace` / `share` 发给对方。 */
|
|
92
|
+
readonly share: Uint8Array
|
|
93
|
+
/** 用对方的份额得到 32 字节 PSK(喂给 `pairing`)。份额不合法时抛 `invalid key`。之后这个对象不能再用。 */
|
|
94
|
+
finish(role: Role, peerShare: Uint8Array): Uint8Array
|
|
95
|
+
/** 中途放弃时释放(私有标量随之清零)。 */
|
|
96
|
+
free(): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 账号内接入(ADR-36,protocol.md § 4.4)用到的密码学。`loadCrypto()` 返回的对象带这些方法;
|
|
101
|
+
* 只做配对 / 重连的 fake 可以只实现 `Crypto`。
|
|
102
|
+
*/
|
|
103
|
+
export interface EnrollCrypto {
|
|
104
|
+
/** `prs`:口令的 `deriveEnrollPasswordPrs`,或规范化后 6 位码的 ASCII 字节;`ci`:`enrollChannelId`;`sid`:`enrollSid`。 */
|
|
105
|
+
cpace(prs: Uint8Array, ci: Uint8Array, sid: Uint8Array): Cpace
|
|
106
|
+
/** 16 字节随机数(`EnrollHello.nonce`)。 */
|
|
107
|
+
generateEnrollNonce(): Uint8Array
|
|
108
|
+
/** 32 字节扫码密钥(放进二维码)。 */
|
|
109
|
+
generateEnrollSecret(): Uint8Array
|
|
110
|
+
/** 去掉空白和 `-`,必须是 6 个数字,否则抛错。 */
|
|
111
|
+
normalizeEnrollCode(code: string): string
|
|
112
|
+
/** 设置口令时校验:NFC 之后至少 8 个字符,否则抛 `enroll password too short`。 */
|
|
113
|
+
validateEnrollPassword(password: string): void
|
|
114
|
+
/** `Argon2id(NFC(口令), salt)`,浏览器里约 0.5–1 s,会同步占用线程。`salt` 是 `EnrollReply.salt`(16 字节)。 */
|
|
115
|
+
deriveEnrollPasswordPrs(password: string, salt: Uint8Array): Uint8Array
|
|
116
|
+
/** 扫码接入的 PSK:`HKDF-BLAKE2s(secret, info = "anycon-enroll-scan-v1" ‖ nodeId)`。 */
|
|
117
|
+
deriveEnrollScanPsk(secret: Uint8Array, nodeId: string): Uint8Array
|
|
118
|
+
/** CPace 信道标识,`method` 是 `'code'` 或 `'password'`。 */
|
|
119
|
+
enrollChannelId(nodeId: string, method: string): Uint8Array
|
|
120
|
+
/** CPace 会话标识:新设备的 nonce ‖ daemon 的 nonce。 */
|
|
121
|
+
enrollSid(initiatorNonce: Uint8Array, responderNonce: Uint8Array): Uint8Array
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `loadCrypto()` 的结果:配对 / 重连 / 传输 + 账号内接入。 */
|
|
125
|
+
export type FullCrypto = Crypto & EnrollCrypto
|
|
126
|
+
|
|
127
|
+
/** wasm 产物不存在或加载失败。 */
|
|
128
|
+
export class CryptoUnavailableError extends Error {
|
|
129
|
+
override readonly name = 'CryptoUnavailableError'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- wasm-bindgen 产物的形状(只列我们用到的) ----------
|
|
133
|
+
|
|
134
|
+
interface RawKeypair {
|
|
135
|
+
readonly public: Uint8Array
|
|
136
|
+
readonly secret: Uint8Array
|
|
137
|
+
free(): void
|
|
138
|
+
}
|
|
139
|
+
interface RawTransport {
|
|
140
|
+
encrypt(plaintext: Uint8Array): Uint8Array
|
|
141
|
+
decrypt(ciphertext: Uint8Array): Uint8Array
|
|
142
|
+
readonly remotePublic: Uint8Array | (() => Uint8Array)
|
|
143
|
+
free(): void
|
|
144
|
+
}
|
|
145
|
+
interface RawHandshake {
|
|
146
|
+
readonly isMyTurn: boolean | (() => boolean)
|
|
147
|
+
write(payload: Uint8Array): Uint8Array
|
|
148
|
+
read(message: Uint8Array): Uint8Array
|
|
149
|
+
readonly isFinished: boolean | (() => boolean)
|
|
150
|
+
readonly remotePublic: Uint8Array | undefined | (() => Uint8Array | undefined)
|
|
151
|
+
intoTransport(): RawTransport
|
|
152
|
+
free(): void
|
|
153
|
+
}
|
|
154
|
+
interface RawCpace {
|
|
155
|
+
readonly share: Uint8Array
|
|
156
|
+
finish(role: Role, peerShare: Uint8Array): Uint8Array
|
|
157
|
+
free(): void
|
|
158
|
+
}
|
|
159
|
+
interface RawPairSecrets {
|
|
160
|
+
readonly matchId: string
|
|
161
|
+
readonly psk: Uint8Array
|
|
162
|
+
free(): void
|
|
163
|
+
}
|
|
164
|
+
/** 动态 import 得到的模块。 */
|
|
165
|
+
export interface WasmModule {
|
|
166
|
+
default: (input?: unknown) => Promise<unknown>
|
|
167
|
+
WasmKeypair: {
|
|
168
|
+
generate(): RawKeypair
|
|
169
|
+
fromSecret(secret: Uint8Array): RawKeypair
|
|
170
|
+
}
|
|
171
|
+
WasmHandshake: {
|
|
172
|
+
pairing(role: Role, local: RawKeypair, psk: Uint8Array): RawHandshake
|
|
173
|
+
reconnect(role: Role, local: RawKeypair, remotePublic?: Uint8Array): RawHandshake
|
|
174
|
+
}
|
|
175
|
+
derivePairSecrets(code: string): RawPairSecrets
|
|
176
|
+
derivePsk(code: string): Uint8Array
|
|
177
|
+
deriveMatchId(code: string): string
|
|
178
|
+
normalizeCode(code: string): string
|
|
179
|
+
generatePairCode(): string
|
|
180
|
+
generatePairCodeLong(): string
|
|
181
|
+
fingerprint(publicKey: Uint8Array): string
|
|
182
|
+
WasmCpace: new (prs: Uint8Array, ci: Uint8Array, sid: Uint8Array) => RawCpace
|
|
183
|
+
generateEnrollNonce(): Uint8Array
|
|
184
|
+
generateEnrollSecret(): Uint8Array
|
|
185
|
+
normalizeEnrollCode(code: string): string
|
|
186
|
+
validateEnrollPassword(password: string): void
|
|
187
|
+
deriveEnrollPasswordPrs(password: string, salt: Uint8Array): Uint8Array
|
|
188
|
+
deriveEnrollScanPsk(secret: Uint8Array, nodeId: string): Uint8Array
|
|
189
|
+
enrollChannelId(nodeId: string, method: string): Uint8Array
|
|
190
|
+
enrollSid(initiatorNonce: Uint8Array, responderNonce: Uint8Array): Uint8Array
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `.wasm` 的来源:URL / 字节 / 已编译的 Module / fetch 的 Response。 */
|
|
194
|
+
export type WasmSource =
|
|
195
|
+
| string
|
|
196
|
+
| URL
|
|
197
|
+
| BufferSource
|
|
198
|
+
| WebAssembly.Module
|
|
199
|
+
| Response
|
|
200
|
+
| Promise<Response | BufferSource | WebAssembly.Module>
|
|
201
|
+
|
|
202
|
+
export interface LoadCryptoOptions {
|
|
203
|
+
/**
|
|
204
|
+
* `.wasm` 的位置。浏览器里交给打包器:
|
|
205
|
+
* `loadCrypto({ wasm: new URL('@anycon/protocol/wasm/anycon_crypto_bg.wasm', import.meta.url) })`
|
|
206
|
+
* 或 Vite 的 `?url` 导入。Node / Bun 下不给就自动读 `../wasm/anycon_crypto_bg.wasm`。
|
|
207
|
+
*/
|
|
208
|
+
wasm?: WasmSource
|
|
209
|
+
/** 直接给 wasm-bindgen 的 JS 模块(打包器想自己控制 import 路径时用)。 */
|
|
210
|
+
module?: WasmModule | Promise<WasmModule>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function member<T>(obj: object, name: string): T {
|
|
214
|
+
const value = (obj as Record<string, unknown>)[name]
|
|
215
|
+
return (typeof value === 'function' ? (value as () => T).call(obj) : value) as T
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function wrapTransport(raw: RawTransport): Transport {
|
|
219
|
+
const remote = member<Uint8Array>(raw, 'remotePublic').slice()
|
|
220
|
+
return {
|
|
221
|
+
encrypt: (plaintext) => raw.encrypt(plaintext),
|
|
222
|
+
decrypt: (ciphertext) => raw.decrypt(ciphertext),
|
|
223
|
+
remotePublic: remote,
|
|
224
|
+
free: () => raw.free(),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function wrapHandshake(raw: RawHandshake): Handshake {
|
|
229
|
+
return {
|
|
230
|
+
isMyTurn: () => member<boolean>(raw, 'isMyTurn'),
|
|
231
|
+
write: (payload) => raw.write(payload ?? new Uint8Array(0)),
|
|
232
|
+
read: (message) => raw.read(message),
|
|
233
|
+
isFinished: () => member<boolean>(raw, 'isFinished'),
|
|
234
|
+
remotePublic: () => {
|
|
235
|
+
const key = member<Uint8Array | undefined | null>(raw, 'remotePublic')
|
|
236
|
+
return key ? key.slice() : undefined
|
|
237
|
+
},
|
|
238
|
+
intoTransport: () => wrapTransport(raw.intoTransport()),
|
|
239
|
+
free: () => raw.free(),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function toKeypair(raw: RawKeypair): Keypair {
|
|
244
|
+
const kp: Keypair = { public: raw.public.slice(), secret: raw.secret.slice() }
|
|
245
|
+
raw.free()
|
|
246
|
+
return kp
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function wrapCpace(raw: RawCpace): Cpace {
|
|
250
|
+
let done = false
|
|
251
|
+
return {
|
|
252
|
+
share: raw.share.slice(),
|
|
253
|
+
// wasm 侧 `finish` 按值消费对象,之后 `free` 是空操作
|
|
254
|
+
finish: (role, peerShare) => {
|
|
255
|
+
done = true
|
|
256
|
+
return raw.finish(role, peerShare)
|
|
257
|
+
},
|
|
258
|
+
free: () => {
|
|
259
|
+
if (!done) raw.free()
|
|
260
|
+
done = true
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function wrapModule(mod: WasmModule): FullCrypto {
|
|
266
|
+
// 传给 wasm 的 Keypair 每次临时重建。握手对象建好后释放它:wasm 若按值消费了它,free 是空操作。
|
|
267
|
+
const withKeypair = (kp: Keypair, build: (raw: RawKeypair) => RawHandshake): Handshake => {
|
|
268
|
+
const raw = mod.WasmKeypair.fromSecret(kp.secret)
|
|
269
|
+
try {
|
|
270
|
+
return wrapHandshake(build(raw))
|
|
271
|
+
} finally {
|
|
272
|
+
try {
|
|
273
|
+
raw.free()
|
|
274
|
+
} catch {
|
|
275
|
+
// 已被消费
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
generateKeypair: () => toKeypair(mod.WasmKeypair.generate()),
|
|
281
|
+
keypairFromSecret: (secret) => toKeypair(mod.WasmKeypair.fromSecret(secret)),
|
|
282
|
+
pairing: (role, local, psk) =>
|
|
283
|
+
withKeypair(local, (raw) => mod.WasmHandshake.pairing(role, raw, psk)),
|
|
284
|
+
reconnect: (role, local, remotePublic) =>
|
|
285
|
+
withKeypair(local, (raw) => mod.WasmHandshake.reconnect(role, raw, remotePublic)),
|
|
286
|
+
derivePairSecrets: (code) => {
|
|
287
|
+
const raw = mod.derivePairSecrets(code)
|
|
288
|
+
try {
|
|
289
|
+
// `psk` getter 每次返回一份新的 JS 拷贝(wasm 内存里的原件随 `free()` 清零),所以直接交给调用方,
|
|
290
|
+
// 不再 `.slice()` 多复制一份——多出来的那份没人能清零。调用方用完要 `psk.fill(0)`。
|
|
291
|
+
return { matchId: raw.matchId, psk: raw.psk }
|
|
292
|
+
} finally {
|
|
293
|
+
raw.free()
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
derivePsk: (code) => mod.derivePsk(code),
|
|
297
|
+
deriveMatchId: (code) => mod.deriveMatchId(code),
|
|
298
|
+
normalizeCode: (code) => mod.normalizeCode(code),
|
|
299
|
+
generatePairCode: () => mod.generatePairCode(),
|
|
300
|
+
generatePairCodeLong: () => mod.generatePairCodeLong(),
|
|
301
|
+
fingerprint: (publicKey) => mod.fingerprint(publicKey),
|
|
302
|
+
cpace: (prs, ci, sid) => wrapCpace(new mod.WasmCpace(prs, ci, sid)),
|
|
303
|
+
generateEnrollNonce: () => mod.generateEnrollNonce(),
|
|
304
|
+
generateEnrollSecret: () => mod.generateEnrollSecret(),
|
|
305
|
+
normalizeEnrollCode: (code) => mod.normalizeEnrollCode(code),
|
|
306
|
+
validateEnrollPassword: (password) => mod.validateEnrollPassword(password),
|
|
307
|
+
deriveEnrollPasswordPrs: (password, salt) => mod.deriveEnrollPasswordPrs(password, salt),
|
|
308
|
+
deriveEnrollScanPsk: (secret, nodeId) => mod.deriveEnrollScanPsk(secret, nodeId),
|
|
309
|
+
enrollChannelId: (nodeId, method) => mod.enrollChannelId(nodeId, method),
|
|
310
|
+
enrollSid: (a, b) => mod.enrollSid(a, b),
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isNode = (): boolean =>
|
|
315
|
+
typeof (globalThis as { process?: { versions?: { node?: string } } }).process?.versions?.node ===
|
|
316
|
+
'string'
|
|
317
|
+
|
|
318
|
+
async function importWasmModule(): Promise<WasmModule> {
|
|
319
|
+
try {
|
|
320
|
+
// 字面量路径:打包器(Vite 等)能静态分析并把 wasm 胶水 js 打进来。
|
|
321
|
+
// @ts-ignore -- 产物由 `just gen-wasm` 生成,可能不存在,类型也由本文件的 WasmModule 描述。
|
|
322
|
+
return (await import('../wasm/anycon_crypto.js')) as unknown as WasmModule
|
|
323
|
+
} catch (cause) {
|
|
324
|
+
throw new CryptoUnavailableError(
|
|
325
|
+
'anycon crypto wasm is not built: packages/protocol/wasm/anycon_crypto.js is missing. ' +
|
|
326
|
+
'Run `just gen-wasm` (wasm-pack build of crates/crypto) first.',
|
|
327
|
+
{ cause },
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function nodeWasmBytes(): Promise<Uint8Array> {
|
|
333
|
+
// 变量指定符:不让浏览器打包器解析 node 内置模块。
|
|
334
|
+
const fsSpecifier = 'node:fs/promises'
|
|
335
|
+
const { readFile } = (await import(/* @vite-ignore */ fsSpecifier)) as {
|
|
336
|
+
readFile(url: URL): Promise<Uint8Array>
|
|
337
|
+
}
|
|
338
|
+
const url = new URL('../wasm/anycon_crypto_bg.wasm', import.meta.url)
|
|
339
|
+
try {
|
|
340
|
+
return await readFile(url)
|
|
341
|
+
} catch (cause) {
|
|
342
|
+
throw new CryptoUnavailableError(
|
|
343
|
+
`anycon crypto wasm binary not found at ${url.pathname}. Run \`just gen-wasm\`.`,
|
|
344
|
+
{ cause },
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function initialize(options: LoadCryptoOptions): Promise<FullCrypto> {
|
|
350
|
+
const mod = await (options.module ?? importWasmModule())
|
|
351
|
+
const input: unknown = options.wasm ?? (isNode() ? await nodeWasmBytes() : undefined)
|
|
352
|
+
// wasm-bindgen ≥ 0.2.93 的对象形参;不给 input 时浏览器走胶水 js 里的 `new URL(…, import.meta.url)`。
|
|
353
|
+
await mod.default(input === undefined ? undefined : { module_or_path: input })
|
|
354
|
+
return wrapModule(mod)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let cached: Promise<FullCrypto> | undefined
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 加载并初始化 wasm。进程内只初始化一次(第二次调用返回同一个 Promise;失败不缓存,可重试)。
|
|
361
|
+
* wasm 产物不存在时抛 `CryptoUnavailableError`,消息里写明要跑 `just gen-wasm`。
|
|
362
|
+
*/
|
|
363
|
+
export function loadCrypto(options: LoadCryptoOptions = {}): Promise<FullCrypto> {
|
|
364
|
+
if (!cached) {
|
|
365
|
+
cached = initialize(options).catch((err: unknown) => {
|
|
366
|
+
cached = undefined
|
|
367
|
+
throw err
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
return cached
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 短码规范化(纯 JS):大写,去掉 `-` 和空白。与 `crates/crypto` 的 `normalize_code` 一致。 */
|
|
374
|
+
export function normalizePairCode(code: string): string {
|
|
375
|
+
return code.replace(/[\s-]/g, '').toUpperCase()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 短码格式校验:规范化后是 8 位或 16 位,且都在字母表内。纯 JS,不需要 wasm。 */
|
|
379
|
+
export function isValidPairCode(code: string): boolean {
|
|
380
|
+
const normalized = normalizePairCode(code)
|
|
381
|
+
if (normalized.length !== PAIR_CODE_LEN && normalized.length !== PAIR_CODE_LEN_LONG) return false
|
|
382
|
+
for (const ch of normalized) if (!PAIR_CODE_ALPHABET.includes(ch)) return false
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* `XXXXXXXX` → `XXXX-XXXX`,16 位 → `XXXX-XXXX-XXXX-XXXX`(每 4 位一组)。
|
|
388
|
+
* 长度既不是 8 也不是 16 时原样返回规范化结果。
|
|
389
|
+
*/
|
|
390
|
+
export function formatPairCode(code: string): string {
|
|
391
|
+
const normalized = normalizePairCode(code)
|
|
392
|
+
if (normalized.length !== PAIR_CODE_LEN && normalized.length !== PAIR_CODE_LEN_LONG) {
|
|
393
|
+
return normalized
|
|
394
|
+
}
|
|
395
|
+
return normalized.match(/.{4}/g)!.join('-')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 从用户输入里取出短码:接受裸短码(8 / 16 位,大小写、`-`、空白随意)、完整配对链接
|
|
400
|
+
* `https://hub.example/pair#code=XXXX-XXXX`、或只有 fragment 的 `#code=…` / `code=…`。
|
|
401
|
+
* 短码在 fragment 里,浏览器不会把它发给 hub。格式不合法返回 `undefined`。
|
|
402
|
+
*/
|
|
403
|
+
export function extractPairCode(input: string): string | undefined {
|
|
404
|
+
const text = input.trim()
|
|
405
|
+
if (text === '') return undefined
|
|
406
|
+
let candidate = text
|
|
407
|
+
const hashAt = text.indexOf('#')
|
|
408
|
+
if (hashAt >= 0) {
|
|
409
|
+
candidate = new URLSearchParams(text.slice(hashAt + 1)).get('code') ?? ''
|
|
410
|
+
} else if (/^code=/i.test(text)) {
|
|
411
|
+
candidate = new URLSearchParams(text).get('code') ?? ''
|
|
412
|
+
}
|
|
413
|
+
return isValidPairCode(candidate) ? formatPairCode(candidate) : undefined
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function hexOf(bytes: Uint8Array): string {
|
|
417
|
+
let out = ''
|
|
418
|
+
for (const b of bytes) out += b.toString(16).padStart(2, '0')
|
|
419
|
+
return out
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function bytesOfHex(hex: string): Uint8Array {
|
|
423
|
+
if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) throw new Error('invalid hex string')
|
|
424
|
+
const out = new Uint8Array(hex.length / 2)
|
|
425
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
426
|
+
return out
|
|
427
|
+
}
|