refactor(V2): IBKR OAuth signer — type tightening + verify-based test

Code review polish:
- _signature_key/_encryption_key typed as RSAPrivateKey | None
- sign() uses assert instead of type: ignore
- test_oauth_signer_signs_with_rsa verifies signature against public key
- Clarifying comments on %3D/%26 manual encoding and Task 3 imports

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 20:12:48 +00:00
parent ae63aaf69a
commit a90c5c4d6f
2 changed files with 36 additions and 10 deletions
+8 -5
View File
@@ -5,8 +5,8 @@ Reference: https://www.interactivebrokers.com/api/doc.html (Self-Service OAuth)
from __future__ import annotations
import base64
import hashlib # noqa: F401
import hmac # noqa: F401
import hashlib # noqa: F401 -- used in Task 3 (DH live session token mint)
import hmac # noqa: F401 -- used in Task 3 (DH live session token mint)
import secrets
import time
from dataclasses import dataclass, field
@@ -14,6 +14,7 @@ from urllib.parse import quote
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
def _percent_encode(value: str) -> str:
@@ -32,6 +33,7 @@ def build_signature_base_string(
f"{_percent_encode(k)}%3D{_percent_encode(v)}"
for k, v in sorted_params
]
# Manual %3D / %26 = double-encoding of '=' and '&' per RFC 5849 §3.4.1.3.2
params_str = "%26".join(encoded_pairs)
return f"{method.upper()}&{_percent_encode(url)}&{params_str}"
@@ -45,8 +47,8 @@ class OAuth1aSigner:
encryption_key_path: str
dh_prime: str # hex string
_signature_key: object = field(default=None, init=False, repr=False)
_encryption_key: object = field(default=None, init=False, repr=False)
_signature_key: RSAPrivateKey | None = field(default=None, init=False, repr=False)
_encryption_key: RSAPrivateKey | None = field(default=None, init=False, repr=False)
_live_session_token: str | None = field(default=None, init=False, repr=False)
_lst_expires_at: float = field(default=0.0, init=False, repr=False)
@@ -62,8 +64,9 @@ class OAuth1aSigner:
def sign(self, method: str, url: str, params: dict[str, str]) -> str:
"""Firma RSA-SHA256 della signature base string. Ritorna base64."""
assert self._signature_key is not None # set in __post_init__
base = build_signature_base_string(method, url, params)
signature = self._signature_key.sign( # type: ignore[attr-defined]
signature = self._signature_key.sign(
base.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),