From d2144dc698d19ded9d1bc967064fd464ab237b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E6=B5=A9?= Date: Mon, 13 Jul 2026 13:50:46 +0800 Subject: [PATCH] fix(vivo): normalize multipart newlines before signing Node FormData serializes text line breaks as CRLF. Sign the same normalized values that are sent so multiline metadata passes vivo verification. Prepare the 0.2.2 patch release and add regression coverage. Co-Authored-By: Codex GPT-5 --- CHANGELOG.md | 5 +++ package-lock.json | 4 +-- package.json | 2 +- src/adapters/android/vivo/api.ts | 19 +++++++++-- test/vivo.test.mjs | 55 ++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 test/vivo.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc3fbd..505e6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## 0.2.2 - 2026-07-13 + +- Fix vivo signature verification for multiline release notes and descriptions by + matching the CRLF normalization performed during multipart serialization. + ## 0.2.1 - 2026-07-13 - Publish `shipup` to npm and document global installation. diff --git a/package-lock.json b/package-lock.json index 48da71b..c99ea27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shipup", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shipup", - "version": "0.2.1", + "version": "0.2.2", "license": "0BSD", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 236364b..fd10b07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shipup", - "version": "0.2.1", + "version": "0.2.2", "description": "Cross-platform release CLI for App Store Connect, AppGallery Connect, and Android app markets", "type": "module", "bin": { diff --git a/src/adapters/android/vivo/api.ts b/src/adapters/android/vivo/api.ts index 77f3173..fb3e60f 100644 --- a/src/adapters/android/vivo/api.ts +++ b/src/adapters/android/vivo/api.ts @@ -40,12 +40,26 @@ function compareAsciiUpperCase(a: string, b: string): number { */ export function signVivo(params: Record, accessSecret: string): string { const str = Object.keys(params) + .filter((k) => k !== 'sign') .sort(compareAsciiUpperCase) .map((k) => `${k}=${params[k]}`) .join('&'); return hmacSha256Hex(accessSecret, str); } +/** + * WHATWG FormData 会在 multipart 序列化时把文本字段中的 CR/LF 统一为 CRLF。 + * vivo 服务端按实际收到的字段值重算签名,因此必须在签名前做相同规范化; + * 否则多行 updateDesc/detailDesc 会因签名原文与线上字节不一致而返回 10001。 + */ +function normalizeMultipartParams(params: Record): Record { + return Object.fromEntries( + Object.entries(params) + .filter(([k]) => k !== 'sign') + .map(([k, v]) => [k, v.replace(/\r\n|\r|\n/g, '\r\n')]), + ); +} + /** 网关公共参数(timestamp 为毫秒,与 QQ 的秒不同)。 */ function commonParams(accessKey: string, method: string): Record { return { @@ -66,9 +80,10 @@ async function postMultipart( timeoutMs: number, file?: { field: string; bytes: Uint8Array; filename: string }, ): Promise { - params.sign = signVivo(params, accessSecret); + const signedParams = normalizeMultipartParams(params); + signedParams.sign = signVivo(signedParams, accessSecret); const form = new FormData(); - for (const [k, v] of Object.entries(params)) form.append(k, v); + for (const [k, v] of Object.entries(signedParams)) form.append(k, v); if (file) { form.append(file.field, new Blob([file.bytes]), file.filename); } diff --git a/test/vivo.test.mjs b/test/vivo.test.mjs new file mode 100644 index 0000000..e9b83d0 --- /dev/null +++ b/test/vivo.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { signVivo, syncUpdateApp } from "../dist/adapters/android/vivo/api.js"; + +test("vivo multipart 签名使用实际发送的 CRLF 多行文本", async () => { + const originalFetch = globalThis.fetch; + const originalNow = Date.now; + let requested = false; + + Date.now = () => 1_700_000_000_000; + globalThis.fetch = async (_url, init) => { + requested = true; + assert.ok(init.body instanceof FormData); + + const params = {}; + for (const [key, value] of init.body.entries()) { + assert.equal(typeof value, "string"); + params[key] = String(value); + } + + const signature = params.sign; + delete params.sign; + assert.equal(params.updateDesc, "第一行\r\n第二行\r\n第三行\r\n第四行"); + assert.equal(signature, signVivo(params, "secret")); + + return new Response(JSON.stringify({ code: 0, subCode: "0", msg: "成功" }), { + status: 200, + }); + }; + + try { + await syncUpdateApp({ + accessKey: "access-key", + accessSecret: "secret", + packageName: "com.example.app", + versionCode: "123", + apkMd5: "0123456789abcdef0123456789abcdef", + apkSerialNumber: "apk-serial", + updateDesc: "第一行\n第二行\r第三行\r\n第四行", + timeoutMs: 10_000, + }); + } finally { + globalThis.fetch = originalFetch; + Date.now = originalNow; + } + + assert.equal(requested, true); +}); + +test("vivo 重签时不把旧 sign 参与签名", () => { + assert.equal( + signVivo({ a: "1", sign: "old-signature" }, "secret"), + signVivo({ a: "1" }, "secret"), + ); +});