diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 1726cbd..6ca14b9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1,4 +1,5 @@ import asyncio +import json import logging import time from collections.abc import Callable @@ -10,7 +11,7 @@ from tplus.client.base import BaseClient, ClientSettings from tplus.exceptions import MissingClientUserError from tplus.model.types import UserPublicKey -from tplus.utils.user import User +from tplus.utils.user import DelegatedUser, User class FakeAuthBackend: @@ -125,6 +126,39 @@ def test_auth_has_lock(self): auth = Auth() assert isinstance(auth.lock, asyncio.Lock) + @pytest.mark.anyio + async def test_delegated_user_authenticates_target_with_additional_signer(self): + account = User() + signer = User() + delegated = DelegatedUser(account.public_key, signer) + auth_payloads: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == f"/nonce/{account.public_key}": + return httpx.Response(200, json={"value": "nonce"}) + if request.url.path == "/auth": + auth_payloads.append(json.loads(request.content)) + return httpx.Response( + 200, + json={"token": "tok", "expiry_ns": time.time_ns() + 3_600_000_000_000}, + ) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + httpx_client = httpx.AsyncClient(base_url="http://test", transport=transport) + client = AuthenticatedClient( + base_url="http://test", default_user=delegated, client=httpx_client + ) + + await client._authenticate() + + payload = auth_payloads[0] + assert payload["user_id"] == account.public_key + assert payload["signature"] == [] + assert payload["additional_signers"][0]["signer"] == {"Ed25519": signer.public_key_vec} + signer.vk.verify(bytes(payload["additional_signers"][0]["signature"]), b"nonce") + await client.close() + class TestBaseClient: def _make_client(self, **kwargs) -> BaseClient: diff --git a/tests/utils/test_delegated_orders.py b/tests/utils/test_delegated_orders.py new file mode 100644 index 0000000..3b0fe29 --- /dev/null +++ b/tests/utils/test_delegated_orders.py @@ -0,0 +1,55 @@ +from tplus.model.asset_identifier import AssetIdentifier +from tplus.model.limit_order import GTC +from tplus.utils.limit_order import create_limit_order_ob_request_payload +from tplus.utils.replace_order import create_replace_order_ob_request_payload +from tplus.utils.signing import create_cancel_order_ob_request_payload +from tplus.utils.user import DelegatedUser, User + + +def test_delegated_create_order_uses_additional_signer(): + account = User() + signer = User() + delegated = DelegatedUser(account.public_key, signer) + + request = create_limit_order_ob_request_payload( + quantity=100, + price=200, + side="Buy", + signer=delegated, + book_quantity_decimals=2, + book_price_decimals=2, + asset_identifier=AssetIdentifier("1"), + order_id="order-1", + time_in_force=GTC(post_only=True), + ) + + assert request.order.signer == account.public_key + assert request.signature == [] + assert request.additional_signers[0].signer.model_dump() == {"Ed25519": signer.public_key_vec} + signer.vk.verify( + bytes(request.additional_signers[0].signature), + request.order.signable_part().encode(), + ) + + +def test_delegated_replace_and_cancel_use_target_account_without_master_signature(): + account = User() + delegated = DelegatedUser(account.public_key, User()) + asset = AssetIdentifier("1") + + replace = create_replace_order_ob_request_payload( + original_order_id="order-1", + asset_identifier=asset, + signer=delegated, + new_price=300, + ) + cancel = create_cancel_order_ob_request_payload( + signer=delegated, + asset_identifier=asset, + order_id="order-1", + ) + + assert replace.user_id == account.public_key + assert replace.signature == [] + assert cancel.cancel.signer == account.public_key + assert cancel.signature == [] diff --git a/tests/utils/test_user.py b/tests/utils/test_user.py index c1c85e3..88b56af 100644 --- a/tests/utils/test_user.py +++ b/tests/utils/test_user.py @@ -2,7 +2,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # type: ignore from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # type: ignore -from tplus.utils.user import LocalUser, User +from tplus.utils.user import DelegatedUser, LocalUser, User class TestUser: @@ -61,3 +61,21 @@ def test_local_user_unlock_pubkey_mismatch_raises(self, private_key_hex): with pytest.raises(ValueError, match="does not match stored public key"): user.sign("testmessage") + + def test_delegated_user_targets_account_and_emits_additional_signature(self): + account = User() + signer = User() + user = DelegatedUser(account.public_key, signer) + + master_signature, additional = user.signing_parts("payload") + + assert user.public_key == account.public_key + assert master_signature == [] + assert len(additional) == 1 + assert additional[0].signer.model_dump() == {"Ed25519": signer.public_key_vec} + signer.vk.verify(bytes(additional[0].signature), b"payload") + + @pytest.mark.parametrize("account", ["short", "zz" * 32]) + def test_delegated_user_rejects_invalid_account_public_key(self, account): + with pytest.raises(ValueError, match="account_public_key"): + DelegatedUser(account, User()) diff --git a/tplus/client/auth.py b/tplus/client/auth.py index 7a6f363..0a3af0f 100644 --- a/tplus/client/auth.py +++ b/tplus/client/auth.py @@ -219,8 +219,7 @@ async def _authenticate(self, user: "User | None" = None) -> None: # NOTE: nonce_value **must** be a `str` here. nonce_value = f"{nonce_data['value']}" if isinstance(nonce_data, dict) else f"{nonce_data}" - signature_bytes = user.sign(nonce_value) - signature_array = list(signature_bytes) + signature_array, additional_signers = user.signing_parts(nonce_value) nonce_value_len = len(nonce_value) self.logger.debug(f"AUTH DEBUG: nonce={nonce_value} (len={nonce_value_len})") @@ -232,6 +231,7 @@ async def _authenticate(self, user: "User | None" = None) -> None: "user_id": user.public_key, "nonce": nonce_value, "signature": signature_array, + "additional_signers": [signer.model_dump(mode="json") for signer in additional_signers], } token_resp = await self._client.post("/auth", json=auth_payload) diff --git a/tplus/client/orderbook.py b/tplus/client/orderbook.py index 7a91c33..6436927 100644 --- a/tplus/client/orderbook.py +++ b/tplus/client/orderbook.py @@ -925,6 +925,56 @@ async def get_multisig_config(self, user: UserType | None = None) -> dict[str, A "thresholds": {"low": 1, "medium": 1, "high": 1}, } + async def add_multisig_signer( + self, + signer: "User", + *, + weight: int = 1, + session_duration_ns: int = (2**64) - 1, + user: "User | None" = None, + ) -> dict[str, Any]: + """Register an Ed25519 additional signer on an account. + + The resolved ``user`` authorizes the high-tier config mutation. + ``signer`` is the key that will subsequently co-sign requests for that + account. + """ + user = self._resolve_user(user=user) + if weight <= 0: + raise ValueError("weight must be greater than zero") + if not 0 <= session_duration_ns <= (2**64) - 1: + raise ValueError("session_duration_ns must fit in a u64") + + nonce_data = await self._request( + "GET", + f"/nonce/{user.public_key}", + requires_auth=False, + user=user, + ) + inner = { + "user": user.public_key, + "oms_nonce": str(nonce_data["value"]), + "ce_nonce": int(nonce_data["ce_nonce"]), + "signer": {"Ed25519": signer.public_key_vec}, + "weight": weight, + "session_duration_ns": session_duration_ns, + } + signing_payload = json.dumps(inner, separators=(",", ":")) + signature, additional_signers = user.signing_parts(signing_payload) + payload = { + "inner": inner, + "signature": signature, + "additional_signers": [ + additional.model_dump(mode="json") for additional in additional_signers + ], + } + return await self._request( + "POST", + "/multisig/add-signer", + json_data=payload, + user=user, + ) + async def stream_orders(self, user: UserType | None = None) -> AsyncIterator[OrderEvent]: """Stream order events for the authenticated user. @@ -1211,11 +1261,11 @@ def _build_transfer_to_subaccount( } self.logger.debug(f"Transfer request: {inner}") signing_payload = json.dumps(inner, separators=(",", ":")) - signature = list(user.sign(signing_payload)) + signature, additional_signers = user.signing_parts(signing_payload) payload = { "inner": inner, "signature": signature, - "additional_signers": [], + "additional_signers": [signer.model_dump(mode="json") for signer in additional_signers], } return payload @@ -1252,11 +1302,11 @@ def _build_close_position_request( self.logger.debug(f"Preparing close position request: {inner}") signing_payload = json.dumps(inner, separators=(",", ":")) - signature = list(user.sign(signing_payload)) + signature, additional_signers = user.signing_parts(signing_payload) payload = { "inner": inner, "signature": signature, - "additional_signers": [], + "additional_signers": [signer.model_dump(mode="json") for signer in additional_signers], } return payload diff --git a/tplus/model/multisig.py b/tplus/model/multisig.py index a1624ee..dee8a9c 100644 --- a/tplus/model/multisig.py +++ b/tplus/model/multisig.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any from pydantic import BaseModel, model_serializer, model_validator diff --git a/tplus/model/order.py b/tplus/model/order.py index 73bee60..7a71b5a 100644 --- a/tplus/model/order.py +++ b/tplus/model/order.py @@ -3,11 +3,12 @@ from enum import Enum from typing import Any, Literal -from pydantic import BaseModel, ValidationError, field_serializer +from pydantic import BaseModel, Field, ValidationError, field_serializer from tplus.model.asset_identifier import AssetIdentifier from tplus.model.limit_order import LimitOrderDetails from tplus.model.market_order import MarketOrderDetails +from tplus.model.multisig import AdditionalSigner from tplus.model.order_trigger import OrderTrigger from tplus.model.types import UserPublicKey @@ -85,6 +86,7 @@ class CreateOrderRequest(BaseModel): order: Order signature: list[int] post_sign_timestamp: int + additional_signers: list[AdditionalSigner] = Field(default_factory=list) class OrderResponse(BaseModel): diff --git a/tplus/utils/limit_order.py b/tplus/utils/limit_order.py index 55b91e7..5920282 100644 --- a/tplus/utils/limit_order.py +++ b/tplus/utils/limit_order.py @@ -47,10 +47,13 @@ def create_limit_order_ob_request_payload( max_trading_fees_rate=(50000 if max_trading_fees_rate is None else max_trading_fees_rate), ) sign_payload_json = order.signable_part() - signature_bytes = signer.sign(sign_payload_json) + signature, additional_signers = signer.signing_parts(sign_payload_json) return CreateOrderRequest( - order=order, signature=list(signature_bytes), post_sign_timestamp=time.time_ns() + order=order, + signature=signature, + post_sign_timestamp=time.time_ns(), + additional_signers=additional_signers, ) diff --git a/tplus/utils/market_order.py b/tplus/utils/market_order.py index d6e118e..71af781 100644 --- a/tplus/utils/market_order.py +++ b/tplus/utils/market_order.py @@ -50,8 +50,11 @@ def create_market_order_ob_request_payload( ) sign_payload_json = order.signable_part() - signature_bytes = signer.sign(sign_payload_json) + signature, additional_signers = signer.signing_parts(sign_payload_json) return CreateOrderRequest( - order=order, signature=list(signature_bytes), post_sign_timestamp=time.time_ns() + order=order, + signature=signature, + post_sign_timestamp=time.time_ns(), + additional_signers=additional_signers, ) diff --git a/tplus/utils/replace_order.py b/tplus/utils/replace_order.py index e6d61f8..e079710 100644 --- a/tplus/utils/replace_order.py +++ b/tplus/utils/replace_order.py @@ -47,12 +47,12 @@ def create_replace_order_ob_request_payload( compact_sign_payload_json = ( sign_payload_json.replace(" ", "").replace("\r", "").replace("\n", "") ) - signature_bytes = signer.sign(compact_sign_payload_json) + signature, _ = signer.signing_parts(compact_sign_payload_json) return ReplaceOrderRequestPayload( request=replace_details, user_id=signer.public_key, asset_id=asset_identifier, - signature=list(signature_bytes), + signature=signature, post_sign_timestamp=time.time_ns(), ) diff --git a/tplus/utils/signing.py b/tplus/utils/signing.py index e2c4ebd..920e2d7 100644 --- a/tplus/utils/signing.py +++ b/tplus/utils/signing.py @@ -22,7 +22,7 @@ def create_cancel_order_ob_request_payload( compact_sign_payload_json = ( sign_payload_json.replace(" ", "").replace("\r", "").replace("\n", "") ) - signature_bytes = signer.sign(compact_sign_payload_json) + signature, _ = signer.signing_parts(compact_sign_payload_json) return CancelOrderRequest( - cancel=cancel, signature=list(signature_bytes), post_sign_timestamp=time.time_ns() + cancel=cancel, signature=signature, post_sign_timestamp=time.time_ns() ) diff --git a/tplus/utils/user/__init__.py b/tplus/utils/user/__init__.py index dfb0e6f..78681b4 100644 --- a/tplus/utils/user/__init__.py +++ b/tplus/utils/user/__init__.py @@ -1,5 +1,5 @@ from tplus.utils.user.manager import UserManager -from tplus.utils.user.model import LocalUser, User +from tplus.utils.user.model import DelegatedUser, LocalUser, User def load_user(name: str | None = None, password: str | None = None) -> User: @@ -29,4 +29,4 @@ def load_user(name: str | None = None, password: str | None = None) -> User: raise ValueError("No default user; please add a user.") -__all__ = ("LocalUser", "User", "UserManager", "load_user") +__all__ = ("DelegatedUser", "LocalUser", "User", "UserManager", "load_user") diff --git a/tplus/utils/user/model.py b/tplus/utils/user/model.py index 76657c0..8bdc11a 100644 --- a/tplus/utils/user/model.py +++ b/tplus/utils/user/model.py @@ -7,6 +7,7 @@ ) from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # type: ignore +from tplus.model.multisig import AdditionalSigner, SignerKey from tplus.model.types import UserPublicKey from tplus.utils.hex import str_to_vec from tplus.utils.user.validate import privkey_to_bytes @@ -112,6 +113,66 @@ def sign(self, payload: str): payload_bytes = payload.encode("utf-8") return self.sk.sign(payload_bytes) + def signing_parts(self, payload: str) -> tuple[list[int], list[AdditionalSigner]]: + """Return the master and additional signatures for a request payload.""" + return list(self.sign(payload)), [] + + +class DelegatedUser(User): + """Act for one account using a registered Ed25519 additional signer. + + ``account_public_key`` identifies the account in request payloads and auth + headers. ``signer`` supplies the co-signature; its private key is never + treated as the account's master key. + """ + + def __init__( + self, + account_public_key: str | UserPublicKey, + signer: User, + sub_account: int | None = None, + ) -> None: + normalized = str(account_public_key).removeprefix("0x").lower() + if len(normalized) != 64: + raise ValueError("account_public_key must be a 32-byte Ed25519 public key") + try: + bytes.fromhex(normalized) + except ValueError as exc: + raise ValueError("account_public_key must be hexadecimal") from exc + + self._account_public_key = UserPublicKey(normalized) + self._signer = signer + self._sub_account = sub_account + + @cached_property + def public_key(self) -> UserPublicKey: + return self._account_public_key + + @cached_property + def public_key_vec(self) -> list[int]: + return str_to_vec(self.public_key) + + def pubkey(self) -> str: + return self.public_key + + def pubkey_vec(self) -> list[int]: + return self.public_key_vec + + def sign(self, payload: str): + """Sign with the registered co-signer key. + + Prefer :meth:`signing_parts` for protocol requests so this signature + is placed in ``additional_signers``, not the master-signature field. + """ + return self._signer.sign(payload) + + def signing_parts(self, payload: str) -> tuple[list[int], list[AdditionalSigner]]: + additional = AdditionalSigner( + signer=SignerKey.ed25519(self._signer.public_key_vec), + signature=list(self._signer.sign(payload)), + ) + return [], [additional] + class LocalUser(User): """A :class:`User` backed by a local encrypted keyfile.