Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -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.0.3
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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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",
]
Expand Down
34 changes: 34 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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"
44 changes: 44 additions & 0 deletions tests/test_delegated.py
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 42 additions & 0 deletions tests/test_multipart.py
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions tests/test_signer.py
Original file line number Diff line number Diff line change
@@ -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"