强化支付协议校验与证据保护
This commit is contained in:
@@ -141,6 +141,8 @@ const result = {
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n');
|
||||
fs.chmodSync(path.dirname(outputPath), 0o700);
|
||||
fs.chmodSync(outputPath, 0o600);
|
||||
dom.window.close();
|
||||
console.log(`DeviceFP 已保存: ${outputPath}`);
|
||||
console.log(`SessionID: ${result.session_id}`);
|
||||
|
||||
@@ -11,8 +11,8 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
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.login_profile import midas_login_params # 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"
|
||||
GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
|
||||
SAVE_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_save"
|
||||
PAGE_INFO_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_page_info"
|
||||
FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg"
|
||||
APPID = PAY_APPID
|
||||
GOODS_URL = PAY_GOODS_URL
|
||||
SAVE_URL = PAY_SAVE_URL
|
||||
PAGE_INFO_URL = PAY_PAGE_INFO_URL
|
||||
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:
|
||||
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:
|
||||
headers = {
|
||||
"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",
|
||||
"User-Agent": GOODS_USER_AGENT,
|
||||
"Cookie": cookie_header(cookies),
|
||||
"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",
|
||||
"from_h5": "1",
|
||||
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
||||
"r": str(random.random()),
|
||||
"r": str(secrets.SystemRandom().random()),
|
||||
"openid": cookies.get("openid", ""),
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"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",
|
||||
"wx_order_interface": "1",
|
||||
"encrypt_msg": encrypt_msg,
|
||||
"base_key_version": "web_1.0.6",
|
||||
"base_key_version": PAY_WEB_VERSION,
|
||||
"encrypt_way": "web_new_encrypt",
|
||||
"web_token": web_token,
|
||||
"webversion": "minipayv2",
|
||||
"webversion": PAY_WEBVERSION,
|
||||
"from_https": "1",
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"__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",
|
||||
"from_h5": "1",
|
||||
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
||||
"r": str(random.random()),
|
||||
"r": str(secrets.SystemRandom().random()),
|
||||
"openid": cookies.get("openid", ""),
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"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,
|
||||
"isusempaymode": "1",
|
||||
"zoneid": zone_id,
|
||||
"webversion": "minipayv2",
|
||||
"webversion": PAY_WEBVERSION,
|
||||
"from_https": "1",
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"__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,
|
||||
is_qq_login: bool) -> str:
|
||||
"""按登录渠道生成页面已验证形态的 _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 位于加密明文中,
|
||||
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
||||
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,
|
||||
"sensitive_field_fingerprints": fingerprints,
|
||||
}
|
||||
(out_dir / "web-save-request-meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
|
||||
)
|
||||
write_private_json(out_dir / "web-save-request-meta.json", meta)
|
||||
|
||||
|
||||
def make_qr(sign: str, output: Path) -> None:
|
||||
@@ -265,8 +291,9 @@ def make_qr(sign: str, output: Path) -> None:
|
||||
import segno
|
||||
except ImportError as 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)
|
||||
os.chmod(output, 0o600)
|
||||
|
||||
|
||||
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)),
|
||||
"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],
|
||||
@@ -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)),
|
||||
"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:
|
||||
@@ -396,11 +423,11 @@ def main() -> int:
|
||||
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}"
|
||||
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)
|
||||
if args.amount_fen <= 0:
|
||||
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)
|
||||
cookies = dict(session.get("cookies", {}))
|
||||
@@ -418,13 +445,13 @@ def main() -> int:
|
||||
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
make_private_directory(out_dir)
|
||||
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
||||
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
||||
print("[jsdom-pay] 拉取同订单 goods 页面...")
|
||||
html = request_bytes(url, cookies)
|
||||
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)
|
||||
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...")
|
||||
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:
|
||||
fp_json = json.loads(fp_response)
|
||||
except json.JSONDecodeError:
|
||||
@@ -458,7 +485,7 @@ def main() -> int:
|
||||
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
|
||||
order, cookies, anti_token, args.zone_id, payment_pf,
|
||||
).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:
|
||||
page_info_json = json.loads(page_info)
|
||||
except json.JSONDecodeError:
|
||||
@@ -470,10 +497,13 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
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 由反解器
|
||||
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
||||
# 无需浏览器捕获 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)]
|
||||
key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0])
|
||||
params = {
|
||||
@@ -493,7 +523,7 @@ def main() -> int:
|
||||
"provide_uin": "",
|
||||
"extend": "",
|
||||
"from_h5": "1",
|
||||
"webversion": "minipayv2",
|
||||
"webversion": PAY_WEBVERSION,
|
||||
}
|
||||
params.update(midas_login_params(cookies))
|
||||
now_seconds = str(int(time.time()))
|
||||
@@ -525,7 +555,7 @@ def main() -> int:
|
||||
print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试")
|
||||
print("[jsdom-pay] 提交 web_save...")
|
||||
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:
|
||||
response_json = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -13,6 +13,7 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -25,6 +26,7 @@ from urllib.parse import urlparse
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
DEFAULT_DATA = ROOT / "config" / "worker-jobs"
|
||||
DEFAULT_EVIDENCE_TTL_HOURS = 72
|
||||
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
|
||||
_jobs: dict[str, dict] = {}
|
||||
_lock = threading.Lock()
|
||||
@@ -53,6 +55,25 @@ def _job_dir(job_id: str) -> Path:
|
||||
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:
|
||||
# Do not persist cookies, payment URI, or long opaque tokens in the worker API.
|
||||
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
|
||||
@@ -389,6 +410,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
job_id = uuid.uuid4().hex[:16]
|
||||
directory = DEFAULT_DATA / job_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
|
||||
"logs": [], "directory": str(directory), "created_at": int(time.time())}
|
||||
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("--port", type=int, default=8810)
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
||||
@@ -573,7 +597,13 @@ def main() -> int:
|
||||
WORKER_KEY = args.key
|
||||
if args.host not in {"127.0.0.1", "localhost", "::1"} and not 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)
|
||||
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)
|
||||
if restored:
|
||||
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
|
||||
|
||||
Reference in New Issue
Block a user