feat: RSA login encryption + confirmed wechat type codes

Replace the Phase-0 placeholder password encryption with real RSA
(PKCS#1 v1.5). encrypt_password now builds an RSA public key from the
getSecureKey modulus/exponent (RSAPublicNumbers, e=int(hex,16),
n=int(hex,16)), encrypts the UTF-8 password, and returns hex (documented
assumption -- base64 is the one-line fallback noted in the docstring).
get_secure_key now returns the inner data dict to match the new contract;
pc_login keeps {account, password} with a comment on the username/password
and account/pwd fallbacks if pcLogin rejects.

Extend normalize_wechat_type with the Phase-0 confirmed codes:
2 -> image, 3 -> image_legacy, 4 -> video, 10 -> system (1 -> text and
-2 -> divider unchanged; 6/9/13/15/21/22 stay unknown until confirmed).

Tests:
- encrypt_password round-trip: generate an RSA keypair, build secure_key
  from its public numbers, encrypt, then decrypt with the private key and
  assert the original password comes back. Pins 512-hex output for a
  2048-bit key. No network.
- Parser parametrize table extended for the new codes (and a downstream
  message-fixture assertion updated from unknown -> image_legacy).

Suite: 87 -> 97 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 13:37:42 +08:00
parent 7f19b12e74
commit eebff9f786
7 changed files with 214 additions and 102 deletions
+27 -13
View File
@@ -4,12 +4,12 @@ The yunke CRM uses opaque integer type codes (``type`` / ``messageType``) on
WeChat message records. The storage layer prefers stable enum strings so that WeChat message records. The storage layer prefers stable enum strings so that
media dispatch (image / voice / video / file) and presentation (text vs. media dispatch (image / voice / video / file) and presentation (text vs.
date divider) can be keyed off a single normalized value rather than the date divider) can be keyed off a single normalized value rather than the
raw code, whose exact mapping for non-text codes is a Phase-0 unknown. raw code.
This module is intentionally tiny: one total function that maps the This module is intentionally tiny: one total function that maps the
documented codes and falls through to ``"unknown"`` for everything else. Phase-0 confirmed codes and falls through to ``"unknown"`` for everything
It MUST NOT raise — every record pulled during a scrape passes through here, else. It MUST NOT raise — every record pulled during a scrape passes through
and one unexpected code must not abort the whole pull. The raw code is here, and one unexpected code must not abort the whole pull. The raw code is
preserved on the record's ``raw_json`` so the persistence layer can recover preserved on the record's ``raw_json`` so the persistence layer can recover
it when needed. it when needed.
""" """
@@ -20,26 +20,40 @@ from typing import Any
__all__ = ["normalize_wechat_type"] __all__ = ["normalize_wechat_type"]
# Documented WeChat message type codes (see Task 1.6 spec). # Phase-0 confirmed WeChat message type codes (see
# * ``1`` — plain text (and sticker text). The only non-media type we map # docs/superpowers/specs/phase0-findings.md §2). Captured by live inspection
# today; everything else falls through to ``"unknown"``. # of the yunke CRM.
# * ``1`` — plain text (and sticker text).
# * ``-2`` — date divider. NOT a real message; the scraper uses this label as # * ``-2`` — date divider. NOT a real message; the scraper uses this label as
# its drop signal (filtered out, never yielded, never persisted). # its drop signal (filtered out, never yielded, never persisted).
# Image / voice / video / file / transfer / redpacket / system codes are # * ``2`` — image / sticker (webp). ``file`` / ``fileTh`` are direct URLs;
# observed in the wild but their exact integer values are a Phase-0 unknown. # download + dedupe by ``md5``.
# When confirmed, extend this table — for now they pass through as # * ``3`` — image, legacy format. ``file`` is an opaque ID (not a URL) and
# ``"unknown"`` so the message is still ingested rather than dropped. # needs a separate download endpoint; flagged distinctly so the media layer
# can route it.
# * ``4`` — video (mp4). ``file`` is a direct URL.
# * ``10`` — system / link / card / invite. ``file`` is a support URL (non-media);
# the media layer skips downloading.
# Other codes (``6``, ``9``, ``13``, ``15``, ``21``, ``22`` — voice / file /
# transfer / redpacket / quote / etc.) are observed but unconfirmed; they pass
# through as ``"unknown"`` so the message is still ingested (raw code kept on
# ``raw_json``) rather than dropped. Extend this table as each is confirmed.
_WECHAT_TYPE_NAMES: dict[int, str] = { _WECHAT_TYPE_NAMES: dict[int, str] = {
1: "text", 1: "text",
-2: "divider", -2: "divider",
2: "image",
3: "image_legacy",
4: "video",
10: "system",
} }
def normalize_wechat_type(raw: Any) -> str: def normalize_wechat_type(raw: Any) -> str:
"""Map an upstream WeChat ``type`` / ``messageType`` code to a stable string. """Map an upstream WeChat ``type`` / ``messageType`` code to a stable string.
Returns ``"text"`` for code ``1``, ``"divider"`` for code ``-2``, and Confirmed mappings: ``1→text``, ``-2→divider``, ``2→image``,
``"unknown"`` for any other / missing / unparsable value. ``3→image_legacy``, ``4→video``, ``10→system``. Any other / missing /
unparsable value returns ``"unknown"``.
Never raises: an unexpected type must not break the scrape. The raw code Never raises: an unexpected type must not break the scrape. The raw code
is preserved on ``raw_json`` so the persistence layer can recover it. is preserved on ``raw_json`` so the persistence layer can recover it.
+2 -2
View File
@@ -6,8 +6,8 @@ Public surface:
company, cookies snapshot, raw payload). company, cookies snapshot, raw payload).
* :func:`app.session.login.get_secure_key` — ``GET /newuc/login/getSecureKey``. * :func:`app.session.login.get_secure_key` — ``GET /newuc/login/getSecureKey``.
* :func:`app.session.login.pc_login` — ``POST /usercenter/login/pcLogin``. * :func:`app.session.login.pc_login` — ``POST /usercenter/login/pcLogin``.
* :func:`app.session.login.encrypt_password` — Phase-0 swappable password * :func:`app.session.login.encrypt_password` — RSA (PKCS#1 v1.5) password
encryption (see file for the open TODO). encryption using the ``getSecureKey`` modulus/exponent.
* :class:`app.session.yunke_client.YunkeClient` — the authenticated HTTP * :class:`app.session.yunke_client.YunkeClient` — the authenticated HTTP
client every scraper uses. client every scraper uses.
""" """
+77 -44
View File
@@ -1,37 +1,42 @@
"""yunke CRM login flow: ``getSecureKey`` -> ``pcLogin``. """yunke CRM login flow: ``getSecureKey`` -> ``pcLogin``.
Real-API facts (from inspection — see design doc §6.1) Real-API facts (from Phase-0 reconnaissance — see
``docs/superpowers/specs/phase0-findings.md`` §1)
------------------------------------------------------ ------------------------------------------------------
* ``GET /newuc/login/getSecureKey`` kicks off the login chain. * ``GET /newuc/login/getSecureKey`` returns the RSA public key as
``{"data": {"public_exponent": "10001", "modulus": "<hex>"}}``.
* The password is **RSA-encrypted (PKCS#1 v1.5)** with that public key
(exponent ``0x10001`` = 65537, modulus = the returned hex) — see
:func:`encrypt_password`.
* ``POST /usercenter/login/pcLogin`` exchanges credentials for a session. * ``POST /usercenter/login/pcLogin`` exchanges credentials for a session.
* After login every data request to ``crm2.yunkecn.com`` carries the headers * After login every data request to ``crm2.yunkecn.com`` carries the headers
``userid`` / ``company`` / ``source=yunkepc`` / ``channel=4`` / ``userid`` / ``company`` / ``source=yunkepc`` / ``channel=4`` /
``content-type=application/json`` plus the login session cookie (handled ``content-type=application/json`` plus the login session cookie (handled
by the httpx client's cookie jar, not by this module). by the httpx client's cookie jar, not by this module).
Phase-0 unknowns (clearly marked) Remaining inferences (clearly marked)
--------------------------------- -------------------------------------
The exact ``pcLogin`` request body, the password encryption scheme (almost The exact ``pcLogin`` request-body field names and the precise response JSON
certainly RSA using the modulus/exponent from ``getSecureKey``), and the shape were not fully captured. To keep the session layer end-to-end runnable:
precise response JSON shape were NOT captured. To keep the session layer
end-to-end runnable today:
* :func:`encrypt_password` is a deterministic, documented placeholder. The * :func:`pc_login` sends ``{"account", "password"}`` (password = the RSA-hex).
real RSA implementation drops in here later without touching call-sites. If ``pcLogin`` rejects, the next variants to try are ``{"username",
"password"}`` / ``{"account", "pwd"}`` — see phase0-findings §1.
* :func:`pc_login` parses ``userid`` / ``company`` out of a ``data`` object * :func:`pc_login` parses ``userid`` / ``company`` out of a ``data`` object
with several reasonable key-name variants. When the real shape is captured with several reasonable key-name variants. When the real shape is captured
only this parser needs to change. only this parser needs to change.
Tests mock both endpoints, so the placeholders are exercised end-to-end. Tests mock both endpoints, so the flow is exercised end-to-end.
""" """
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
import httpx import httpx
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Data structures # Data structures
@@ -67,31 +72,46 @@ class LoginResult:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Password encryption — Phase-0 placeholder # Password encryption — RSA (PKCS#1 v1.5), Phase-0 confirmed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str: def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str:
"""Encrypt ``plain`` using the key material in ``secure_key``. """RSA-encrypt ``plain`` (PKCS#1 v1.5) and return the ciphertext as hex.
Phase-0 TODO: real RSA encryption per ``getSecureKey``. ``secure_key`` is the parsed ``data`` object from
``GET /newuc/login/getSecureKey`` (Phase-0 confirmed shape)::
The browser-side crypto was not captured. Based on the endpoint name {"public_exponent": "10001", "modulus": "<hex>"}
(``getSecureKey``) and the typical Chinese-CRM login idiom, the real
implementation almost certainly RSA-encrypts the password using a
modulus/exponent (or PEM public key) returned in ``secure_key`` and
then base64-encodes the ciphertext. Until that is confirmed by a real
capture, this function returns base64(UTF-8(plain)) so the login flow
is end-to-end runnable and deterministic for tests.
Drop-in contract for the real implementation: Both fields are hex strings. ``public_exponent`` is typically ``"10001"``
def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str: (65537); ``modulus`` is the RSA modulus. An :class:`RSAPublicNumbers`
# 1. Pull modulus/exponent (or PEM) out of secure_key. public key is built from ``int(modulus, 16)`` / ``int(exponent, 16)``,
# 2. RSA-OAEP (or PKCS#1 v1.5) encrypt UTF-8(plain). used to PKCS#1 v1.5-encrypt UTF-8(plain), and the ciphertext is returned
# 3. Return base64(ciphertext)." as a lowercase hex string.
Output-format assumption: **hex**. Phase-0 confirmed the public-key shape
but not the ciphertext encoding; hex is the common Chinese-CRM convention
and is what this implementation uses. If ``pcLogin`` ever rejects the
body, base64 is the first alternative to try — both are one-line changes
here (``ciphertext.hex()`` <-> ``base64.b64encode(ciphertext).decode()``).
Raises ``ValueError`` if ``secure_key`` is missing either field.
""" """
# Phase 0 TODO: real RSA encryption per getSecureKey. exp_hex = secure_key.get("public_exponent")
return base64.b64encode(plain.encode("utf-8")).decode("ascii") mod_hex = secure_key.get("modulus")
if not exp_hex or not mod_hex:
raise ValueError(
"secure_key must contain 'public_exponent' and 'modulus' (hex"
" strings) — got: "
f"{secure_key!r}"
)
e = int(str(exp_hex), 16)
n = int(str(mod_hex), 16)
public_key = RSAPublicNumbers(e, n).public_key()
ciphertext = public_key.encrypt(plain.encode("utf-8"), padding.PKCS1v15())
return ciphertext.hex()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -100,15 +120,23 @@ def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str:
async def get_secure_key(client: httpx.AsyncClient) -> dict[str, Any]: async def get_secure_key(client: httpx.AsyncClient) -> dict[str, Any]:
"""Call ``GET {base}/newuc/login/getSecureKey`` and return the JSON body. """Call ``GET {base}/newuc/login/getSecureKey`` and return the key material.
The exact shape of the secure-key payload is a Phase-0 unknown; this Phase-0 confirmed return shape::
function returns the whole decoded JSON so :func:`encrypt_password` (and
eventually the real RSA step) can pick whatever fields it needs. {"code": 10000, "data": {"public_exponent": "10001", "modulus": "<hex>"}}
Returns the inner ``data`` dict (the ``public_exponent`` / ``modulus`` key
material) so callers — in particular :func:`encrypt_password` — receive
the key directly. If the wrapper is ever absent, the whole payload is
returned as a defensive fallback.
""" """
resp = await client.get("/newuc/login/getSecureKey") resp = await client.get("/newuc/login/getSecureKey")
resp.raise_for_status() resp.raise_for_status()
return resp.json() payload = resp.json()
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
return payload["data"]
return payload
async def pc_login( async def pc_login(
@@ -119,17 +147,22 @@ async def pc_login(
) -> LoginResult: ) -> LoginResult:
"""Call ``POST {base}/usercenter/login/pcLogin`` and parse a LoginResult. """Call ``POST {base}/usercenter/login/pcLogin`` and parse a LoginResult.
Phase-0 assumptions about the request body (not captured): Request body inference (field names NOT yet captured — see
``{"account": <username>, "password": <encrypt_password(...)>}``. phase0-findings §1)::
Real key names (``account`` vs ``loginName`` vs ``username``) and any
additional required fields (captcha, deviceId, ...) are to be confirmed;
only this function needs to change when they are.
Phase-0 assumptions about the response (not captured): JSON body with a {"account": <username>, "password": <encrypt_password(...)>}
``data`` object containing ``userid`` + ``company``. Several reasonable
key-name variants are tried (see :func:`_pick`). The session cookie is where ``password`` is the RSA-hex from :func:`encrypt_password`. If
read back from the httpx client's cookie jar — ``pcLogin``'s ``pcLogin`` rejects this, the next variants to try are
``Set-Cookie`` lands there automatically. ``{"username", "password"}`` or ``{"account", "pwd"}``; any additional
required fields (captcha, deviceId, ...) land here too. Only this
function changes when the real shape is captured.
Response inference (NOT yet captured): JSON body with a ``data`` object
containing ``userid`` + ``company``. Several reasonable key-name variants
are tried (see :func:`_pick`). The session cookie is read back from the
httpx client's cookie jar — ``pcLogin``'s ``Set-Cookie`` lands there
automatically.
""" """
body = { body = {
"account": username, "account": username,
+21 -18
View File
@@ -7,20 +7,20 @@ pipeline is built.
Auth modes (mutually exclusive; session mode preferred for now) Auth modes (mutually exclusive; session mode preferred for now)
-------------------------------------------------------------- --------------------------------------------------------------
1. **Existing-session mode (preferred today).** Set ``YK_USERID``, 1. **Existing-session mode (most reliable).** Set ``YK_USERID``,
``YK_COMPANY``, and optionally ``YK_COOKIE``. The script builds a ``YK_COMPANY``, and optionally ``YK_COOKIE``. The script builds a
:class:`YunkeClient` directly from that captured session :class:`YunkeClient` directly from that captured session
(``userid=`` / ``company=`` / ``cookies=``) and BYPASSES login. This is the (``userid=`` / ``company=`` / ``cookies=``) and BYPASSES login. This avoids
only auth mode that actually authenticates against the real CRM today — the remaining ``pcLogin`` body-field-name inference in mode 2.
see the Phase-0 note below.
2. **Login mode (future).** Set ``YUNKE_USERNAME`` and ``YUNKE_PASSWORD`` (and 2. **Login mode.** Set ``YUNKE_USERNAME`` and ``YUNKE_PASSWORD`` (and
leave the session vars unset). The script calls :meth:`YunkeClient.login`, leave the session vars unset). The script calls :meth:`YunkeClient.login`,
which runs the ``getSecureKey`` -> ``pcLogin`` flow. **NOTE:** password which runs the ``getSecureKey`` -> ``pcLogin`` flow. Password encryption
encryption is a Phase-0 placeholder (see is real RSA (PKCS#1 v1.5) using the ``getSecureKey`` modulus/exponent —
:func:`app.session.login.encrypt_password`), so the real CRM will REJECT the see :func:`app.session.login.encrypt_password`. The remaining inference
login. The script catches that, prints a clear message, and exits with code is the ``pcLogin`` request-body field names; if the real CRM rejects the
3. Once Phase-0 login lands, this mode will just work. body, the script catches that, prints a clear message, and exits with
code 3.
If NEITHER set of env vars is present, the script prints usage and exits If NEITHER set of env vars is present, the script prints usage and exits
with code 2. with code 2.
@@ -51,7 +51,7 @@ Windows, ``source .venv/bin/activate`` on *nix)::
export YK_COOKIE="JSESSIONID=deadbeef; other=foo" export YK_COOKIE="JSESSIONID=deadbeef; other=foo"
python -m scripts.smoke_wechat python -m scripts.smoke_wechat
# Login mode (Phase-0 blocked — will exit 3 with a message): # Login mode (real RSA encryption; pcLogin body field names inferred):
export YUNKE_USERNAME=user export YUNKE_USERNAME=user
export YUNKE_PASSWORD=pass export YUNKE_PASSWORD=pass
python -m scripts.smoke_wechat python -m scripts.smoke_wechat
@@ -61,8 +61,9 @@ The smoke DB defaults to ``./smoke.db`` (SQLite); override the URL with
SQLAlchemy async URL). On a 401 / expired session, the script prints a helpful SQLAlchemy async URL). On a 401 / expired session, the script prints a helpful
message and exits non-zero (code 4) instead of dumping a traceback. message and exits non-zero (code 4) instead of dumping a traceback.
Exit codes: ``0`` success · ``2`` no auth env · ``3`` login failed (Phase-0 Exit codes: ``0`` success · ``2`` no auth env · ``3`` login failed (e.g. an
blocked) · ``4`` upstream auth/session error mid-scrape. inferred ``pcLogin`` body field name the real CRM rejects) · ``4`` upstream
auth/session error mid-scrape.
""" """
from __future__ import annotations from __future__ import annotations
@@ -195,17 +196,19 @@ async def main() -> int:
_print_usage() _print_usage()
return 2 return 2
# Login mode: try to log in. Phase-0 placeholder encryption will be # Login mode: try to log in. The pcLogin body field names are still an
# rejected by the real CRM — catch and explain instead of tracebacking. # inference, so a real-CRM rejection is possible — catch and explain
# instead of tracebacking.
if not client.logged_in: if not client.logged_in:
try: try:
await client.login() await client.login()
except Exception as exc: # noqa: BLE001 — any failure is fatal-but-explained except Exception as exc: # noqa: BLE001 — any failure is fatal-but-explained
sys.stderr.write( sys.stderr.write(
f"\n[smoke] login failed (likely Phase-0 placeholder " f"\n[smoke] login failed (real RSA encryption is in place; "
f"encryption): {exc!r}\n" f"the likely cause is an inferred pcLogin body field name): "
f"{exc!r}\n"
"Use existing-session mode (YK_USERID / YK_COMPANY / YK_COOKIE) " "Use existing-session mode (YK_USERID / YK_COMPANY / YK_COOKIE) "
"until Phase-0 login lands.\n" "if login keeps failing.\n"
) )
await client.aclose() await client.aclose()
return 3 return 3
+15 -5
View File
@@ -18,17 +18,27 @@ from app.scraper.parsers import normalize_wechat_type
@pytest.mark.parametrize( @pytest.mark.parametrize(
"raw,expected", "raw,expected",
[ [
# Documented codes. # Phase-0 confirmed codes (see docs/superpowers/specs/phase0-findings.md §2).
(1, "text"), (1, "text"),
(-2, "divider"), (-2, "divider"), # date separator — NOT a real message (scraper drops it)
# Phase-0 unknown codes — pass through as "unknown", never dropped. (2, "image"), # image / sticker (webp) — file/fileTh are direct URLs
(3, "unknown"), # image (3, "image_legacy"), # old image format — file is an opaque ID (needs another endpoint)
(34, "unknown"), # voice (4, "video"), # mp4 — file is a direct URL
(10, "system"), # system / link / card / invite — non-media, skip download
# Still-unconfirmed codes — pass through as "unknown", never dropped.
# (6, 9, 13, 15, 21, 22 — voice/file/transfer/redpacket/quote/etc; raw_json retains the code.)
(6, "unknown"),
(9, "unknown"),
(34, "unknown"), # voice (legacy WeChat code, not yet seen in yunke)
(49, "unknown"), # file / link / transfer (49, "unknown"), # file / link / transfer
(10000, "unknown"), (10000, "unknown"),
# Stringified ints (the upstream API may send either form). # Stringified ints (the upstream API may send either form).
("1", "text"), ("1", "text"),
("-2", "divider"), ("-2", "divider"),
("2", "image"),
("3", "image_legacy"),
("4", "video"),
("10", "system"),
("34", "unknown"), ("34", "unknown"),
], ],
) )
@@ -114,12 +114,12 @@ async def test_scrape_wechat_messages_drops_divider_and_paginates_backward():
assert r1["sender_head_url"] == "https://cdn.example.com/alice.png" assert r1["sender_head_url"] == "https://cdn.example.com/alice.png"
assert r1["raw_json"] == MESSAGE_RECORDS_PAGE1[0] assert r1["raw_json"] == MESSAGE_RECORDS_PAGE1[0]
# m2 — image / chatroom / outgoing. # m2 — image (legacy) / chatroom / outgoing.
assert r2["msg_id"] == "1000002" assert r2["msg_id"] == "1000002"
assert r2["room_id"] == "room12345@chatroom" assert r2["room_id"] == "room12345@chatroom"
assert r2["ori_talker"] == "room12345@chatroom" assert r2["ori_talker"] == "room12345@chatroom"
assert r2["is_send"] == 1 assert r2["is_send"] == 1
assert r2["type"] == "unknown" # unmapped code passes through assert r2["type"] == "image_legacy" # code 3 -> legacy image (phase0 §2)
assert r2["type_raw"] == 3 # raw image-type code preserved assert r2["type_raw"] == 3 # raw image-type code preserved
assert r2["file_url"] == "https://cdn.example.com/img2.jpg" assert r2["file_url"] == "https://cdn.example.com/img2.jpg"
assert r2["file_th_url"] == "https://cdn.example.com/img2_th.jpg" assert r2["file_th_url"] == "https://cdn.example.com/img2_th.jpg"
+70 -18
View File
@@ -4,11 +4,13 @@ These tests use ``respx`` to mock the upstream httpx transport so we can
assert exactly what gets sent (headers, body) and drive the auto-login + assert exactly what gets sent (headers, body) and drive the auto-login +
401-relogin behaviour without touching the real CRM. 401-relogin behaviour without touching the real CRM.
Phase-0 caveat: the real ``pcLogin`` request body / password encryption / Phase-0 caveat: the real ``pcLogin`` request-body field names and response
response shape were not captured. The mocks here encode our documented shape are still inferences (see ``app/session/login.py``). Password
assumptions (see ``app/session/login.py``): response JSON has a ``data`` encryption is now confirmed real RSA (PKCS#1 v1.5) against the
object with ``userid`` + ``company`` fields. When the real shapes are ``getSecureKey`` modulus/exponent. The mocks here encode the confirmed
confirmed, only the parsing inside ``pc_login`` needs to change — these ``getSecureKey`` payload shape and our inferred response shape: JSON with a
``data`` object carrying ``userid`` + ``company``. When the response shape
is captured, only the parsing inside ``pc_login`` needs to change — these
tests stay green. tests stay green.
""" """
@@ -17,6 +19,7 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
import respx import respx
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from app.session.login import LoginResult from app.session.login import LoginResult
from app.session.yunke_client import YunkeClient from app.session.yunke_client import YunkeClient
@@ -25,6 +28,25 @@ BASE = "https://crm2.yunkecn.com"
USERID = "a" * 32 # 32-hex user id, as observed in real auth headers USERID = "a" * 32 # 32-hex user id, as observed in real auth headers
COMPANY = "8m0atd" COMPANY = "8m0atd"
# Fixed RSA keypair backing the mocked ``getSecureKey`` endpoint so the real
# RSA step in pc_login has a valid key to encrypt against. The matching
# private key is retained so the round-trip test can decrypt what
# encrypt_password produced. Generated once at import (cheap vs. network).
_TEST_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _secure_key_from_test_public_key() -> dict[str, str]:
"""Build the Phase-0 confirmed ``getSecureKey`` ``data`` payload from the
test public key — ``public_exponent`` + ``modulus`` as hex strings."""
nums = _TEST_PRIVATE_KEY.public_key().public_numbers()
return {
"public_exponent": format(nums.e, "x"),
"modulus": format(nums.n, "x"),
}
_TEST_SECURE_KEY = _secure_key_from_test_public_key()
@pytest.fixture() @pytest.fixture()
async def client(): async def client():
@@ -43,9 +65,7 @@ async def client():
def _stub_login_endpoints() -> tuple[respx.Route, respx.Route]: def _stub_login_endpoints() -> tuple[respx.Route, respx.Route]:
"""Register the getSecureKey + pcLogin mocks used across tests.""" """Register the getSecureKey + pcLogin mocks used across tests."""
secure = respx.get(f"{BASE}/newuc/login/getSecureKey").mock( secure = respx.get(f"{BASE}/newuc/login/getSecureKey").mock(
return_value=httpx.Response( return_value=httpx.Response(200, json={"data": _TEST_SECURE_KEY})
200, json={"data": {"modulus": "00ab", "exponent": "010001"}}
)
) )
login = respx.post(f"{BASE}/usercenter/login/pcLogin").mock( login = respx.post(f"{BASE}/usercenter/login/pcLogin").mock(
return_value=httpx.Response( return_value=httpx.Response(
@@ -190,19 +210,51 @@ async def test_first_request_auto_logs_in(client: YunkeClient):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test F: encrypt_password is a documented Phase-0 placeholder # Test F: encrypt_password performs real RSA (PKCS#1 v1.5) encryption
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_encrypt_password_is_swappable_phase0_placeholder(): def test_encrypt_password_rsa_round_trips():
"""Phase 0: encrypt_password must be deterministic and non-trivial but is """Real RSA: encrypt_password must produce a hex PKCS#1 v1.5 ciphertext
NOT the real RSA scheme. It must accept any secure-key shape and return a that the matching private key decrypts back to the original password.
str so the login flow is end-to-end runnable in tests.
``secure_key`` is built from the test public key in the Phase-0 confirmed
shape (``public_exponent`` + ``modulus`` as hex strings, exactly what
``GET /newuc/login/getSecureKey`` returns). Proves real RSA end-to-end
without needing the live yunke key. No network.
""" """
from app.session.login import encrypt_password from app.session.login import encrypt_password
out = encrypt_password("hunter2", {"modulus": "00ab", "exponent": "010001"}) cipher_hex = encrypt_password("hunter2", _TEST_SECURE_KEY)
assert isinstance(out, str)
assert out # non-empty # Output contract: a hex string (yunke convention; see phase0-findings §1).
# Deterministic for the same inputs (so tests / debugging are stable). assert isinstance(cipher_hex, str)
assert encrypt_password("hunter2", {"modulus": "00ab"}) == out assert len(cipher_hex) % 2 == 0
assert int(cipher_hex, 16) > 0
# For a 2048-bit key the PKCS#1 v1.5 ciphertext is 256 bytes -> 512 hex
# chars. Pin the length so a regression to a stub (e.g. base64 of the
# 7-byte password) is caught immediately.
assert len(cipher_hex) == 512
# The decisive assertion: decrypt with the matching private key and the
# original UTF-8 password must come back.
plain = _TEST_PRIVATE_KEY.decrypt(
bytes.fromhex(cipher_hex), padding.PKCS1v15()
)
assert plain.decode("utf-8") == "hunter2"
def test_encrypt_password_uses_phase0_confirmed_field_names():
"""encrypt_password must read ``public_exponent`` + ``modulus`` (the exact
field names ``getSecureKey`` returns), not the earlier placeholder names.
A key dict carrying only the old ``exponent`` field must NOT work.
"""
from app.session.login import encrypt_password
# The confirmed-shape dict encrypts without error.
out = encrypt_password("hunter2", _TEST_SECURE_KEY)
assert isinstance(out, str) and out
# An empty / wrong-shape dict must raise, not silently return junk.
with pytest.raises(ValueError, match="public_exponent"):
encrypt_password("hunter2", {"modulus": "00ab", "exponent": "010001"})