diff --git a/yunke-server/app/scraper/parsers.py b/yunke-server/app/scraper/parsers.py index e7f4747..c206870 100644 --- a/yunke-server/app/scraper/parsers.py +++ b/yunke-server/app/scraper/parsers.py @@ -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 media dispatch (image / voice / video / file) and presentation (text vs. 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 -documented codes and falls through to ``"unknown"`` for everything else. -It MUST NOT raise — every record pulled during a scrape passes through here, -and one unexpected code must not abort the whole pull. The raw code is +Phase-0 confirmed codes and falls through to ``"unknown"`` for everything +else. It MUST NOT raise — every record pulled during a scrape passes through +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 it when needed. """ @@ -20,26 +20,40 @@ from typing import Any __all__ = ["normalize_wechat_type"] -# Documented WeChat message type codes (see Task 1.6 spec). -# * ``1`` — plain text (and sticker text). The only non-media type we map -# today; everything else falls through to ``"unknown"``. +# Phase-0 confirmed WeChat message type codes (see +# docs/superpowers/specs/phase0-findings.md §2). Captured by live inspection +# of the yunke CRM. +# * ``1`` — plain text (and sticker text). # * ``-2`` — date divider. NOT a real message; the scraper uses this label as # its drop signal (filtered out, never yielded, never persisted). -# Image / voice / video / file / transfer / redpacket / system codes are -# observed in the wild but their exact integer values are a Phase-0 unknown. -# When confirmed, extend this table — for now they pass through as -# ``"unknown"`` so the message is still ingested rather than dropped. +# * ``2`` — image / sticker (webp). ``file`` / ``fileTh`` are direct URLs; +# download + dedupe by ``md5``. +# * ``3`` — image, legacy format. ``file`` is an opaque ID (not a URL) and +# 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] = { 1: "text", -2: "divider", + 2: "image", + 3: "image_legacy", + 4: "video", + 10: "system", } def normalize_wechat_type(raw: Any) -> str: """Map an upstream WeChat ``type`` / ``messageType`` code to a stable string. - Returns ``"text"`` for code ``1``, ``"divider"`` for code ``-2``, and - ``"unknown"`` for any other / missing / unparsable value. + Confirmed mappings: ``1→text``, ``-2→divider``, ``2→image``, + ``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 is preserved on ``raw_json`` so the persistence layer can recover it. diff --git a/yunke-server/app/session/__init__.py b/yunke-server/app/session/__init__.py index b25114e..1090b51 100644 --- a/yunke-server/app/session/__init__.py +++ b/yunke-server/app/session/__init__.py @@ -6,8 +6,8 @@ Public surface: company, cookies snapshot, raw payload). * :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.encrypt_password` — Phase-0 swappable password - encryption (see file for the open TODO). +* :func:`app.session.login.encrypt_password` — RSA (PKCS#1 v1.5) password + encryption using the ``getSecureKey`` modulus/exponent. * :class:`app.session.yunke_client.YunkeClient` — the authenticated HTTP client every scraper uses. """ diff --git a/yunke-server/app/session/login.py b/yunke-server/app/session/login.py index 92daf7e..3bada83 100644 --- a/yunke-server/app/session/login.py +++ b/yunke-server/app/session/login.py @@ -1,37 +1,42 @@ """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": ""}}``. +* 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. * After login every data request to ``crm2.yunkecn.com`` carries the headers ``userid`` / ``company`` / ``source=yunkepc`` / ``channel=4`` / ``content-type=application/json`` plus the login session cookie (handled by the httpx client's cookie jar, not by this module). -Phase-0 unknowns (clearly marked) ---------------------------------- -The exact ``pcLogin`` request body, the password encryption scheme (almost -certainly RSA using the modulus/exponent from ``getSecureKey``), and the -precise response JSON shape were NOT captured. To keep the session layer -end-to-end runnable today: +Remaining inferences (clearly marked) +------------------------------------- +The exact ``pcLogin`` request-body field names and the precise response JSON +shape were not fully captured. To keep the session layer end-to-end runnable: -* :func:`encrypt_password` is a deterministic, documented placeholder. The - real RSA implementation drops in here later without touching call-sites. +* :func:`pc_login` sends ``{"account", "password"}`` (password = the RSA-hex). + 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 with several reasonable key-name variants. When the real shape is captured 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 -import base64 from dataclasses import dataclass, field from typing import Any import httpx +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers # --------------------------------------------------------------------------- # 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: - """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 - (``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. + {"public_exponent": "10001", "modulus": ""} - Drop-in contract for the real implementation: - def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str: - # 1. Pull modulus/exponent (or PEM) out of secure_key. - # 2. RSA-OAEP (or PKCS#1 v1.5) encrypt UTF-8(plain). - # 3. Return base64(ciphertext)." + Both fields are hex strings. ``public_exponent`` is typically ``"10001"`` + (65537); ``modulus`` is the RSA modulus. An :class:`RSAPublicNumbers` + public key is built from ``int(modulus, 16)`` / ``int(exponent, 16)``, + used to PKCS#1 v1.5-encrypt UTF-8(plain), and the ciphertext is returned + 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. - return base64.b64encode(plain.encode("utf-8")).decode("ascii") + exp_hex = secure_key.get("public_exponent") + 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]: - """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 - function returns the whole decoded JSON so :func:`encrypt_password` (and - eventually the real RSA step) can pick whatever fields it needs. + Phase-0 confirmed return shape:: + + {"code": 10000, "data": {"public_exponent": "10001", "modulus": ""}} + + 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.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( @@ -119,17 +147,22 @@ async def pc_login( ) -> LoginResult: """Call ``POST {base}/usercenter/login/pcLogin`` and parse a LoginResult. - Phase-0 assumptions about the request body (not captured): - ``{"account": , "password": }``. - 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. + Request body inference (field names NOT yet captured — see + phase0-findings §1):: - Phase-0 assumptions about the response (not 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. + {"account": , "password": } + + where ``password`` is the RSA-hex from :func:`encrypt_password`. If + ``pcLogin`` rejects this, the next variants to try are + ``{"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 = { "account": username, diff --git a/yunke-server/scripts/smoke_wechat.py b/yunke-server/scripts/smoke_wechat.py index 974b92b..13aac01 100644 --- a/yunke-server/scripts/smoke_wechat.py +++ b/yunke-server/scripts/smoke_wechat.py @@ -7,20 +7,20 @@ pipeline is built. 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 :class:`YunkeClient` directly from that captured session - (``userid=`` / ``company=`` / ``cookies=``) and BYPASSES login. This is the - only auth mode that actually authenticates against the real CRM today — - see the Phase-0 note below. + (``userid=`` / ``company=`` / ``cookies=``) and BYPASSES login. This avoids + the remaining ``pcLogin`` body-field-name inference in mode 2. -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`, - which runs the ``getSecureKey`` -> ``pcLogin`` flow. **NOTE:** password - encryption is a Phase-0 placeholder (see - :func:`app.session.login.encrypt_password`), so the real CRM will REJECT the - login. The script catches that, prints a clear message, and exits with code - 3. Once Phase-0 login lands, this mode will just work. + which runs the ``getSecureKey`` -> ``pcLogin`` flow. Password encryption + is real RSA (PKCS#1 v1.5) using the ``getSecureKey`` modulus/exponent — + see :func:`app.session.login.encrypt_password`. The remaining inference + is the ``pcLogin`` request-body field names; if the real CRM rejects the + 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 with code 2. @@ -51,7 +51,7 @@ Windows, ``source .venv/bin/activate`` on *nix):: export YK_COOKIE="JSESSIONID=deadbeef; other=foo" 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_PASSWORD=pass 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 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 -blocked) · ``4`` upstream auth/session error mid-scrape. +Exit codes: ``0`` success · ``2`` no auth env · ``3`` login failed (e.g. an +inferred ``pcLogin`` body field name the real CRM rejects) · ``4`` upstream +auth/session error mid-scrape. """ from __future__ import annotations @@ -195,17 +196,19 @@ async def main() -> int: _print_usage() return 2 - # Login mode: try to log in. Phase-0 placeholder encryption will be - # rejected by the real CRM — catch and explain instead of tracebacking. + # Login mode: try to log in. The pcLogin body field names are still an + # inference, so a real-CRM rejection is possible — catch and explain + # instead of tracebacking. if not client.logged_in: try: await client.login() except Exception as exc: # noqa: BLE001 — any failure is fatal-but-explained sys.stderr.write( - f"\n[smoke] login failed (likely Phase-0 placeholder " - f"encryption): {exc!r}\n" + f"\n[smoke] login failed (real RSA encryption is in place; " + 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) " - "until Phase-0 login lands.\n" + "if login keeps failing.\n" ) await client.aclose() return 3 diff --git a/yunke-server/tests/test_parsers.py b/yunke-server/tests/test_parsers.py index 29a5aa5..ea72468 100644 --- a/yunke-server/tests/test_parsers.py +++ b/yunke-server/tests/test_parsers.py @@ -18,17 +18,27 @@ from app.scraper.parsers import normalize_wechat_type @pytest.mark.parametrize( "raw,expected", [ - # Documented codes. + # Phase-0 confirmed codes (see docs/superpowers/specs/phase0-findings.md §2). (1, "text"), - (-2, "divider"), - # Phase-0 unknown codes — pass through as "unknown", never dropped. - (3, "unknown"), # image - (34, "unknown"), # voice + (-2, "divider"), # date separator — NOT a real message (scraper drops it) + (2, "image"), # image / sticker (webp) — file/fileTh are direct URLs + (3, "image_legacy"), # old image format — file is an opaque ID (needs another endpoint) + (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 (10000, "unknown"), # Stringified ints (the upstream API may send either form). ("1", "text"), ("-2", "divider"), + ("2", "image"), + ("3", "image_legacy"), + ("4", "video"), + ("10", "system"), ("34", "unknown"), ], ) diff --git a/yunke-server/tests/test_scrape_wechat_messages.py b/yunke-server/tests/test_scrape_wechat_messages.py index cd17dfa..dbeb1fa 100644 --- a/yunke-server/tests/test_scrape_wechat_messages.py +++ b/yunke-server/tests/test_scrape_wechat_messages.py @@ -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["raw_json"] == MESSAGE_RECORDS_PAGE1[0] - # m2 — image / chatroom / outgoing. + # m2 — image (legacy) / chatroom / outgoing. assert r2["msg_id"] == "1000002" assert r2["room_id"] == "room12345@chatroom" assert r2["ori_talker"] == "room12345@chatroom" 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["file_url"] == "https://cdn.example.com/img2.jpg" assert r2["file_th_url"] == "https://cdn.example.com/img2_th.jpg" diff --git a/yunke-server/tests/test_yunke_client.py b/yunke-server/tests/test_yunke_client.py index 4f036e6..53ba5b2 100644 --- a/yunke-server/tests/test_yunke_client.py +++ b/yunke-server/tests/test_yunke_client.py @@ -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 + 401-relogin behaviour without touching the real CRM. -Phase-0 caveat: the real ``pcLogin`` request body / password encryption / -response shape were not captured. The mocks here encode our documented -assumptions (see ``app/session/login.py``): response JSON has a ``data`` -object with ``userid`` + ``company`` fields. When the real shapes are -confirmed, only the parsing inside ``pc_login`` needs to change — these +Phase-0 caveat: the real ``pcLogin`` request-body field names and response +shape are still inferences (see ``app/session/login.py``). Password +encryption is now confirmed real RSA (PKCS#1 v1.5) against the +``getSecureKey`` modulus/exponent. The mocks here encode the confirmed +``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. """ @@ -17,6 +19,7 @@ from __future__ import annotations import httpx import pytest import respx +from cryptography.hazmat.primitives.asymmetric import padding, rsa from app.session.login import LoginResult 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 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() async def client(): @@ -43,9 +65,7 @@ async def client(): def _stub_login_endpoints() -> tuple[respx.Route, respx.Route]: """Register the getSecureKey + pcLogin mocks used across tests.""" secure = respx.get(f"{BASE}/newuc/login/getSecureKey").mock( - return_value=httpx.Response( - 200, json={"data": {"modulus": "00ab", "exponent": "010001"}} - ) + return_value=httpx.Response(200, json={"data": _TEST_SECURE_KEY}) ) login = respx.post(f"{BASE}/usercenter/login/pcLogin").mock( 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(): - """Phase 0: encrypt_password must be deterministic and non-trivial but is - NOT the real RSA scheme. It must accept any secure-key shape and return a - str so the login flow is end-to-end runnable in tests. +def test_encrypt_password_rsa_round_trips(): + """Real RSA: encrypt_password must produce a hex PKCS#1 v1.5 ciphertext + that the matching private key decrypts back to the original password. + + ``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 - out = encrypt_password("hunter2", {"modulus": "00ab", "exponent": "010001"}) - assert isinstance(out, str) - assert out # non-empty - # Deterministic for the same inputs (so tests / debugging are stable). - assert encrypt_password("hunter2", {"modulus": "00ab"}) == out + cipher_hex = encrypt_password("hunter2", _TEST_SECURE_KEY) + + # Output contract: a hex string (yunke convention; see phase0-findings §1). + assert isinstance(cipher_hex, str) + 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"})