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"), + ); +});