From 108ec64819db50036213514a5689973850ef4aae Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Wed, 1 Jul 2026 16:14:03 +0200 Subject: [PATCH 1/3] ci: enable lint, type-check and test workflow Add a Build workflow running ruff check, mypy --strict and pytest across the supported Python matrix (3.10/3.11/3.12), plus the client/delegated/multipart test suites. Declare typing_extensions as a runtime dependency (the generated SDK imports NotRequired/deprecated from it) and respx as a dev dependency (used by the HTTP-mocking tests). pyproject.toml, tests/ and .github/ are hand-maintained in this repo (the sync from the monorepo overlays only generated code and preserves them), so these gates persist across syncs. The generated SDK is emitted lint- and type-clean by the monorepo generator, so no hand-edits to dfns_sdk/ are needed. --- .github/workflows/build.yaml | 45 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 ++ tests/test_client.py | 34 +++++++++++++++++++++++++++ tests/test_delegated.py | 44 +++++++++++++++++++++++++++++++++++ tests/test_multipart.py | 42 +++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 tests/test_client.py create mode 100644 tests/test_delegated.py create mode 100644 tests/test_multipart.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5395211 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,45 @@ +name: Build + +on: + pull_request: + push: + branches: [main] + +# Least-privilege default token (GitHub's implicit default is not safe to rely on). +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint, type-check & test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Matches requires-python (>=3.10) and the supported-version classifiers. + python-version: ['3.10', '3.11', '3.12'] + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install (with dev extras) + run: python3 -m pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check . + + - name: Type-check (mypy --strict) + run: mypy dfns_sdk + + - name: Test (pytest) + run: pytest diff --git a/pyproject.toml b/pyproject.toml index 145f938..c520987 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ dependencies = [ "httpx>=0.25.0", "cryptography>=41.0.0", + "typing_extensions>=4.5.0", ] authors = [ {name = "Dfns", email = "support@dfns.co"} @@ -32,6 +33,7 @@ authors = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "respx>=0.20.0", "mypy>=1.5.0", "ruff>=0.1.0", ] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..4362c57 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,34 @@ +"""Tests for the regular DfnsClient (read + user-action flows).""" + +import httpx +import respx + +from dfns_sdk import DfnsClient +from dfns_sdk.types import DfnsClientConfig + +BASE_URL = "https://api.test.dfns" + + +def make_client() -> DfnsClient: + return DfnsClient(DfnsClientConfig(auth_token="test-token", base_url=BASE_URL)) + + +@respx.mock +def test_list_wallets_parses_response() -> None: + respx.get(f"{BASE_URL}/wallets").mock( + return_value=httpx.Response(200, json={"items": [{"id": "wa-1"}], "nextPageToken": None}) + ) + + client = make_client() + result = client.wallets.list_wallets() + + assert result["items"][0]["id"] == "wa-1" + + +@respx.mock +def test_auth_header_is_sent() -> None: + route = respx.get(f"{BASE_URL}/wallets").mock(return_value=httpx.Response(200, json={"items": []})) + + make_client().wallets.list_wallets() + + assert route.calls.last.request.headers["authorization"] == "Bearer test-token" diff --git a/tests/test_delegated.py b/tests/test_delegated.py new file mode 100644 index 0000000..398466d --- /dev/null +++ b/tests/test_delegated.py @@ -0,0 +1,44 @@ +"""Tests for the delegated (externally-signed) client: init/complete flow.""" + +import httpx +import respx + +from dfns_sdk import DfnsDelegatedClient +from dfns_sdk.types import DfnsDelegatedClientConfig + +BASE_URL = "https://api.test.dfns" + + +def make_delegated() -> DfnsDelegatedClient: + return DfnsDelegatedClient(DfnsDelegatedClientConfig(auth_token="service-account-token", base_url=BASE_URL)) + + +@respx.mock +def test_create_wallet_init_returns_challenge() -> None: + init = respx.post(f"{BASE_URL}/auth/action/init").mock( + return_value=httpx.Response(200, json={"challengeIdentifier": "ch-1", "challenge": "Y2g"}) + ) + + challenge = make_delegated().wallets.create_wallet_init(body={"network": "EthereumSepolia"}) + + assert challenge["challengeIdentifier"] == "ch-1" + sent = init.calls.last.request + assert b'"userActionHttpPath":"/wallets"' in sent.content + assert b'"userActionHttpMethod":"POST"' in sent.content + + +@respx.mock +def test_create_wallet_complete_uses_user_action_token() -> None: + respx.post(f"{BASE_URL}/auth/action").mock(return_value=httpx.Response(200, json={"userAction": "ua-token"})) + wallet_route = respx.post(f"{BASE_URL}/wallets").mock( + return_value=httpx.Response(200, json={"id": "wa-123", "network": "EthereumSepolia"}) + ) + + wallet = make_delegated().wallets.create_wallet_complete( + body={"network": "EthereumSepolia"}, + signed_challenge={"challengeIdentifier": "ch-1", "firstFactor": {"kind": "Key"}}, + ) + + assert wallet["id"] == "wa-123" + # The actual request must carry the pre-obtained user action token. + assert wallet_route.calls.last.request.headers["x-dfns-useraction"] == "ua-token" diff --git a/tests/test_multipart.py b/tests/test_multipart.py new file mode 100644 index 0000000..432eeb3 --- /dev/null +++ b/tests/test_multipart.py @@ -0,0 +1,42 @@ +"""Tests for multipart/form-data file-upload endpoints.""" + +import hashlib + +import httpx +import respx + +from dfns_sdk import DfnsClient +from dfns_sdk.types import DfnsClientConfig + +BASE_URL = "https://api.test.dfns" + + +class _FakeSigner: + """Duck-typed Signer for the user-action flow these upload endpoints require.""" + + def sign(self, challenge): # type: ignore[no-untyped-def] + return {"kind": "Key", "credentialAssertion": {"credId": "cr-1", "clientData": "x", "signature": "y"}} + + +@respx.mock +def test_submit_onchain_sign_output_sends_multipart() -> None: + respx.post(f"{BASE_URL}/auth/action/init").mock( + return_value=httpx.Response(200, json={"challengeIdentifier": "ch", "challenge": "Y2g"}) + ) + respx.post(f"{BASE_URL}/auth/action").mock(return_value=httpx.Response(200, json={"userAction": "ua"})) + route = respx.post(f"{BASE_URL}/key-stores/ks-1/onchain-sign/output").mock( + return_value=httpx.Response(200, json={}) + ) + + client = DfnsClient(DfnsClientConfig(auth_token="t", base_url=BASE_URL, signer=_FakeSigner())) + file_bytes = b"signed-output-bytes" + client.signers.submit_onchain_sign_output("ks-1", body={"foo": "bar"}, file=file_bytes) + + req = route.calls.last.request + # Multipart content type, a `data` part with the file checksum, and a `file` part. + assert req.headers["content-type"].startswith("multipart/form-data") + body = req.content + assert hashlib.sha256(file_bytes).hexdigest().encode() in body + assert b'name="data"' in body + assert b'name="file"' in body + assert file_bytes in body From 44b2b632134a5b44911fce1bc172a4138bdcd550 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Thu, 2 Jul 2026 00:28:56 +0200 Subject: [PATCH 2/3] test: cover KeySigner user-action challenge signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo-owned tests for dfns_sdk.auth.KeySigner: Ed25519 + ECDSA(P-256) assertion structure (kind/credId/clientData) and signature verification against the public key over the exact clientData bytes, Ed25519 determinism, and app_origin propagation. Generates ephemeral keys in-test (cryptography), no fixtures. NOTE: unsigned (author away) — amend/re-sign on review. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_signer.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_signer.py diff --git a/tests/test_signer.py b/tests/test_signer.py new file mode 100644 index 0000000..5293e7e --- /dev/null +++ b/tests/test_signer.py @@ -0,0 +1,78 @@ +"""Tests for the KeySigner (user-action challenge signing with a private key).""" + +import json + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from dfns_sdk.auth import KeySigner, base64url_decode + +CHALLENGE = { + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "challengeIdentifier": "ch-1", + "supportedCredentialKinds": [], + "allowCredentials": {}, + "rp": {}, + "externalAuthenticationUrl": "", +} + + +def _pem(private_key) -> str: + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + +def test_ed25519_assertion_structure_and_signature() -> None: + key = ed25519.Ed25519PrivateKey.generate() + signer = KeySigner(credential_id="cr-ed25519", private_key=_pem(key)) + + assertion = signer.sign(CHALLENGE) + + assert assertion["kind"] == "Key" + ca = assertion["credentialAssertion"] + assert ca["credId"] == "cr-ed25519" + + # clientData decodes to the WebAuthn-style JSON binding the challenge. + client_data = json.loads(base64url_decode(ca["clientData"])) + assert client_data["type"] == "key.get" + assert client_data["challenge"] == CHALLENGE["challenge"] + assert client_data["crossOrigin"] is False + + # Ed25519 signs the raw clientData; the signature verifies against the public key. + key.public_key().verify(base64url_decode(ca["signature"]), base64url_decode(ca["clientData"])) + + +def test_ecdsa_p256_assertion_signature_verifies() -> None: + key = ec.generate_private_key(ec.SECP256R1()) + signer = KeySigner(credential_id="cr-ec", private_key=_pem(key)) + + ca = signer.sign(CHALLENGE)["credentialAssertion"] + client_data_bytes = base64url_decode(ca["clientData"]) + + # ECDSA signs SHA-256 of the clientData; verify with the matching public key. + key.public_key().verify( + base64url_decode(ca["signature"]), + client_data_bytes, + ec.ECDSA(hashes.SHA256()), + ) + + +def test_ed25519_signing_is_deterministic() -> None: + key = ed25519.Ed25519PrivateKey.generate() + signer = KeySigner(credential_id="cr-ed25519", private_key=_pem(key)) + + first = signer.sign(CHALLENGE)["credentialAssertion"]["signature"] + second = signer.sign(CHALLENGE)["credentialAssertion"]["signature"] + assert first == second + + +def test_app_origin_flows_into_client_data() -> None: + key = ed25519.Ed25519PrivateKey.generate() + signer = KeySigner(credential_id="cr-ed25519", private_key=_pem(key), app_origin="https://custom.example") + + ca = signer.sign(CHALLENGE)["credentialAssertion"] + client_data = json.loads(base64url_decode(ca["clientData"])) + assert client_data["origin"] == "https://custom.example" From 673a2af7ab319e41a06d4a193b93ccf4984c3c4e Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 2 Jul 2026 14:34:00 +0200 Subject: [PATCH 3/3] Update .github/workflows/build.yaml Co-authored-by: James Barrios --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5395211..d0c5e99 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.0.3 with: python-version: ${{ matrix.python-version }} cache: pip