@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.
Files changed (49) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +146 -0
  3. package/README.zh-CN.md +118 -0
  4. package/dist/constants.d.ts +151 -0
  5. package/dist/constants.d.ts.map +1 -0
  6. package/dist/constants.js +159 -0
  7. package/dist/constants.js.map +1 -0
  8. package/dist/crypto.d.ts +202 -0
  9. package/dist/crypto.d.ts.map +1 -0
  10. package/dist/crypto.js +218 -0
  11. package/dist/crypto.js.map +1 -0
  12. package/dist/envelope.d.ts +105 -0
  13. package/dist/envelope.d.ts.map +1 -0
  14. package/dist/envelope.js +390 -0
  15. package/dist/envelope.js.map +1 -0
  16. package/dist/errors.d.ts +5 -0
  17. package/dist/errors.d.ts.map +1 -0
  18. package/dist/errors.js +5 -0
  19. package/dist/errors.js.map +1 -0
  20. package/dist/generated.d.ts +2007 -0
  21. package/dist/generated.d.ts.map +1 -0
  22. package/dist/generated.js +4 -0
  23. package/dist/generated.js.map +1 -0
  24. package/dist/index.d.ts +17 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +16 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/method-types.d.ts +293 -0
  29. package/dist/method-types.d.ts.map +1 -0
  30. package/dist/method-types.js +2 -0
  31. package/dist/method-types.js.map +1 -0
  32. package/dist/rpc.d.ts +70 -0
  33. package/dist/rpc.d.ts.map +1 -0
  34. package/dist/rpc.js +310 -0
  35. package/dist/rpc.js.map +1 -0
  36. package/package.json +62 -0
  37. package/src/constants.ts +173 -0
  38. package/src/crypto.ts +427 -0
  39. package/src/envelope.ts +469 -0
  40. package/src/errors.ts +4 -0
  41. package/src/generated.ts +2094 -0
  42. package/src/index.ts +18 -0
  43. package/src/method-types.ts +183 -0
  44. package/src/rpc.ts +377 -0
  45. package/wasm/THIRD-PARTY-LICENSES.txt +1957 -0
  46. package/wasm/anycon_crypto.d.ts +248 -0
  47. package/wasm/anycon_crypto.js +1250 -0
  48. package/wasm/anycon_crypto_bg.wasm +0 -0
  49. 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
+ }