mqtt-crypto-client 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 weijia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NPM-SETUP.md ADDED
@@ -0,0 +1,134 @@
1
+ # npm 发布设置(Trusted Publishing / OIDC)
2
+
3
+ 本文只描述**需要你在 npmjs 上完成的一次性设置**。本地工作区、CI 配置与 GitHub 公开仓库均已就绪,未执行 `npm publish`。
4
+
5
+ - 包名:`mqtt-crypto-client`
6
+ - npm 包页面(首次发布后生成):<https://www.npmjs.com/package/mqtt-crypto-client>
7
+ - GitHub 仓库:<https://github.com/weijia/mqtt-crypto-client>(public,`main` 已推送)
8
+
9
+ - 发布工作流:`.github/workflows/publish.yml`(文件名 `publish.yml`)
10
+ - 触发方式:推送 `v*` tag,或发布 GitHub Release
11
+
12
+ ## 1. 包名占用检查(已执行)
13
+
14
+ ```bash
15
+ npm view mqtt-crypto-client version
16
+ ```
17
+
18
+ 执行结果(2026-11-03):`E404 Not Found ... 'mqtt-crypto-client@*' is not in this registry`,即当前 **未被占用**。npm 名称先到先得,正式发布前建议再执行一次确认。
19
+
20
+ ## 2. 首次发布前必须知道的前提
21
+
22
+ npm 的 Trusted Publisher 配置入口位于**包的 Settings 页面**,而该页面只有在包已经存在于 registry 后才存在。因此 OIDC 无法完成「从未发布过的包」的第一次发布。可选做法:
23
+
24
+ 1. **推荐**:本地手动完成 0.1.0 的首次发布(仅这一次使用登录态/2FA,不产生长期 token):
25
+
26
+ ```bash
27
+ npm login
28
+ npm ci
29
+ npm test
30
+ npm run typecheck
31
+ npm run build
32
+ npm publish --access public
33
+ ```
34
+
35
+ 随后立即在 npmjs 上配置 Trusted Publisher(见第 3 节),之后所有版本都由 CI 通过 OIDC 发布。
36
+
37
+ 2. 或者用**临时 granular access token** 在 CI 里完成首次发布,然后删除该 token,再切换到 OIDC。
38
+
39
+ > 如果你不希望在本地发布,也可以先在 npmjs 上创建一个同名占位包(例如发布一个极简 0.0.0),再配置 OIDC;但第一次发布的版本号会占用 registry 历史。
40
+
41
+ ## 3. npmjs 上需要填写的 Trusted Publisher
42
+
43
+ 路径:**npmjs.com → 你的包 `mqtt-crypto-client` → Settings → Trusted publishing → GitHub Actions**
44
+
45
+ | 字段 | 填写值 |
46
+ | --- | --- |
47
+ | Organization or user | `weijia` |
48
+ | Repository | `mqtt-crypto-client` |
49
+ | Workflow filename | `publish.yml`(只填文件名,不要带 `.github/workflows/` 路径) |
50
+ | Environment name | **留空**(本仓库工作流没有配置 GitHub Environment) |
51
+ | Allowed actions | 勾选 **`npm publish`**(如需改 dist-tag 再勾 `npm dist-tag`) |
52
+
53
+ 注意:npm 自 2026-03-09 起,新建的 trusted publisher 默认只允许 `npm stage publish`。本工作流使用 `npm publish`,所以必须显式勾选 `npm publish`,否则 CI 会认证成功但被拒绝直接发布。
54
+
55
+ ### Provenance
56
+
57
+ - npmjs 侧**没有单独的 provenance 开关**;provenance 由发布命令与 CI 环境决定。
58
+ - 本仓库已配置:工作流 `permissions: id-token: write`,发布命令 `npm publish --provenance --access public`,`package.json` 中 `publishConfig.provenance: true`。
59
+ - 生成 provenance 需要:公开 GitHub 仓库 + GitHub 托管的 Actions runner(自托管 runner 不支持)。本仓库为公开仓库,满足条件。
60
+ - 发布成功后,npm 包页面会出现 provenance 标识,可用 `npm view mqtt-crypto-client@<version> dist.attestations` 检查。
61
+
62
+ ### 发布访问策略(建议)
63
+
64
+ 在同一 Settings 页面把 **Publishing access** 设为:
65
+
66
+ - **Require two-factor authentication and disallow tokens**(要求 2FA、禁止长期 token)
67
+
68
+ 这样只保留 OIDC 发布路径。设置前请确认 Trusted Publisher 已生效,避免把自己锁死。
69
+
70
+ ## 4. CI 工作流行为
71
+
72
+ `.github/workflows/publish.yml`:
73
+
74
+ ```yaml
75
+ on:
76
+ push:
77
+ tags: ['v*']
78
+ release:
79
+ types: [published]
80
+
81
+ permissions:
82
+ id-token: write # OIDC
83
+ contents: read
84
+ ```
85
+
86
+ 步骤:checkout → setup-node 24 → 升级 npm 到 `>=11.5.1`(Trusted Publishing 的最低要求,Node 22.14+)→ `npm ci` → `npm test` → `npm run typecheck` → `npm run build` → `npm publish --provenance --access public`。
87
+
88
+ 工作流**不读取 `NPM_TOKEN`**,也没有设置 `NODE_AUTH_TOKEN`。
89
+
90
+ 发布一个新版本:
91
+
92
+ ```bash
93
+ npm version patch # 或 minor / major
94
+ git push origin main
95
+ git push origin v0.1.1 # 触发 CI
96
+ ```
97
+
98
+ > 同一个版本不要同时通过 tag 和 Release 触发两次;第二次会因版本已存在而失败。
99
+
100
+ ## 5. 如果你的 CI 不是 GitHub Actions(如 Gitea)
101
+
102
+ npm Trusted Publishing 目前只支持:GitHub Actions(GitHub 托管 runner)、GitLab.com 共享 runner、CircleCI。Gitea / Jenkins / 自建 runner **不能使用 OIDC 直连 npmjs**。替代方案:
103
+
104
+ 1. 在 npmjs → Access Tokens 创建 **Granular Automation Token**(仅限该包、仅 publish 权限)。
105
+ 2. 存入 Gitea 仓库 Secret(例如 `NPM_TOKEN`)。
106
+ 3. CI 中写入临时 `.npmrc`(不要提交到仓库):
107
+
108
+ ```bash
109
+ echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
110
+ npm ci && npm test && npm run build
111
+ npm publish --access public
112
+ ```
113
+
114
+ 4. `--provenance` 在不受支持的 CI 上不可用,请去掉该参数与 `publishConfig.provenance`,否则发布会失败。
115
+
116
+ 代价是重新引入长期 token,需要定期轮换并限制权限。
117
+
118
+ ## 6. 发布后核对
119
+
120
+ ```bash
121
+ npm view mqtt-crypto-client version
122
+ npm view mqtt-crypto-client dist.tarball
123
+ npm view mqtt-crypto-client dist.attestations
124
+ npm view mqtt-crypto-client repository.url
125
+ ```
126
+
127
+ 同时确认包页面显示 GitHub 仓库与 provenance 徽标。
128
+
129
+ ## 7. 仍需你确认/替换的信息
130
+
131
+ - 包名 `mqtt-crypto-client` 是否最终采用(当前未被占用)。
132
+ - 仓库地址已使用真实地址 `https://github.com/weijia/mqtt-crypto-client`;若迁移仓库,需要同步更新 `package.json` 的 `repository/bugs/homepage` 与 npm Trusted Publisher 的 org/repo。
133
+ - `LICENSE` 的版权主体当前写为 `weijia`,如有正式主体请替换。
134
+ - 首次发布必须由你手动执行或在 npmjs 上完成一次 token 发布(见第 2 节),之后 OIDC 链路即可全自动。
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # mqtt-crypto-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/mqtt-crypto-client.svg)](https://www.npmjs.com/package/mqtt-crypto-client)
4
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org/)
5
+
6
+ 从 `vue-dialog-userscript` 中抽取的 **MQTT over WebSocket + AES 信封加密** 客户端。保留原脚本的真实协议行为:`{ id, msgId, user, msg, time }` 信封、CryptoJS 兼容的 AES 口令加密、QoS 1、30s 心跳、`ping/pong`、5s 去重、忽略自身消息、请求/响应按 `msgId` 配对。
7
+
8
+ - 仓库:<https://github.com/weijia/mqtt-crypto-client>
9
+ - 包名:`mqtt-crypto-client`
10
+ - 运行时:Node.js **18+**(CI 发布使用 Node 24)
11
+ - 依赖:仅 [`mqtt`](https://www.npmjs.com/package/mqtt);加解密使用内置 `node:crypto`
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install mqtt-crypto-client
17
+ ```
18
+
19
+ ESM 与 CommonJS 均可用:
20
+
21
+ ```js
22
+ // ESM
23
+ import { createClient } from 'mqtt-crypto-client';
24
+
25
+ // CommonJS(构建产物 dist/index.cjs)
26
+ const { createClient } = require('mqtt-crypto-client');
27
+ ```
28
+
29
+ > ESM 源码直接作为 `import` 入口发布;CJS 由 `esbuild` 打包为单文件 `dist/index.cjs`。发布前 `prepack`/`prepublishOnly` 会自动构建。
30
+
31
+ ## 快速开始
32
+
33
+ ```js
34
+ import { createClient, PRESET_BROKERS, DEFAULT_TOPIC } from 'mqtt-crypto-client';
35
+
36
+ const client = createClient({
37
+ broker: process.env.MQTT_BROKER ?? PRESET_BROKERS[0], // wss://broker.emqx.io:8084/mqtt
38
+ topic: process.env.MQTT_TOPIC ?? DEFAULT_TOPIC, // secure/stock-condition-order
39
+ secret: process.env.MQTT_AES_SECRET, // 对应原实现的 server.password
40
+ encrypt: true,
41
+ user: 'fangzheng-agent',
42
+ });
43
+
44
+ client.on('error', (error) => console.error('MQTT error:', error.message));
45
+ client.on('connect', () => console.log('已连接,clientId =', client.clientId));
46
+ client.on('agentStatus', (online) => console.log('对端在线:', online));
47
+
48
+ client.on('message', async ({ envelope, command, decrypted }) => {
49
+ console.log('收到', command?.action, '解密:', decrypted, 'msgId:', envelope.msgId);
50
+
51
+ if (command?.action === 'list') {
52
+ // 回显请求 msgId(原实现的消息关联规则)
53
+ await client.reply(envelope, 'response', { status: 'success', data: { items: [] } });
54
+ }
55
+ });
56
+
57
+ await client.connect(); // 首次连接成功 / 超时 reject
58
+ await client.sendCommand('list'); // => Promise<msgId>
59
+ // await client.disconnect();
60
+ ```
61
+
62
+ 完整示例见 [`examples/basic.js`](./examples/basic.js) 与 [`examples/request-response.js`](./examples/request-response.js)。
63
+
64
+ ## API
65
+
66
+ ### 客户端
67
+
68
+ | API | 说明 |
69
+ | --- | --- |
70
+ | `createClient(options)` | 创建 `MqttCryptoClient`(`EventEmitter`) |
71
+ | `client.connect(timeout?)` | 连接并订阅;首次连接成功 resolve。超过 `timeout` reject `CONNECT_TIMEOUT`,但底层仍按 `reconnectPeriod` 重试 |
72
+ | `client.disconnect({ force })` | 断开、清理计时器并 reject 未完成的请求 |
73
+ | `client.subscribe(topic?, { qos })` | 订阅(默认配置 topic),`Promise<granted>` |
74
+ | `client.publish(envelope, { qos, track })` | 发布信封(自动补 `id/user/time/msgId`、按配置加密),`Promise<msgId>` |
75
+ | `client.sendCommand(action, data?, { qos, msgId, track })` | 发送 `{ action, data }` 命令,`Promise<msgId>` |
76
+ | `client.request(action, data?, { timeout, qos })` | 发送并按 `msgId` 等待响应,`Promise<{ command, envelope, topic }>`;超时 reject `REQUEST_TIMEOUT` |
77
+ | `client.reply(requestEnvelope, action, data?)` | 回复并回显请求的 `msgId`,`Promise<msgId>` |
78
+ | `client.isConnected()` / `client.isAgentOnline()` | 连接状态 / 是否收到过 `pong` |
79
+ | `client.mqttClient` | 底层 `mqtt.MqttClient`(高级用法) |
80
+
81
+ ### 事件
82
+
83
+ | 事件 | 参数 | 说明 |
84
+ | --- | --- | --- |
85
+ | `connect` | 无 | 连接成功(已发起订阅) |
86
+ | `reconnect` / `close` / `offline` | 无 | 底层连接状态 |
87
+ | `error` | `Error` | 连接/发布错误;无监听器时不会抛出,可读 `client.lastError` |
88
+ | `message` | `{ envelope, command, topic, decrypted }` | 普通消息;响应类消息不会进入此事件 |
89
+ | `response` | `{ envelope, command, topic }` | 响应消息(`action === 'response'` 或只有 `type`) |
90
+ | `agentStatus` | `boolean` | 收到 `pong` → `true`;`pongTimeout > 0` 且超时 → `false` |
91
+ | `status` | `{ connected, agentOnline }` | 状态变化 |
92
+ | `messageError` | `Error` | 信封或命令 JSON 解析失败 |
93
+
94
+ ### 工具函数
95
+
96
+ | 导出 | 说明 |
97
+ | --- | --- |
98
+ | `encryptPayload(plaintext, secret, { saltLength, salt }?)` | CryptoJS/OpenSSL 兼容的 AES 加密,返回 base64 |
99
+ | `decryptPayload(ciphertext, secret)` | 严格解密,失败抛 `CryptoError` |
100
+ | `tryDecrypt(ciphertext, secret)` | 宽松解密,失败返回 `null` |
101
+ | `isEncryptedPayload(text)` | 是否 `Salted__` base64 密文 |
102
+ | `buildEnvelope(input)` / `parseEnvelope(input)` | 信封构造/解析 |
103
+ | `buildTopic(...segments)` / `validateTopic(topic, { allowWildcards }?)` | topic 构造/校验 |
104
+ | `createClientId(prefix?)` / `generateMsgId({ now, suffix }?)` | ID 生成 |
105
+ | `buildMqttOptions(resolvedOptions)` | 底层 `mqtt.connect` 参数拼装 |
106
+ | `DEFAULT_TOPIC` / `PRESET_BROKERS` / `DEFAULT_OPTIONS` | 常量 |
107
+
108
+ 错误类:`MqttCryptoError` 及子类 `InvalidConfigError`、`NotConnectedError`、`RequestTimeoutError`、`ConnectTimeoutError`、`TopicError`、`CryptoError`、`EnvelopeError`,均带稳定 `error.code`。
109
+
110
+ ## 配置项
111
+
112
+ | 选项 | 默认值 | 说明 |
113
+ | --- | --- | --- |
114
+ | `broker` | `wss://broker.emqx.io:8084/mqtt` | 支持 `ws/wss/mqtt/mqtts/tcp/tls` |
115
+ | `topic` | `secure/stock-condition-order` | 单一业务 topic(原实现没有按用户再拼后缀) |
116
+ | `secret` | 无(必填) | AES 口令;对应原实现服务器配置里的 `password` |
117
+ | `encrypt` | `true` | 仅控制**出站**是否加密;入站始终先尝试解密,失败按明文处理 |
118
+ | `clientId` | 自动生成 | 基础 clientId;原实现为 `vue_` + 6 位十六进制 |
119
+ | `clientIdPrefix` | `vue_` | 自动生成 clientId 时使用的前缀 |
120
+ | `clientIdSuffix` | `''` | 拼到 clientId 末尾;原实现用 `_<serverId>` 区分服务器 |
121
+ | `user` / `userSuffix` | `fangzheng-agent` / `''` | 信封 `user = user + userSuffix` |
122
+ | `qos` | `1` | 订阅与发布默认 QoS |
123
+ | `reconnectPeriod` | `5000` | 自动重连间隔 |
124
+ | `connectTimeout` | `30000` | 底层连接超时,同时作为 `connect()` 的默认等待时间 |
125
+ | `clean` / `resubscribe` | `true` / `true` | 原实现 `clean: true` |
126
+ | `heartbeatInterval` | `30000` | 连接后立即 ping,此后每 30s 一次;`0` 关闭 |
127
+ | `pongTimeout` | `0` | `> 0` 时超过该毫秒未收到 pong 触发 `agentStatus=false`;原实现计时器为空回调,故默认关闭 |
128
+ | `dedupeTtl` | `5000` | 已发送 `msgId` 的去重保留时间 |
129
+ | `requestTimeout` | `5000` | `request()` 默认超时 |
130
+ | `auth` | `null` | 可选的 Broker 鉴权 `{ username, password }`;**与 AES `secret` 不同** |
131
+ | `protocolVersion` | 不设置 | 可显式指定 4 / 5,默认交给 mqtt |
132
+
133
+ ## 协议契约(来自原实现,非臆造)
134
+
135
+ ### 连接
136
+
137
+ - Broker 预设:`wss://broker.emqx.io:8084/mqtt`、`wss://test.mosquitto.org:8081/mqtt`、`wss://broker.hivemq.com:8884/mqtt`
138
+ - `clientId = 基础 ID + clientIdSuffix`;原 Vue 实现为 `vue_xxxxxx_<serverId>`
139
+ - `reconnectPeriod: 5000`、`connectTimeout: 30000`、`clean: true`
140
+ - 订阅与发布均 `QoS 1`
141
+ - 原实现没有 Broker 用户名/密码/Token;`auth` 是可选扩展
142
+
143
+ ### 信封与命令
144
+
145
+ ```json
146
+ {
147
+ "id": "vue_ab12cd",
148
+ "msgId": "1700000000000_1a2b",
149
+ "user": "fangzheng-agent",
150
+ "msg": "{\"action\":\"list\",\"data\":{}}",
151
+ "time": 1700000000000
152
+ }
153
+ ```
154
+
155
+ - `msg` 是**字符串**;业务命令为 `JSON.stringify({ action, data })`
156
+ - 心跳时 `msg` 直接为 `"ping"` / `"pong"`
157
+ - 响应:`{ "action": "response", "status": "success|error|partial", "data": ..., "message": ... }`
158
+ - 原实现会过滤 `action === 'response'` 或「无 `action` 但有 `type`」的响应消息;本库将其转入 `response` 事件并可被 `request()` 配对
159
+ - `id` 用于忽略自己消息:`data.id.startsWith(clientId)`
160
+
161
+ ### 心跳、去重、重试
162
+
163
+ - 连接成功后立即发送 `ping`,之后每 30s 一次
164
+ - 收到 `ping` 回复 `pong`;收到 `pong` 视为对端在线
165
+ - 出站 `msgId` 在 5s 内保留;再次收到同 `msgId` 的消息会被忽略(原实现的去重逻辑)
166
+ - 底层 `mqtt` 自动重连,间隔 5s;`connect()` 超时不会停止重试
167
+
168
+ ### 加密格式(与 CryptoJS 逐字节兼容)
169
+
170
+ ```
171
+ 密文 = base64("Salted__" + 8 字节随机 salt + AES-256-CBC 密文)
172
+ key/iv = EVP_BytesToKey(MD5, 1 次迭代, secret, salt),key 32 字节 + IV 16 字节
173
+ 填充 = PKCS#7
174
+ ```
175
+
176
+ 已验证双向互通(使用原项目 `crypto-js` 4.2.0):
177
+
178
+ ```
179
+ node -> CryptoJS: OK
180
+ CryptoJS -> node: OK
181
+ ```
182
+
183
+ ## 与原脚本的差异
184
+
185
+ | 原实现 | 本库 |
186
+ | --- | --- |
187
+ | 一个实例管理多个 Broker/server | 一个客户端对应一个 Broker;多服务器请创建多个实例(`clientIdSuffix` 区分) |
188
+ | 配置存 `localStorage`、WebDAV 环境跳过连接 | 不包含浏览器持久化/环境探测;配置由调用方提供 |
189
+ | `pong` 超时计时器为空回调 | 默认 `pongTimeout: 0`(不判定离线),可显式开启 |
190
+ | 响应消息直接丢弃 | 新增 `request()` 按 `msgId` 配对;`reply()` 回显请求 `msgId` |
191
+
192
+ ## 安全说明与限制
193
+
194
+ - 该格式是**口令派生 + AES-CBC**,没有 MAC/AEAD,**不提供完整性或身份认证**;能防旁观者直接读取,不能防篡改、重放或伪造。
195
+ - KDF 为 MD5 单轮 `EVP_BytesToKey`,按现代标准偏弱;这里为兼容原协议而保留。
196
+ - 默认 Broker 是公共 Broker,任何知道 topic 与口令的人都能收发;请使用**独立且足够随机**的 `secret`,不要把生产密钥写进仓库。
197
+ - `encrypt: false` 时仍会尝试解密密文;明文回退是原实现的兼容行为,会降低安全性。
198
+ - 不要在本库之上传输高敏感数据而不额外加 MAC/签名或改用 TLS + 认证 Broker。
199
+
200
+ ## 开发与验证
201
+
202
+ ```bash
203
+ npm install
204
+ npm test # node:test,36 个用例
205
+ npm run typecheck # tsc checkJs
206
+ npm run build # ESM 源码 -> dist/index.cjs
207
+ npm pack --dry-run
208
+ ```
209
+
210
+ ## 发布
211
+
212
+ 本项目使用 npm **Trusted Publishing (OIDC)**,不使用 `NPM_TOKEN`。发布流程与需要在 npmjs 上完成的设置见 [NPM-SETUP.md](./NPM-SETUP.md)。
213
+
214
+ ## License
215
+
216
+ MIT,见 [LICENSE](./LICENSE)。当前版权主体为占位值(`weijia`),可按需替换。