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
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.
+2 -2
View File
@@ -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.
"""
+77 -44
View File
@@ -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": "<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.
* 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": "<hex>"}
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": "<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.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": <username>, "password": <encrypt_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": <username>, "password": <encrypt_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,
+21 -18
View File
@@ -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
+15 -5
View File
@@ -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"),
],
)
@@ -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"
+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 +
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"})