dsh-mobile-plugin 0.5.12 → 0.5.14

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.
@@ -65,6 +65,17 @@ function signedPath(req) {
65
65
  return hit >= 0 ? pathname.slice(hit) : pathname;
66
66
  }
67
67
 
68
+ /**
69
+ * v3 二进制分片的 AAD:`v2|up|METHOD|PATH|<uploadId>|<offset>`。
70
+ *
71
+ * 在 v2 的四段后面绑上这两格,是为了让「把一片搬到别的上传 / 搬到同一上传的别的偏移」
72
+ * 在 AEAD 这一层就不成立 —— 签名只覆盖路径与字节,绑不到上传身份上。
73
+ *
74
+ * 字符串口径写在 `docs/native-upload.md` 的 v3 章,与 App 侧下一阶段的实现对齐;这里只
75
+ * 负责把它拼出来,不额外规范化 offset(`"042"` 与 `"42"` 拼出来不同,客户端必须原样发)。
76
+ */
77
+ const binChunkAad = (method, path, uploadId, offset) => `v2|up|${method}|${path}|${uploadId}|${offset}`;
78
+
68
79
  /** 查询串按线上原样(含 `?`),不排序:它必须与 App 真正发出去的那串一致。 */
69
80
  function signedQuery(req) {
70
81
  const raw = String(req?.url || '');
@@ -81,6 +92,8 @@ function signedQuery(req) {
81
92
  * @param {Buffer} [deps.rawBody] 已读到的请求体原文(没读过就给 undefined,这里会自己读)
82
93
  * @returns {{ ok: true, body: object|undefined, keys: object, device: object }
83
94
  * | { ok: false, status: number, code: string, extra?: object }}
95
+ *
96
+ * 带 `X-Dsh-Wire: bin` 的请求没有 JSON body,解出来的明文放在 `req.rawBody`(Buffer)上。
84
97
  */
85
98
  function openRequest(req, deps, rawBody) {
86
99
  const keyId = headerOf(req, 'x-dsh-key-id').trim();
@@ -133,6 +146,32 @@ function openRequest(req, deps, rawBody) {
133
146
  nonces.set(keyId, table);
134
147
  }
135
148
 
149
+ // ── 二进制 wire(v3 的上行分片体,见 docs/native-upload.md 的 v3 章)──
150
+ //
151
+ // 只对**明确带头**的请求生效:老 App 不会发 `X-Dsh-Wire`,所以它走的仍是下面那条
152
+ // JSON 信封分支,一个字节都不变。反过来,带了 `bin` 就说明客户端知道自己在发裸密文,
153
+ // 这时候把它当 JSON 解只会得到一个「请求体不是合法 JSON」的 400。
154
+ if (headerOf(req, 'x-dsh-wire').trim().toLowerCase() === 'bin') {
155
+ const uploadId = headerOf(req, 'x-dsh-upload-id').trim();
156
+ const offsetText = headerOf(req, 'x-dsh-upload-offset').trim();
157
+ // 这两格既进 AAD 也进业务判据,缺一格就没法把「这一片」绑到「这个上传的这个偏移」上。
158
+ if (!uploadId || !/^\d+$/.test(offsetText)) {
159
+ return { ok: false, status: 400, code: 'e2ee-bad-envelope', extra: { reason: 'missing wire headers' } };
160
+ }
161
+ let plain;
162
+ try {
163
+ // AAD 比 v2 多两段:先把一片搬到别的上传、或搬到同一上传的别的偏移,认证就不过。
164
+ // 签名口径不变(签的仍是线上这串密文字节),所以上面那步验签一行没动。
165
+ plain = e2ee.open(keys.up, rawBody || Buffer.alloc(0), binChunkAad(method, signedPath(req), uploadId, offsetText));
166
+ } catch {
167
+ return { ok: false, status: 400, code: 'e2ee-decrypt-failed' };
168
+ }
169
+ // 业务 handler 拿不到 JSON(这一片本来就不是 JSON):明文交给 `req.rawBody`,
170
+ // 上传 handler 用 `readRawBody` 取。响应仍由上面的守卫封成普通的 v2 JSON 信封。
171
+ req.rawBody = plain;
172
+ return { ok: true, body: undefined, keys, device: record, req: { ts, nonce } };
173
+ }
174
+
136
175
  // ── 解封请求体 ──
137
176
  let body;
138
177
  if (rawBody && rawBody.length > 0) {
@@ -137,4 +137,59 @@ function readJsonBody(req, maxBytes = JSON_MAX_BODY) {
137
137
  });
138
138
  }
139
139
 
140
- module.exports = { sendJson, sendHtml, readJsonBody, encodeJsonBody, wantsGzip, JSON_GZIP_MIN_BYTES, JSON_MAX_BODY };
140
+ /**
141
+ * 读原始请求体,回 Buffer;超限 / 连接出错 → null。
142
+ *
143
+ * 与 `readJsonBody` 的分工:那条路交出去的是**解析好的 JSON**,这条路交出去的是**字节**。
144
+ * v3 的二进制分片(`X-Dsh-Wire: bin`)加密后就是裸字节,中间没有 JSON 这一层;handler 拿
145
+ * 到的明文片(守卫解封之后)同样是字节,所以两边都用这一条。
146
+ *
147
+ * `req.rawBody` 优先:信封守卫(`shared/e2ee-request.js`)已经把密文读走、解封完放在那里,
148
+ * 请求流早就见底了,再 `on('data')` 只会等来一个空 body —— 那会表现为「每片都解出 0 字节」,
149
+ * 而错误信息只会是「分片是空的」。
150
+ *
151
+ * @param {object} req node:http IncomingMessage
152
+ * @param {number} [maxBytes] 请求体上限(字节),缺省按普通 JSON 那一档
153
+ * @returns {Promise<Buffer|null>}
154
+ */
155
+ function readRawBody(req, maxBytes = JSON_MAX_BODY) {
156
+ const handed = req && req.rawBody;
157
+ if (Buffer.isBuffer(handed)) return Promise.resolve(handed.length > maxBytes ? null : handed);
158
+ return new Promise((resolve) => {
159
+ const chunks = [];
160
+ let total = 0;
161
+ let settled = false;
162
+ req.on('data', (chunk) => {
163
+ if (settled) return;
164
+ total += chunk.length;
165
+ if (total > maxBytes) {
166
+ settled = true;
167
+ req.destroy();
168
+ resolve(null);
169
+ return;
170
+ }
171
+ chunks.push(Buffer.from(chunk));
172
+ });
173
+ req.on('end', () => {
174
+ if (settled) return;
175
+ settled = true;
176
+ resolve(Buffer.concat(chunks));
177
+ });
178
+ req.on('error', () => {
179
+ if (settled) return;
180
+ settled = true;
181
+ resolve(null);
182
+ });
183
+ });
184
+ }
185
+
186
+ module.exports = {
187
+ sendJson,
188
+ sendHtml,
189
+ readJsonBody,
190
+ readRawBody,
191
+ encodeJsonBody,
192
+ wantsGzip,
193
+ JSON_GZIP_MIN_BYTES,
194
+ JSON_MAX_BODY,
195
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",