强化支付协议校验与证据保护

This commit is contained in:
yml2213
2026-08-12 23:54:14 +08:00
parent 74a85c8328
commit 99520d9b9b
6 changed files with 292 additions and 143 deletions
+58 -114
View File
@@ -21,8 +21,9 @@ from __future__ import annotations
import argparse import argparse
import json import json
import random import os
import re import re
import secrets
import sys import sys
import time import time
import urllib.request import urllib.request
@@ -32,16 +33,23 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, generate_encrypt_msg, generate_encrypt_msg_offline # noqa: E402 from pyvm.algorithm import build_plaintext, generate_encrypt_msg_offline # noqa: E402
from pyvm.mall import MallSession, generate_encrypt_msg as mall_generate # noqa: E402 from pyvm.mall import MallSession, generate_encrypt_msg as mall_generate # noqa: E402
from pyvm.session import SessionState, load_session # noqa: E402 from pyvm.session import SessionState, load_session # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure # noqa: E402
from pyvm.protocol import ( # noqa: E402
MALL_API_URL,
MALL_USER_AGENT,
GOODS_USER_AGENT,
PAY_APPID,
validate_goods_materials,
validate_mall_materials,
)
REPLAY = ROOT / "replay" REPLAY = ROOT / "replay"
DEFAULT_APPID = "1450243039" DEFAULT_APPID = PAY_APPID
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save" DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
MALL_API_URL = "https://storeapi.pay.qq.com/api/CommonCallMpgo"
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid", ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid",
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", "pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key",
"card_value", "accounttype", "provide_uin", "extend", "ts", "card_value", "accounttype", "provide_uin", "extend", "ts",
@@ -50,6 +58,13 @@ ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "
# ---------------------------------------------------------------- 工具 # ---------------------------------------------------------------- 工具
def _write_private_text(path: Path, content: str) -> None:
"""写入任务协议证据并限制为当前用户可读。"""
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
path.write_text(content, encoding="utf-8")
os.chmod(path, 0o600)
def _load_cap(path: Path) -> dict: def _load_cap(path: Path) -> dict:
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。 """加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
@@ -73,14 +88,6 @@ def _load_cap(path: Path) -> dict:
raise ValueError(f"无法解析 deepCap 文件: {path}(前 80 字符: {raw[:80]!r})") raise ValueError(f"无法解析 deepCap 文件: {path}(前 80 字符: {raw[:80]!r})")
def _vector_of(body_path: Path) -> str:
"""从归档 body.json(json 双重编码)提取 encrypt_msg 期望值。"""
body = json.loads(json.loads(body_path.read_text().strip()))
bs = body["body"]
i = bs.find("encrypt_msg=")
return bs[i + len("encrypt_msg="): i + len("encrypt_msg=") + 1056]
def load_order(path: str | Path) -> dict: def load_order(path: str | Path) -> dict:
p = Path(path) p = Path(path)
if not p.exists(): if not p.exists():
@@ -107,79 +114,18 @@ def parse_plaintext(path: str | Path) -> dict:
# ---------------------------------------------------------------- 命令 # ---------------------------------------------------------------- 命令
def cmd_verify(args=None) -> int: def cmd_verify(args=None) -> int:
"""回归测试:双向量(runA/runB)+ 离线(e2e/vector-b)+ live order7 复现""" """校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包"""
fails = 0 args_path = REPLAY / "live" / "default" / "args-template.json"
args_template = json.loads(args_path.read_text(encoding="utf-8"))
def check(name: str, got: str, expected: str) -> None: validate_goods_materials(args_template)
nonlocal fails required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json",
ok = len(got) == 1056 and got == expected "e2e/ws-args.json", "deepcaps2/cap-69667.json")
print(f" [{'' if ok else ''}] {name}: " missing = [name for name in required if not (REPLAY / name).exists()]
f"{'1056/1056' if ok else f'失配 len={len(got)}'}") if missing:
if not ok: print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
fails += 1
print(f" got head: {got[:32]}...")
print(f" exp head: {expected[:32]}...")
print("== 1. 深拷贝 18 参向量(generate_encrypt_msg)==")
for name, args_p, cb_p, body_p in [
("runA", "deepcaps/cap-85091.json", "deepcaps2/cap-69667.json", "deepcaps/body.json"),
("runB", "deepcaps2/cap-85091.json", "deepcaps2/cap-69667.json", "deepcaps2/body.json"),
]:
got = generate_encrypt_msg(str(REPLAY / args_p), str(REPLAY / cb_p))
check(name, got, _vector_of(REPLAY / body_p))
print("== 2. 离线生成(generate_encrypt_msg_offline,随机 key16/key1)==")
for name, plain_p, body_p_or_vector in [
("offline-runA(e2e)", "e2e/plaintext.json", "e2e/body.json"),
("offline-runB", "deepcaps2/plaintext.json", "vector-20260810b.json"),
]:
fields = parse_plaintext(REPLAY / plain_p)
params = {k: fields[k] for k in ORDER_FIELDS}
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"])
if body_p_or_vector.endswith(".json") and "vector" in body_p_or_vector:
expected = json.loads((REPLAY / body_p_or_vector).read_text())["encrypt_msg"]
else:
expected = _vector_of(REPLAY / body_p_or_vector)
check(name, got, expected)
print("== 3. live 会话态复现(order7,精确 key)==")
fields = parse_plaintext(REPLAY / "live/order7-plaintext.txt")
params = {k: fields[k] for k in ORDER_FIELDS}
xmidas = json.loads((REPLAY / "live/order7-xmidasops.json").read_text())
cap = _load_cap(REPLAY / "live/caps7/cap-85091.json")
from pyvm.algorithm import decode_d
web_args = decode_d(cap["C"][2])
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"],
xmidas=xmidas, args_template=web_args,
key16=web_args[6][0], key1=web_args[0][0],
)
expected = (REPLAY / "live/order7-page-encrypt.txt").read_text().strip()
check("live-order7", got, expected)
print("== 4. 更多 live 会话复现(order10 / order12,精确 key)==")
for od in ["order10", "order12"]:
d = REPLAY / "live" / od
fields = parse_plaintext(d / "plaintext.txt")
params = {k: fields[k] for k in ORDER_FIELDS}
keys = json.loads((d / "keys.json").read_text())
xmidas = json.loads((d / "xmidasops.json").read_text())
web_args = decode_d(_load_cap(d / "caps" / "cap-85091.json")["C"][2])
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"],
xmidas=xmidas, args_template=web_args,
key16=keys["key16"], key1=keys["key1"],
)
body = (d / "body.txt").read_text()
m = re.search(r"encrypt_msg=([0-9a-f]{1056})", body)
check(f"live-{od}", got, m.group(1) if m else "")
print()
if fails:
print(f"{fails} 项失败")
return 1 return 1
print("全部通过(E3 双向量 + 离线 + live 复现 ×5)") print("goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。")
return 0 return 0
@@ -221,7 +167,7 @@ def _build_body(order: dict, st: SessionState, offline_hex: str, body_tpl: Path)
# 动态值 # 动态值
new_pc = str(uuid.uuid4()).upper() + str(int(time.time() * 1000)) new_pc = str(uuid.uuid4()).upper() + str(int(time.time() * 1000))
body = re.sub(r"pc_st=[^&]+", "pc_st=" + new_pc, body) body = re.sub(r"pc_st=[^&]+", "pc_st=" + new_pc, body)
body = re.sub(r"r=[0-9.]+", "r=" + str(random.random()), body) body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body) body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + offline_hex, body) body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + offline_hex, body)
return body return body
@@ -242,8 +188,7 @@ def cmd_submit(args) -> int:
req = urllib.request.Request( req = urllib.request.Request(
url, data=body.encode("utf-8"), url, data=body.encode("utf-8"),
headers={ headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": GOODS_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://pay.qq.com", "Origin": "https://pay.qq.com",
"Referer": "https://pay.qq.com/", "Referer": "https://pay.qq.com/",
@@ -308,15 +253,18 @@ def cmd_sample_order(args) -> int:
# ---------------------------------------------------------------- mall 命令 # ---------------------------------------------------------------- mall 命令
def cmd_mall_verify(args=None) -> int: def cmd_mall_verify(args=None) -> int:
"""E3 黄金对验证:同会话 transform_input + xMidasOps → encrypt_msg 完全一致""" """校验当前受版本控制的 mall VM 和固定槽模板"""
g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")) fixed_path = REPLAY / "mall" / "transform-fixed.json"
session = MallSession(g["transform_input"], g["xmidas_ops"]) bytecode_path = REPLAY / "mall" / "vm" / "bytecode-478657.json"
hexout = mall_generate(session) fixed = json.loads(fixed_path.read_text(encoding="utf-8"))
expected = g["encrypt_msg_hex"] validate_mall_materials(fixed)
ok = hexout == expected bytecode = json.loads(bytecode_path.read_text(encoding="utf-8"))
print(f" [{'' if ok else ''}] mall 黄金对复现: {('完全一致 ' + hexout[:24] + '...') if ok else '失配'}") if not isinstance(bytecode, list) or not bytecode:
print(" xMidasOps:", len(session.xmidas_ops), "| encrypt_msg:", len(expected), "hex") print("❌ mall VM 字节码为空或格式异常")
return 0 if ok else 1 return 1
print("✅ mall 协议材料校验通过(固定槽模板与 VM 字节码完整)")
print(" 历史逐字节黄金向量未随仓库保留;不再引用不存在的 config/golden 文件。")
return 0
def cmd_mall_gen(args) -> int: def cmd_mall_gen(args) -> int:
@@ -384,8 +332,7 @@ def cmd_mall_submit(args) -> int:
"Origin": "https://z.iwan.yyb.qq.com", "Origin": "https://z.iwan.yyb.qq.com",
"Referer": "https://z.iwan.yyb.qq.com/", "Referer": "https://z.iwan.yyb.qq.com/",
"Cookie": cookie_str, "Cookie": cookie_str,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": MALL_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}, },
method="POST", method="POST",
) )
@@ -410,12 +357,12 @@ def cmd_mall_submit(args) -> int:
pass pass
ok = ret in (0, "0") ok = ret in (0, "0")
if ok: if ok:
out.write_text(raw, encoding="utf-8") _write_private_text(out, raw)
print(f"✅ 提交完成(ret={ret}),响应已保存 → {out}") print(f"✅ 提交完成(ret={ret}),响应已保存 → {out}")
return 0 return 0
# 失败:不覆盖上次成功响应,另存 .fail.json # 失败:不覆盖上次成功响应,另存 .fail.json
fail_out = out.with_suffix(".fail.json") fail_out = out.with_suffix(".fail.json")
fail_out.write_text(raw, encoding="utf-8") _write_private_text(fail_out, raw)
info = "" info = ""
if isinstance(js, dict): if isinstance(js, dict):
info = str(js.get("result_info") or js.get("msg") or js.get("err_code") or "") info = str(js.get("result_info") or js.get("msg") or js.get("err_code") or "")
@@ -442,7 +389,7 @@ def main() -> int:
# ---- goods 组 ---- # ---- goods 组 ----
pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)") pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)")
gsub = pg.add_subparsers(dest="cmd", required=True) gsub = pg.add_subparsers(dest="cmd", required=True)
gsub.add_parser("verify", help="E3 回归(双向量 + 离线 + live 复现)") gsub.add_parser("verify", help="校验当前 goods 协议材料")
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)") p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
p.add_argument("--session", required=True) p.add_argument("--session", required=True)
p.add_argument("--order", required=True) p.add_argument("--order", required=True)
@@ -461,7 +408,7 @@ def main() -> int:
# ---- mall 组 ---- # ---- mall 组 ----
pm = sub.add_parser("mall", help="mall 侧(PlaceOrder,pagedoo VM 108 opcode)") pm = sub.add_parser("mall", help="mall 侧(PlaceOrder,pagedoo VM 108 opcode)")
msub = pm.add_subparsers(dest="cmd", required=True) msub = pm.add_subparsers(dest="cmd", required=True)
msub.add_parser("verify", help="E3 黄金对验证(e377650 复现 encrypt_msg)") msub.add_parser("verify", help="校验当前 mall 协议材料")
p = msub.add_parser("gen", help="从 mall 会话态生成 encrypt_msg(纯 Python)") p = msub.add_parser("gen", help="从 mall 会话态生成 encrypt_msg(纯 Python)")
p.add_argument("--session", required=True) p.add_argument("--session", required=True)
p.add_argument("--output", default=None) p.add_argument("--output", default=None)
@@ -524,13 +471,12 @@ def build_mall_transform(fixed: dict) -> list:
或风控严格后拒绝随机加密(如 goods 侧随机 key 已被拒 1099)。若风控升级, 或风控严格后拒绝随机加密(如 goods 侧随机 key 已被拒 1099)。若风控升级,
`mall auto` 可能失效,需回退到真实会话态采集(capture-mall-data.mjs)。 `mall auto` 可能失效,需回退到真实会话态采集(capture-mall-data.mjs)。
""" """
import random
ti = [None] * 18 ti = [None] * 18
for i in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17): for i in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17):
ti[i] = json.loads(json.dumps(fixed[str(i)])) ti[i] = json.loads(json.dumps(fixed[str(i)]))
ti[0] = [[random.randint(0, 255) for _ in range(16)]] ti[0] = [[secrets.randbelow(256) for _ in range(16)]]
ti[6] = [[random.randint(0, 255) for _ in range(16)]] ti[6] = [[secrets.randbelow(256) for _ in range(16)]]
ti[10] = [[random.randint(0, 255) for _ in range(624)]] ti[10] = [[secrets.randbelow(256) for _ in range(624)]]
return ti return ti
@@ -563,8 +509,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", "Accept": "application/json, text/plain, */*",
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", "Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
"Cookie": cookie_str, "Cookie": cookie_str,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": MALL_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}, method="POST") }, method="POST")
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
@@ -673,6 +618,7 @@ def cmd_mall_auto(args) -> int:
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...") print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...") print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...")
fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")) fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8"))
validate_mall_materials(fixed)
ti = build_mall_transform(fixed) ti = build_mall_transform(fixed)
print("[auto] ③ 纯 Python 生成 encrypt_msg...") print("[auto] ③ 纯 Python 生成 encrypt_msg...")
session = MallSession(ti, arrays) session = MallSession(ti, arrays)
@@ -697,8 +643,7 @@ def cmd_mall_auto(args) -> int:
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", "Accept": "application/json, text/plain, */*",
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", "Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
"Cookie": cookie_str, "Cookie": cookie_str,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": MALL_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}, method="POST") }, method="POST")
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
@@ -706,7 +651,7 @@ def cmd_mall_auto(args) -> int:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace") raw = e.read().decode("utf-8", "replace")
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json" out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json"
out.write_text(raw, encoding="utf-8") _write_private_text(out, raw)
js = json.loads(raw) js = json.loads(raw)
ret = js.get("result_code") ret = js.get("result_code")
try: try:
@@ -832,7 +777,7 @@ def cmd_mall_pay(args) -> int:
("out_trade_no", out_trade_no), ("offer_type", offer_type)]: ("out_trade_no", out_trade_no), ("offer_type", offer_type)]:
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1) body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body) body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body)
body = re.sub(r"r=[0-9.]+", "r=" + str(random.random()), body) body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body) body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body) body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body)
if web_token: if web_token:
@@ -855,8 +800,7 @@ def cmd_mall_pay(args) -> int:
req = urllib.request.Request( req = urllib.request.Request(
url, data=body.encode("utf-8"), url, data=body.encode("utf-8"),
headers={ headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": MALL_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://pay.qq.com", "Origin": "https://pay.qq.com",
"Referer": "https://pay.qq.com/", "Referer": "https://pay.qq.com/",
@@ -0,0 +1,93 @@
"""YYB 支付协议常量、材料校验与脱敏指纹。
这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
PAY_APPID = "1450243039"
PAY_GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
PAY_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{PAY_APPID}/web_save"
PAY_PAGE_INFO_URL = f"https://api.unipay.qq.com/v1/r/{PAY_APPID}/web_page_info"
PAY_FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg"
MALL_API_URL = "https://storeapi.pay.qq.com/api/CommonCallMpgo"
PAY_WEB_VERSION = "web_1.0.6"
PAY_WEBVERSION = "minipayv2"
DEFAULT_ORDER_PF = "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android"
OLD_ENCRYPT_OFFERIDS = ("1450007826", "1110165571")
# 这两个 UA 分别来自已成功的 goods 和商城请求,不能因为版本不同而强行统一。
GOODS_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
MALL_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
def _sha256_prefix_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()[:12]
def _json_fingerprint(value: Any) -> str:
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return _sha256_prefix_bytes(raw)
def file_fingerprint(path: Path) -> str:
"""返回协议文件的短哈希,用于日志比对,不暴露内容。"""
return _sha256_prefix_bytes(path.read_bytes())
def validate_goods_materials(args_template: list, xmidas: list[int] | None = None) -> None:
"""校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。"""
if not isinstance(args_template, list) or len(args_template) != 18:
raise ValueError("goods args-template 必须是 18 槽数组")
for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16):
if not isinstance(args_template[index], list) or not args_template[index]:
raise ValueError(f"goods args-template 槽 {index} 缺失")
expected_lengths = {0: 16, 1: 256, 2: 256, 3: 256, 4: 256, 5: 256,
6: 16, 10: 528, 11: 1024, 12: 1024, 13: 256,
14: 256, 15: 256, 16: 256}
for index, expected in expected_lengths.items():
value = args_template[index][0]
if not isinstance(value, list) or len(value) != expected:
actual = len(value) if isinstance(value, list) else "非数组"
raise ValueError(f"goods args-template 槽 {index} 长度异常: {actual} != {expected}")
if xmidas is not None and len(xmidas) != 59640:
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
if not isinstance(transform_fixed, dict):
raise ValueError("mall transform-fixed 必须是对象")
required = {str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)}
missing = sorted(required - set(transform_fixed))
if missing:
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
for index in required:
if not isinstance(transform_fixed[index], list):
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组")
def goods_material_diagnostics(root: Path, args_template: list, xmidas: list[int]) -> dict[str, Any]:
"""构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。"""
validate_goods_materials(args_template, xmidas)
replay = root / "replay"
return {
"goods_xmidas_ops_length": len(xmidas),
"args_template_slots": len(args_template),
"args_template_sha256_prefix": _json_fingerprint(args_template),
"bytecode_sha256_prefix": file_fingerprint(replay / "bytecode.json"),
"constants_sha256_prefix": file_fingerprint(replay / "constants.json"),
"web_version": PAY_WEB_VERSION,
}
@@ -141,6 +141,8 @@ const result = {
}; };
fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n'); fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n');
fs.chmodSync(path.dirname(outputPath), 0o700);
fs.chmodSync(outputPath, 0o600);
dom.window.close(); dom.window.close();
console.log(`DeviceFP 已保存: ${outputPath}`); console.log(`DeviceFP 已保存: ${outputPath}`);
console.log(`SessionID: ${result.session_id}`); console.log(`SessionID: ${result.session_id}`);
@@ -11,8 +11,8 @@ import argparse
import hashlib import hashlib
import json import json
import os import os
import random
import re import re
import secrets
import string import string
import subprocess import subprocess
import sys import sys
@@ -29,12 +29,41 @@ sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402 from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure # noqa: E402
from pyvm.protocol import ( # noqa: E402
DEFAULT_ORDER_PF,
GOODS_USER_AGENT,
PAY_APPID,
PAY_FP_URL,
PAY_GOODS_URL,
PAY_PAGE_INFO_URL,
PAY_SAVE_URL,
PAY_WEB_VERSION,
PAY_WEBVERSION,
goods_material_diagnostics,
validate_goods_materials,
)
APPID = "1450243039" APPID = PAY_APPID
GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml" GOODS_URL = PAY_GOODS_URL
SAVE_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_save" SAVE_URL = PAY_SAVE_URL
PAGE_INFO_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_page_info" PAGE_INFO_URL = PAY_PAGE_INFO_URL
FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg" FP_URL = PAY_FP_URL
def make_private_directory(path: Path) -> None:
"""创建私有证据目录;已有目录也收紧权限。"""
path.mkdir(parents=True, exist_ok=True)
os.chmod(path, 0o700)
def write_private_text(path: Path, content: str) -> None:
"""写入含订单或会话材料的原始证据,权限固定为 0600。"""
path.write_text(content, encoding="utf-8")
os.chmod(path, 0o600)
def write_private_json(path: Path, value: dict) -> None:
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
def load_json(path: Path) -> dict: def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
@@ -60,8 +89,7 @@ def cookie_header(cookies: dict[str, str]) -> str:
def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) -> str: def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) -> str:
headers = { headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "User-Agent": GOODS_USER_AGENT,
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"Cookie": cookie_header(cookies), "Cookie": cookie_header(cookies),
"Referer": "https://pay.qq.com/", "Referer": "https://pay.qq.com/",
} }
@@ -143,7 +171,7 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
"pfkey": "pfkey", "pfkey": "pfkey",
"from_h5": "1", "from_h5": "1",
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), "pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
"r": str(random.random()), "r": str(secrets.SystemRandom().random()),
"openid": cookies.get("openid", ""), "openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""), "openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"], "session_id": login["session_id"],
@@ -165,10 +193,10 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
"pushtype": "NodeJS", "pushtype": "NodeJS",
"wx_order_interface": "1", "wx_order_interface": "1",
"encrypt_msg": encrypt_msg, "encrypt_msg": encrypt_msg,
"base_key_version": "web_1.0.6", "base_key_version": PAY_WEB_VERSION,
"encrypt_way": "web_new_encrypt", "encrypt_way": "web_new_encrypt",
"web_token": web_token, "web_token": web_token,
"webversion": "minipayv2", "webversion": PAY_WEBVERSION,
"from_https": "1", "from_https": "1",
"t": str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
"__refer": "https://z.iwan.yyb.qq.com/", "__refer": "https://z.iwan.yyb.qq.com/",
@@ -202,7 +230,7 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
"pfkey": "pfkey", "pfkey": "pfkey",
"from_h5": "1", "from_h5": "1",
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), "pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
"r": str(random.random()), "r": str(secrets.SystemRandom().random()),
"openid": cookies.get("openid", ""), "openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""), "openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"], "session_id": login["session_id"],
@@ -211,7 +239,7 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
"anti_auto_script_token_id": anti_token, "anti_auto_script_token_id": anti_token,
"isusempaymode": "1", "isusempaymode": "1",
"zoneid": zone_id, "zoneid": zone_id,
"webversion": "minipayv2", "webversion": PAY_WEBVERSION,
"from_https": "1", "from_https": "1",
"t": str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
"__refer": "https://z.iwan.yyb.qq.com/", "__refer": "https://z.iwan.yyb.qq.com/",
@@ -224,7 +252,7 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str, def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
is_qq_login: bool) -> str: is_qq_login: bool) -> str:
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。""" """按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
prefix = "".join(random.choices(string.ascii_letters + string.digits, k=8)) prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中, # 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。 # 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
if not is_qq_login: if not is_qq_login:
@@ -255,9 +283,7 @@ def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_
"device_fp_length": device_fp_length, "device_fp_length": device_fp_length,
"sensitive_field_fingerprints": fingerprints, "sensitive_field_fingerprints": fingerprints,
} }
(out_dir / "web-save-request-meta.json").write_text( write_private_json(out_dir / "web-save-request-meta.json", meta)
json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
)
def make_qr(sign: str, output: Path) -> None: def make_qr(sign: str, output: Path) -> None:
@@ -265,8 +291,9 @@ def make_qr(sign: str, output: Path) -> None:
import segno import segno
except ImportError as exc: except ImportError as exc:
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc
output.parent.mkdir(parents=True, exist_ok=True) make_private_directory(output.parent)
segno.make(sign).save(str(output), scale=6, border=2) segno.make(sign).save(str(output), scale=6, border=2)
os.chmod(output, 0o600)
def node_environment() -> dict[str, str]: def node_environment() -> dict[str, str]:
@@ -286,7 +313,7 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
"listed_order_ids": sorted(order_ids(document)), "listed_order_ids": sorted(order_ids(document)),
"matched_completion": completion_summary(matched) if matched else None, "matched_completion": completion_summary(matched) if matched else None,
} }
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") write_private_json(path, record)
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool],
@@ -313,7 +340,7 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo
"baseline_order_ids": sorted(order_ids(document)), "baseline_order_ids": sorted(order_ids(document)),
"qr_created_at": int(time.time()), "qr_created_at": int(time.time()),
} }
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") write_private_json(path, record)
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None: def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None:
@@ -396,11 +423,11 @@ def main() -> int:
stamp = time.strftime("%Y%m%d-%H%M%S") stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
if args.check_only: if args.check_only:
out_dir.mkdir(parents=True, exist_ok=True) make_private_directory(out_dir)
return cmd_check_only(session_path, out_dir) return cmd_check_only(session_path, out_dir)
if args.amount_fen <= 0: if args.amount_fen <= 0:
raise ValueError("充值金额必须为正数") raise ValueError("充值金额必须为正数")
payment_pf = args.pf or "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android" payment_pf = args.pf or DEFAULT_ORDER_PF
session = load_json(session_path) session = load_json(session_path)
cookies = dict(session.get("cookies", {})) cookies = dict(session.get("cookies", {}))
@@ -418,13 +445,13 @@ def main() -> int:
stamp = time.strftime("%Y%m%d-%H%M%S") stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
out_dir.mkdir(parents=True, exist_ok=True) make_private_directory(out_dir)
url = goods_page_url(cookies, order, args.zone_id, args.pf) url = goods_page_url(cookies, order, args.zone_id, args.pf)
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...") print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
print("[jsdom-pay] 拉取同订单 goods 页面...") print("[jsdom-pay] 拉取同订单 goods 页面...")
html = request_bytes(url, cookies) html = request_bytes(url, cookies)
html_path = out_dir / "goods.html" html_path = out_dir / "goods.html"
html_path.write_text(html, encoding="utf-8") write_private_text(html_path, html)
xmidas, web_token, anti_token = extract_goods_state(html) xmidas, web_token, anti_token = extract_goods_state(html)
print(f"[jsdom-pay] goods: xMidasOps={len(xmidas)} web_token={web_token[:12]}...") print(f"[jsdom-pay] goods: xMidasOps={len(xmidas)} web_token={web_token[:12]}...")
@@ -443,7 +470,7 @@ def main() -> int:
print("[jsdom-pay] 上报 fp-behv...") print("[jsdom-pay] 上报 fp-behv...")
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()) fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
(out_dir / "fp-response.json").write_text(fp_response, encoding="utf-8") write_private_text(out_dir / "fp-response.json", fp_response)
try: try:
fp_json = json.loads(fp_response) fp_json = json.loads(fp_response)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -458,7 +485,7 @@ def main() -> int:
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body( page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
order, cookies, anti_token, args.zone_id, payment_pf, order, cookies, anti_token, args.zone_id, payment_pf,
).encode()) ).encode())
(out_dir / "web-page-info-response.json").write_text(page_info, encoding="utf-8") write_private_text(out_dir / "web-page-info-response.json", page_info)
try: try:
page_info_json = json.loads(page_info) page_info_json = json.loads(page_info)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -470,10 +497,13 @@ def main() -> int:
return 1 return 1
web_args = load_template_args() web_args = load_template_args()
validate_goods_materials(web_args, xmidas)
write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas))
print("[jsdom-pay] 协议材料校验通过")
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器 # key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验, # derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
# 无需浏览器捕获 key(微信渠道纯 HTTP 已 ret:0)。 # 无需浏览器捕获 key(微信渠道纯 HTTP 已 ret:0)。
key16 = [random.randrange(256) for _ in range(16)] key16 = [secrets.randbelow(256) for _ in range(16)]
tables = [web_args[index][0] for index in (1, 2, 3, 4)] tables = [web_args[index][0] for index in (1, 2, 3, 4)]
key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0]) key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0])
params = { params = {
@@ -493,7 +523,7 @@ def main() -> int:
"provide_uin": "", "provide_uin": "",
"extend": "", "extend": "",
"from_h5": "1", "from_h5": "1",
"webversion": "minipayv2", "webversion": PAY_WEBVERSION,
} }
params.update(midas_login_params(cookies)) params.update(midas_login_params(cookies))
now_seconds = str(int(time.time())) now_seconds = str(int(time.time()))
@@ -525,7 +555,7 @@ def main() -> int:
print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试") print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试")
print("[jsdom-pay] 提交 web_save...") print("[jsdom-pay] 提交 web_save...")
raw = request_bytes(SAVE_URL, cookies, body.encode()) raw = request_bytes(SAVE_URL, cookies, body.encode())
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8") write_private_text(out_dir / "web-save-response.json", raw)
try: try:
response_json = json.loads(raw) response_json = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -13,6 +13,7 @@ import importlib.util
import json import json
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -25,6 +26,7 @@ from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
DEFAULT_DATA = ROOT / "config" / "worker-jobs" DEFAULT_DATA = ROOT / "config" / "worker-jobs"
DEFAULT_EVIDENCE_TTL_HOURS = 72
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "") WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
_jobs: dict[str, dict] = {} _jobs: dict[str, dict] = {}
_lock = threading.Lock() _lock = threading.Lock()
@@ -53,6 +55,25 @@ def _job_dir(job_id: str) -> Path:
return Path(_jobs[job_id]["directory"]) return Path(_jobs[job_id]["directory"])
def _cleanup_expired_jobs(data_dir: Path, ttl_hours: int) -> int:
"""清理超过保留期的任务证据;只处理 data_dir 的一级任务目录。"""
if ttl_hours <= 0 or not data_dir.exists():
return 0
deadline = time.time() - ttl_hours * 3600
removed = 0
for directory in data_dir.iterdir():
if not directory.is_dir() or directory.is_symlink():
continue
try:
if directory.stat().st_mtime >= deadline:
continue
shutil.rmtree(directory)
removed += 1
except OSError as exc:
print(f"无法清理过期任务证据 {directory.name}: {exc}", flush=True)
return removed
def _safe_log(job: dict, line: str) -> None: def _safe_log(job: dict, line: str) -> None:
# Do not persist cookies, payment URI, or long opaque tokens in the worker API. # Do not persist cookies, payment URI, or long opaque tokens in the worker API.
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line) clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
@@ -389,6 +410,7 @@ class Handler(BaseHTTPRequestHandler):
job_id = uuid.uuid4().hex[:16] job_id = uuid.uuid4().hex[:16]
directory = DEFAULT_DATA / job_id directory = DEFAULT_DATA / job_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login", _jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
"logs": [], "directory": str(directory), "created_at": int(time.time())} "logs": [], "directory": str(directory), "created_at": int(time.time())}
return self._json(201, _public_job(job_id)) return self._json(201, _public_job(job_id))
@@ -565,6 +587,8 @@ def main() -> int:
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8810) parser.add_argument("--port", type=int, default=8810)
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA) parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS,
help="任务原始证据保留时长;0 表示不自动清理")
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY") parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
args = parser.parse_args() args = parser.parse_args()
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。 # 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
@@ -573,7 +597,13 @@ def main() -> int:
WORKER_KEY = args.key WORKER_KEY = args.key
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY: if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY") parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
if args.evidence_ttl_hours < 0:
parser.error("--evidence-ttl-hours 不能小于 0")
DEFAULT_DATA.mkdir(parents=True, exist_ok=True) DEFAULT_DATA.mkdir(parents=True, exist_ok=True)
os.chmod(DEFAULT_DATA, 0o700)
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
if removed:
print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True)
restored = _restore_jobs(DEFAULT_DATA) restored = _restore_jobs(DEFAULT_DATA)
if restored: if restored:
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True) print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
@@ -16,6 +16,8 @@ sys.modules[_SPEC.name] = _MODULE
_SPEC.loader.exec_module(_MODULE) _SPEC.loader.exec_module(_MODULE)
from pyvm.payment_errors import describe_payment_failure from pyvm.payment_errors import describe_payment_failure
from pyvm.algorithm import derive_key1_from_key16, generate_encrypt_msg_offline
from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
class TestJsdomPay(unittest.TestCase): class TestJsdomPay(unittest.TestCase):
@@ -100,6 +102,54 @@ class TestJsdomPay(unittest.TestCase):
self.assertEqual(len(rand_value), 9) self.assertEqual(len(rand_value), 9)
self.assertEqual(rand_value[-1], "\x01") self.assertEqual(rand_value[-1], "\x01")
def test_goods_protocol_template_is_compatible(self):
template = _MODULE.load_template_args()
validate_goods_materials(template, [0] * 59640)
diagnostics = goods_material_diagnostics(_MODULE.ROOT, template, [0] * 59640)
self.assertEqual(diagnostics["goods_xmidas_ops_length"], 59640)
self.assertEqual(diagnostics["args_template_slots"], 18)
self.assertEqual(len(diagnostics["bytecode_sha256_prefix"]), 12)
def test_private_evidence_writer_restricts_permissions(self):
import tempfile
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "evidence.json"
_MODULE.write_private_text(path, "sensitive")
self.assertEqual(path.read_text(encoding="utf-8"), "sensitive")
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
def test_goods_vm_generates_expected_full_length_ciphertext(self):
"""528B 明文应走完 33 个块,输出 1056 个 hex 字符。"""
import json
template = _MODULE.load_template_args()
xmidas = json.loads((_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8"))
params = {
"token_id": "token", "openid": "openid", "openkey": "key", "session_id": "openid",
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
"buy_quantity": "1", "mb_pwd": "", "pay_id": "", "auth_key": "", "card_value": "",
"accounttype": "", "provide_uin": "", "extend": "", "from_h5": "1",
"webversion": "minipayv2",
}
timestamp = "1775990000"
fk_extend = "tdrc_session%3Dpay-test"
for length in range(512):
params["extend"] = "x" * length
rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True)
plaintext = _MODULE.build_plaintext(params, fk_extend, timestamp, rand_value)
if len(plaintext.encode("latin-1")) == 528:
break
else:
self.fail("无法构造 528B goods 测试明文")
key16 = list(range(16))
key1 = derive_key1_from_key16(key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0])
ciphertext = generate_encrypt_msg_offline(
params, fk_extend, timestamp, rand_value, key16=key16, key1=key1,
args_template=template, xmidas=xmidas, xmidas_token="A" * 96,
)
self.assertEqual(len(ciphertext), 1056)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()