mqtt-crypto-client 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mqtt-crypto-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "MQTT over WebSocket client with CryptoJS-compatible AES envelope encryption, heartbeat, message deduplication and request/response helpers.",
5
5
  "keywords": [
6
6
  "mqtt",
@@ -15,6 +15,8 @@
15
15
  "main": "./dist/index.cjs",
16
16
  "module": "./src/index.js",
17
17
  "types": "./types/index.d.ts",
18
+ "unpkg": "./dist/index.iife.js",
19
+ "jsdelivr": "./dist/index.iife.js",
18
20
  "exports": {
19
21
  ".": {
20
22
  "types": "./types/index.d.ts",
@@ -35,7 +37,7 @@
35
37
  "node": ">=18"
36
38
  },
37
39
  "scripts": {
38
- "build": "node scripts/build-cjs.mjs",
40
+ "build": "node scripts/build-cjs.mjs && node scripts/build-iife.mjs && node scripts/check-iife.mjs",
39
41
  "test": "node --test",
40
42
  "typecheck": "tsc -p tsconfig.json",
41
43
  "prepack": "npm run build",
@@ -47,6 +49,7 @@
47
49
  },
48
50
  "devDependencies": {
49
51
  "@types/node": "^22.10.0",
52
+ "crypto-js": "^4.2.0",
50
53
  "esbuild": "^0.25.0",
51
54
  "typescript": "^5.7.0"
52
55
  },
package/src/browser.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 浏览器 IIFE 构建入口。
3
+ *
4
+ * 通过 esbuild 的 alias 插件把 `./crypto.js` 替换为 `./crypto-browser.js`,
5
+ * 把 `node:events` 替换为本地 shim;对外的导出集合与 Node 入口保持一致。
6
+ * 构建产物:dist/index.iife.js,全局名 `MqttCryptoClient`。
7
+ */
8
+ export * from './index.js';
@@ -0,0 +1,295 @@
1
+ /**
2
+ * 浏览器端加解密实现:使用 crypto-js(仅打包进 IIFE),保持与 Node 版逐字节兼容。
3
+ *
4
+ * 兼容格式(OpenSSL "Salted__" 格式):
5
+ * 密文 = base64( "Salted__"(8 字节) + salt(默认 8 字节) + AES-256-CBC 密文 )
6
+ * 密钥/IV = EVP_BytesToKey(MD5, 1 次迭代, passphrase, salt),key 32 字节 + IV 16 字节
7
+ * 填充 = PKCS#7
8
+ *
9
+ * 与 Node 版差异:salt 参数接受 Uint8Array / crypto-js WordArray(Node 版要求 Buffer);
10
+ * deriveKeyAndIv 返回 Uint8Array。API 保持同步,不依赖 WebCrypto。
11
+ */
12
+ import CryptoJS from 'crypto-js';
13
+ import { CryptoError } from './errors.js';
14
+
15
+ const SALT_HEADER = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
16
+ const KEY_LENGTH = 32;
17
+ const IV_LENGTH = 16;
18
+ const DEFAULT_SALT_LENGTH = 8;
19
+ const BLOCK_SIZE = 16;
20
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
21
+
22
+ const strictUtf8Decoder = new TextDecoder('utf-8', { fatal: true });
23
+
24
+ /**
25
+ * @param {unknown} secret
26
+ */
27
+ function assertSecret(secret) {
28
+ if (typeof secret !== 'string' || secret.length === 0) {
29
+ throw new CryptoError('secret 必须是非空字符串', 'MISSING_SECRET');
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {unknown} value
35
+ * @returns {value is Uint8Array}
36
+ */
37
+ function isByteView(value) {
38
+ return ArrayBuffer.isView(value) && /** @type {any} */ (value).BYTES_PER_ELEMENT === 1;
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} value
43
+ * @returns {value is { words: number[], sigBytes: number }}
44
+ */
45
+ function isWordArray(value) {
46
+ return Boolean(
47
+ value &&
48
+ typeof value === 'object' &&
49
+ Array.isArray(/** @type {any} */ (value).words) &&
50
+ typeof /** @type {any} */ (value).sigBytes === 'number',
51
+ );
52
+ }
53
+
54
+ /**
55
+ * @param {{ words: number[], sigBytes: number }} wordArray
56
+ * @returns {Uint8Array}
57
+ */
58
+ function wordArrayToBytes(wordArray) {
59
+ const bytes = new Uint8Array(wordArray.sigBytes);
60
+ for (let i = 0; i < wordArray.sigBytes; i += 1) {
61
+ bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
62
+ }
63
+ return bytes;
64
+ }
65
+
66
+ /**
67
+ * @param {Uint8Array} bytes
68
+ * @returns {{ words: number[], sigBytes: number }}
69
+ */
70
+ function bytesToWordArray(bytes) {
71
+ /** @type {number[]} */
72
+ const words = [];
73
+ for (let i = 0; i < bytes.length; i += 1) {
74
+ words[i >>> 2] = (words[i >>> 2] ?? 0) | (bytes[i] << (24 - (i % 4) * 8));
75
+ }
76
+ return CryptoJS.lib.WordArray.create(words, bytes.length);
77
+ }
78
+
79
+ /**
80
+ * @param {unknown} value
81
+ * @param {string} name
82
+ * @returns {Uint8Array}
83
+ */
84
+ function toBytes(value, name) {
85
+ if (isByteView(value)) return new Uint8Array(value);
86
+ if (isWordArray(value)) return wordArrayToBytes(value);
87
+ throw new CryptoError(`${name} 必须是 Uint8Array 或 crypto-js WordArray`, 'INVALID_SALT');
88
+ }
89
+
90
+ /**
91
+ * @param {string} secret
92
+ * @param {Uint8Array} salt
93
+ * @param {number} [keyLength]
94
+ * @param {number} [ivLength]
95
+ * @returns {{ key: { words: number[], sigBytes: number }, iv: { words: number[], sigBytes: number } }}
96
+ */
97
+ function deriveWordArrays(secret, salt, keyLength = KEY_LENGTH, ivLength = IV_LENGTH) {
98
+ if (!Number.isInteger(keyLength) || keyLength <= 0 || keyLength % 4 !== 0) {
99
+ throw new CryptoError('keyLength 必须是 4 的正整数倍', 'INVALID_ARGUMENT');
100
+ }
101
+ if (!Number.isInteger(ivLength) || ivLength <= 0 || ivLength % 4 !== 0) {
102
+ throw new CryptoError('ivLength 必须是 4 的正整数倍', 'INVALID_ARGUMENT');
103
+ }
104
+ const kdf = CryptoJS.kdf.OpenSSL.execute(
105
+ secret,
106
+ keyLength / 4,
107
+ ivLength / 4,
108
+ bytesToWordArray(salt),
109
+ );
110
+ return { key: kdf.key, iv: kdf.iv };
111
+ }
112
+
113
+ /**
114
+ * OpenSSL EVP_BytesToKey(MD5,count=1),与 CryptoJS 的 OpenSSLKdf 一致。
115
+ * @param {string} secret 口令
116
+ * @param {Uint8Array | { words: number[], sigBytes: number }} salt
117
+ * @param {number} [keyLength]
118
+ * @param {number} [ivLength]
119
+ * @returns {{ key: Uint8Array, iv: Uint8Array }}
120
+ */
121
+ export function deriveKeyAndIv(secret, salt, keyLength = KEY_LENGTH, ivLength = IV_LENGTH) {
122
+ assertSecret(secret);
123
+ const saltBytes = toBytes(salt, 'salt');
124
+ const { key, iv } = deriveWordArrays(secret, saltBytes, keyLength, ivLength);
125
+ return { key: wordArrayToBytes(key), iv: wordArrayToBytes(iv) };
126
+ }
127
+
128
+ /**
129
+ * @param {Uint8Array} bytes
130
+ * @returns {Uint8Array}
131
+ */
132
+ function padPkcs7(bytes) {
133
+ const padLength = BLOCK_SIZE - (bytes.length % BLOCK_SIZE);
134
+ const padded = new Uint8Array(bytes.length + padLength);
135
+ padded.set(bytes, 0);
136
+ padded.fill(padLength, bytes.length);
137
+ return padded;
138
+ }
139
+
140
+ /**
141
+ * @param {Uint8Array} padded
142
+ * @returns {Uint8Array}
143
+ */
144
+ function unpadPkcs7(padded) {
145
+ const padLength = padded[padded.length - 1];
146
+ if (padLength === undefined || padLength < 1 || padLength > BLOCK_SIZE || padLength > padded.length) {
147
+ throw new CryptoError('解密失败(PKCS#7 填充非法)', 'DECRYPT_FAILED');
148
+ }
149
+ for (let i = padded.length - padLength; i < padded.length; i += 1) {
150
+ if (padded[i] !== padLength) {
151
+ throw new CryptoError('解密失败(PKCS#7 填充非法)', 'DECRYPT_FAILED');
152
+ }
153
+ }
154
+ return padded.subarray(0, padded.length - padLength);
155
+ }
156
+
157
+ /**
158
+ * 加密任意字符串,返回与 Node 版 / CryptoJS 完全兼容的 base64 密文。
159
+ * @param {string} plaintext
160
+ * @param {string} secret
161
+ * @param {{ saltLength?: number, salt?: Uint8Array | { words: number[], sigBytes: number } }} [options]
162
+ * @returns {string}
163
+ */
164
+ export function encryptPayload(plaintext, secret, options = {}) {
165
+ if (typeof plaintext !== 'string') {
166
+ throw new CryptoError('plaintext 必须是字符串', 'INVALID_PLAINTEXT');
167
+ }
168
+ assertSecret(secret);
169
+
170
+ const saltLength = options.saltLength ?? DEFAULT_SALT_LENGTH;
171
+ if (!Number.isInteger(saltLength) || saltLength < 0) {
172
+ throw new CryptoError('saltLength 必须是非负整数', 'INVALID_SALT');
173
+ }
174
+ const salt = options.salt !== undefined
175
+ ? toBytes(options.salt, 'salt')
176
+ : wordArrayToBytes(CryptoJS.lib.WordArray.random(saltLength));
177
+
178
+ try {
179
+ const { key, iv } = deriveWordArrays(secret, salt);
180
+ const plaintextBytes = new TextEncoder().encode(plaintext);
181
+ const encrypted = CryptoJS.AES.encrypt(bytesToWordArray(padPkcs7(plaintextBytes)), key, {
182
+ iv,
183
+ mode: CryptoJS.mode.CBC,
184
+ padding: CryptoJS.pad.NoPadding,
185
+ });
186
+ const output = CryptoJS.lib.WordArray.create(
187
+ [0x53616c74, 0x65645f5f],
188
+ SALT_HEADER.length,
189
+ );
190
+ const withSalt = output.concat(bytesToWordArray(salt));
191
+ return withSalt.concat(encrypted.ciphertext).toString(CryptoJS.enc.Base64);
192
+ } catch (error) {
193
+ if (error instanceof CryptoError) throw error;
194
+ throw new CryptoError('加密失败', 'ENCRYPT_FAILED', /** @type {Error} */ (error));
195
+ }
196
+ }
197
+
198
+ /**
199
+ * @param {Uint8Array} raw
200
+ * @returns {{ salt: Uint8Array, ciphertext: Uint8Array }}
201
+ */
202
+ function splitCiphertext(raw) {
203
+ const hasHeader =
204
+ raw.length >= SALT_HEADER.length &&
205
+ SALT_HEADER.every((byte, index) => raw[index] === byte);
206
+
207
+ if (hasHeader) {
208
+ if (raw.length < SALT_HEADER.length + DEFAULT_SALT_LENGTH) {
209
+ throw new CryptoError('密文缺少 salt', 'INVALID_CIPHERTEXT');
210
+ }
211
+ return {
212
+ salt: raw.subarray(SALT_HEADER.length, SALT_HEADER.length + DEFAULT_SALT_LENGTH),
213
+ ciphertext: raw.subarray(SALT_HEADER.length + DEFAULT_SALT_LENGTH),
214
+ };
215
+ }
216
+ return { salt: new Uint8Array(0), ciphertext: raw };
217
+ }
218
+
219
+ /**
220
+ * 解密并返回 UTF-8 明文;失败时抛出 CryptoError。
221
+ * @param {string} ciphertext base64 密文
222
+ * @param {string} secret 口令
223
+ * @returns {string}
224
+ */
225
+ export function decryptPayload(ciphertext, secret) {
226
+ if (typeof ciphertext !== 'string') {
227
+ throw new CryptoError('密文必须是字符串', 'INVALID_CIPHERTEXT');
228
+ }
229
+ assertSecret(secret);
230
+
231
+ const normalized = ciphertext.trim();
232
+ if (!normalized || normalized.length % 4 !== 0 || !BASE64_PATTERN.test(normalized)) {
233
+ throw new CryptoError('密文不是合法的 base64', 'INVALID_CIPHERTEXT');
234
+ }
235
+
236
+ const raw = wordArrayToBytes(CryptoJS.enc.Base64.parse(normalized));
237
+ if (raw.length < SALT_HEADER.length) {
238
+ throw new CryptoError('密文长度不足', 'INVALID_CIPHERTEXT');
239
+ }
240
+
241
+ const { salt, ciphertext: body } = splitCiphertext(raw);
242
+ if (body.length === 0 || body.length % BLOCK_SIZE !== 0) {
243
+ throw new CryptoError('密文块长度非法', 'INVALID_CIPHERTEXT');
244
+ }
245
+
246
+ try {
247
+ const { key, iv } = deriveWordArrays(secret, salt);
248
+ const decrypted = CryptoJS.AES.decrypt(
249
+ CryptoJS.lib.CipherParams.create({ ciphertext: bytesToWordArray(body) }),
250
+ key,
251
+ { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding },
252
+ );
253
+ const unpadded = unpadPkcs7(wordArrayToBytes(decrypted));
254
+ return strictUtf8Decoder.decode(unpadded);
255
+ } catch (error) {
256
+ if (error instanceof CryptoError) throw error;
257
+ throw new CryptoError(
258
+ '解密失败(口令错误、密文损坏或不是 UTF-8)',
259
+ 'DECRYPT_FAILED',
260
+ /** @type {Error} */ (error),
261
+ );
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 宽松解密:任何失败都返回 null。
267
+ * @param {string} ciphertext
268
+ * @param {string} secret
269
+ * @returns {string | null}
270
+ */
271
+ export function tryDecrypt(ciphertext, secret) {
272
+ if (typeof ciphertext !== 'string' || !ciphertext || !secret) return null;
273
+ try {
274
+ const plaintext = decryptPayload(ciphertext, secret);
275
+ return plaintext.length > 0 ? plaintext : null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 判断字符串是否是本库/CryptoJS 的 "Salted__" base64 密文(仅看头部)。
283
+ * @param {string} value
284
+ * @returns {boolean}
285
+ */
286
+ export function isEncryptedPayload(value) {
287
+ if (typeof value !== 'string') return false;
288
+ const normalized = value.trim();
289
+ if (!normalized || normalized.length % 4 !== 0 || !BASE64_PATTERN.test(normalized)) return false;
290
+ const raw = wordArrayToBytes(CryptoJS.enc.Base64.parse(normalized));
291
+ return (
292
+ raw.length >= SALT_HEADER.length &&
293
+ SALT_HEADER.every((byte, index) => raw[index] === byte)
294
+ );
295
+ }
@@ -0,0 +1,4 @@
1
+ declare module 'crypto-js' {
2
+ const CryptoJS: any;
3
+ export default CryptoJS;
4
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 浏览器构建用的极简 EventEmitter,替代 Node 的 `node:events`。
3
+ * 仅实现本库客户端实际使用的方法,避免把 events 包变成运行时依赖。
4
+ */
5
+ export class EventEmitter {
6
+ constructor() {
7
+ /** @type {Map<string | symbol, Set<Function>>} */
8
+ this._listeners = new Map();
9
+ }
10
+
11
+ /**
12
+ * @param {string | symbol} event
13
+ * @param {Function} listener
14
+ * @returns {this}
15
+ */
16
+ on(event, listener) {
17
+ if (typeof listener !== 'function') {
18
+ throw new TypeError('listener 必须是函数');
19
+ }
20
+ let listeners = this._listeners.get(event);
21
+ if (!listeners) {
22
+ listeners = new Set();
23
+ this._listeners.set(event, listeners);
24
+ }
25
+ listeners.add(listener);
26
+ return this;
27
+ }
28
+
29
+ /**
30
+ * @param {string | symbol} event
31
+ * @param {Function} listener
32
+ * @returns {this}
33
+ */
34
+ addListener(event, listener) {
35
+ return this.on(event, listener);
36
+ }
37
+
38
+ /**
39
+ * @param {string | symbol} event
40
+ * @param {Function} listener
41
+ * @returns {this}
42
+ */
43
+ once(event, listener) {
44
+ /** @type {((...args: any[]) => void) & { listener?: Function }} */
45
+ const wrapper = (...args) => {
46
+ this.off(event, wrapper);
47
+ listener(...args);
48
+ };
49
+ wrapper.listener = listener;
50
+ return this.on(event, wrapper);
51
+ }
52
+
53
+ /**
54
+ * @param {string | symbol} event
55
+ * @param {Function} listener
56
+ * @returns {this}
57
+ */
58
+ off(event, listener) {
59
+ const listeners = this._listeners.get(event);
60
+ if (!listeners) return this;
61
+ for (const candidate of listeners) {
62
+ const original = /** @type {any} */ (candidate).listener;
63
+ if (candidate === listener || original === listener) {
64
+ listeners.delete(candidate);
65
+ }
66
+ }
67
+ if (listeners.size === 0) this._listeners.delete(event);
68
+ return this;
69
+ }
70
+
71
+ /**
72
+ * @param {string | symbol} event
73
+ * @param {Function} listener
74
+ * @returns {this}
75
+ */
76
+ removeListener(event, listener) {
77
+ return this.off(event, listener);
78
+ }
79
+
80
+ /**
81
+ * @param {string | symbol} [event]
82
+ * @returns {this}
83
+ */
84
+ removeAllListeners(event) {
85
+ if (event === undefined) this._listeners.clear();
86
+ else this._listeners.delete(event);
87
+ return this;
88
+ }
89
+
90
+ /**
91
+ * @param {string | symbol} event
92
+ * @returns {number}
93
+ */
94
+ listenerCount(event) {
95
+ return this._listeners.get(event)?.size ?? 0;
96
+ }
97
+
98
+ /**
99
+ * @param {string | symbol} event
100
+ * @param {...any} args
101
+ * @returns {boolean}
102
+ */
103
+ emit(event, ...args) {
104
+ const listeners = this._listeners.get(event);
105
+ if (!listeners || listeners.size === 0) {
106
+ if (event === 'error') throw args[0] instanceof Error ? args[0] : new Error(String(args[0]));
107
+ return false;
108
+ }
109
+ for (const listener of [...listeners]) {
110
+ listener(...args);
111
+ }
112
+ return true;
113
+ }
114
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 仅用于浏览器 IIFE 的 process 兜底(通过 esbuild `inject` 注入)。
3
+ * Node/ESM/CJS 构建不会包含本文件。
4
+ *
5
+ * 优先由构建期 define 消除 `process.env.*` 等引用;本 shim 只处理无法静态消除的裸 `process`。
6
+ */
7
+ const env = Object.freeze({ NODE_ENV: 'production' });
8
+
9
+ /**
10
+ * @param {Function} callback
11
+ * @param {...any} args
12
+ */
13
+ function nextTick(callback, ...args) {
14
+ queueMicrotask(() => callback(...args));
15
+ }
16
+
17
+ export const process = Object.freeze({
18
+ env,
19
+ browser: true,
20
+ version: '0.0.0',
21
+ platform: 'browser',
22
+ nextTick,
23
+ });
package/types/index.d.ts CHANGED
@@ -171,14 +171,15 @@ export declare function isResponseCommand(command: Record<string, any> | null |
171
171
  export declare function encryptPayload(
172
172
  plaintext: string,
173
173
  secret: string,
174
- options?: { saltLength?: number; salt?: Buffer },
174
+ options?: { saltLength?: number; salt?: Uint8Array },
175
175
  ): string;
176
176
  export declare function decryptPayload(ciphertext: string, secret: string): string;
177
177
  export declare function tryDecrypt(ciphertext: string, secret: string): string | null;
178
178
  export declare function isEncryptedPayload(value: string): boolean;
179
- export declare function deriveKeyAndIv(secret: string, salt: Buffer, keyLength?: number, ivLength?: number): {
180
- key: Buffer;
181
- iv: Buffer;
179
+ /** Node 返回 Buffer;浏览器 IIFE 返回 Uint8Array(均兼容 Uint8Array 操作) */
180
+ export declare function deriveKeyAndIv(secret: string, salt: Uint8Array, keyLength?: number, ivLength?: number): {
181
+ key: Uint8Array;
182
+ iv: Uint8Array;
182
183
  };
183
184
 
184
185
  export declare function decodeIncoming(text: string, secret?: string): {