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
25 changes: 21 additions & 4 deletions src/willow/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,25 @@ def verify_signature(

def detect_algorithm_from_did(did: str) -> SignatureAlgorithm:
"""
Detect signature algorithm from DID.
Detect signature algorithm from a *legacy* DID string.

.. deprecated::
Willow DIDs are now self-certifying and no longer embed the key
algorithm in the string::

did:willow:z<base58btc(SHA3-256(multicodec || pubkey))>

The algorithm is therefore **not recoverable** from the DID and this
helper returns ``"Ed25519"`` for every self-certifying id. Do not use
it to pick a signing algorithm: pass the algorithm explicitly instead
(see ``sign_request`` and ``Client.set_identity``). It is retained only
to parse the retired ``did:willow:<alg>:<pubkey>`` format.

Args:
did: The DID string

Returns:
The detected algorithm
The detected algorithm (``"Ed25519"`` for any self-certifying id)
"""
if "ed25519" in did.lower():
return "Ed25519"
Expand All @@ -373,7 +385,8 @@ def sign_request(
private_key_hex: str,
public_key_id: str,
method: str,
path: str
path: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> Dict[str, str]:
"""
Sign an HTTP request for per-request authentication.
Expand All @@ -387,13 +400,17 @@ def sign_request(
public_key_id: Public key ID from DID document
method: HTTP method (e.g., "GET", "POST")
path: API path (e.g., "/data/my-subgrove/my-key")
algorithm: Signature algorithm of the identity ("Ed25519" or
"secp256k1"). Must be supplied by the caller because the
self-certifying DID no longer encodes it; the algorithm cannot be
recovered from the DID string. Defaults to "Ed25519" for backward
compatibility with the SDK's Ed25519-first identities.

Returns:
Dictionary of authentication headers to include in the request
"""
timestamp = int(time.time())
message = f"{method}:{path}:{timestamp}"
algorithm = detect_algorithm_from_did(did)
signature = sign_challenge(message, private_key_hex, algorithm)
return {
"X-DID": did,
Expand Down
21 changes: 16 additions & 5 deletions src/willow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
HealthStatus,
RetryConfig,
)
from .auth import sign_request
from .auth import sign_request, SignatureAlgorithm
from .errors import (
WillowError,
AuthenticationError,
Expand Down Expand Up @@ -872,6 +872,7 @@ def __init__(
self._did: Optional[str] = None
self._private_key: Optional[str] = None
self._public_key_id: Optional[str] = None
self._algorithm: SignatureAlgorithm = "Ed25519"

# Configure proof verification if options provided
if proof_verification_options:
Expand Down Expand Up @@ -976,7 +977,7 @@ async def _call_validator() -> Dict[str, Any]:
if self.is_authenticated():
headers = sign_request(
self._did, self._private_key, self._public_key_id,
"POST", path,
"POST", path, self._algorithm,
)
url = f"{self.api_url}{path}"
resp = await self._http.post(url, json=body, headers=headers)
Expand All @@ -999,7 +1000,7 @@ async def _call_indexer(info: IndexerInfo) -> Dict[str, Any]:
if self.is_authenticated():
headers = sign_request(
self._did, self._private_key, self._public_key_id,
"POST", path,
"POST", path, self._algorithm,
)
endpoint = info.effective_query_endpoint().rstrip("/")
url = f"{endpoint}{path}"
Expand Down Expand Up @@ -1091,7 +1092,8 @@ def set_identity(
self,
did: str,
private_key_hex: str,
public_key_id: str
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> None:
"""Set identity for per-request authentication.

Expand All @@ -1102,10 +1104,17 @@ def set_identity(
did: DID to authenticate as
private_key_hex: Hex-encoded private key
public_key_id: Public key ID from DID document
algorithm: Signature algorithm of this identity ("Ed25519" or
"secp256k1"). Willow DIDs are self-certifying and no longer
encode the algorithm in the string, so it cannot be inferred
from ``did`` — pass "secp256k1" for Ethereum/wallet identities
so per-request auth is signed with the right algorithm.
Defaults to "Ed25519" (the SDK's default identity type).
"""
self._did = did
self._private_key = private_key_hex
self._public_key_id = public_key_id
self._algorithm = algorithm

def is_authenticated(self) -> bool:
"""Check if client has identity set for per-request signing."""
Expand All @@ -1116,6 +1125,7 @@ def clear_identity(self) -> None:
self._did = None
self._private_key = None
self._public_key_id = None
self._algorithm = "Ed25519"

def register_computed_fields(
self,
Expand Down Expand Up @@ -1265,7 +1275,8 @@ async def _request(
headers = {}
if authenticated and self.is_authenticated():
headers = sign_request(
self._did, self._private_key, self._public_key_id, method, path
self._did, self._private_key, self._public_key_id, method, path,
self._algorithm,
)

try:
Expand Down
46 changes: 29 additions & 17 deletions src/willow/consensus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Optional, Dict, Any, Tuple
import logging

from ..auth import sign_message, detect_algorithm_from_did
from ..auth import sign_message, SignatureAlgorithm
from .types import (
RegisterDidTx, RegisterSubgroveTx, TransferTx, DataStoreTx,
StoreFileManifestTx, DeleteFileManifestTx, DeregisterSubgroveTx,
Expand Down Expand Up @@ -61,7 +61,8 @@ async def register_did(
self,
did_document: Dict[str, Any],
private_key: str,
public_key_id: str
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Register a DID on the blockchain.
Expand Down Expand Up @@ -90,7 +91,7 @@ async def register_did(
)

# Sign and broadcast
return await self._sign_and_broadcast("RegisterDid", tx, private_key)
return await self._sign_and_broadcast("RegisterDid", tx, private_key, algorithm)

async def register_subgrove(
self,
Expand All @@ -99,7 +100,8 @@ async def register_subgrove(
owner_did: str,
private_key: str,
public_key_id: str,
mode: Optional[Dict[str, Any]] = None
mode: Optional[Dict[str, Any]] = None,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Register a subgrove (dataset) on the blockchain.
Expand Down Expand Up @@ -127,7 +129,7 @@ async def register_subgrove(
nonce=await self._get_next_nonce(owner_did)
)

return await self._sign_and_broadcast("RegisterSubgrove", tx, private_key)
return await self._sign_and_broadcast("RegisterSubgrove", tx, private_key, algorithm)

async def transfer(
self,
Expand All @@ -136,7 +138,8 @@ async def transfer(
amount: int,
private_key: str,
public_key_id: str,
memo: Optional[str] = None
memo: Optional[str] = None,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Transfer tokens between DIDs.
Expand All @@ -162,7 +165,7 @@ async def transfer(
nonce=await self._get_next_nonce(from_did)
)

return await self._sign_and_broadcast("Transfer", tx, private_key)
return await self._sign_and_broadcast("Transfer", tx, private_key, algorithm)

async def store_data(
self,
Expand All @@ -171,7 +174,8 @@ async def store_data(
data: Dict[str, Any],
owner_did: str,
private_key: str,
public_key_id: str
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Store data on the blockchain.
Expand All @@ -197,7 +201,7 @@ async def store_data(
nonce=await self._get_next_nonce(owner_did)
)

return await self._sign_and_broadcast("DataStore", tx, private_key)
return await self._sign_and_broadcast("DataStore", tx, private_key, algorithm)

async def store_file_manifest(
self,
Expand All @@ -213,6 +217,7 @@ async def store_file_manifest(
owner_did: str,
private_key: str,
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Store a file manifest on the blockchain.
Expand Down Expand Up @@ -250,7 +255,7 @@ async def store_file_manifest(
nonce=await self._get_next_nonce(owner_did),
)

return await self._sign_and_broadcast("StoreFileManifest", tx, private_key)
return await self._sign_and_broadcast("StoreFileManifest", tx, private_key, algorithm)

async def delete_file_manifest(
self,
Expand All @@ -259,6 +264,7 @@ async def delete_file_manifest(
owner_did: str,
private_key: str,
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Delete a file manifest from the blockchain.
Expand All @@ -282,14 +288,15 @@ async def delete_file_manifest(
nonce=await self._get_next_nonce(owner_did),
)

return await self._sign_and_broadcast("DeleteFileManifest", tx, private_key)
return await self._sign_and_broadcast("DeleteFileManifest", tx, private_key, algorithm)

async def deregister_subgrove(
self,
subgrove_id: str,
owner_did: str,
private_key: str,
public_key_id: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""
Deregister (delete) a subgrove. Remaining funding is refunded to the owner.
Expand All @@ -311,7 +318,7 @@ async def deregister_subgrove(
nonce=await self._get_next_nonce(owner_did),
)

return await self._sign_and_broadcast("DeregisterSubgrove", tx, private_key)
return await self._sign_and_broadcast("DeregisterSubgrove", tx, private_key, algorithm)

async def get_transaction_status(self, tx_hash: str) -> TransactionStatus:
"""
Expand Down Expand Up @@ -393,14 +400,19 @@ async def _sign_and_broadcast(
self,
tx_type: str,
transaction: Transaction,
private_key: str
private_key: str,
algorithm: SignatureAlgorithm = "Ed25519",
) -> BroadcastResult:
"""Sign a transaction and broadcast it."""
"""Sign a transaction and broadcast it.

``algorithm`` selects the signing algorithm. It must be supplied by the
caller because the self-certifying DID no longer encodes it; it cannot
be recovered from the transaction's ``owner_did``.
"""
# Create canonical message for signing
sign_message_text = create_sign_message(tx_type, transaction)

# Detect signature algorithm and sign
algorithm = detect_algorithm_from_did(getattr(transaction, 'owner_did', ''))

# Sign with the identity's algorithm
signature_hex = sign_message(sign_message_text, private_key, algorithm)

# Update transaction with signature
Expand Down
85 changes: 85 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
detect_algorithm_from_did,
generate_did,
sign_challenge,
sign_request,
verify_signature,
)

Expand Down Expand Up @@ -247,6 +248,90 @@ def test_detect_default(self):
assert detect_algorithm_from_did("did:willow:unknown:abc123") == "Ed25519"
assert detect_algorithm_from_did("did:willow:test:abc123") == "Ed25519"

def test_self_certifying_did_is_not_detectable(self):
"""Self-certifying DIDs do not encode the algorithm.

This documents *why* the algorithm can no longer be parsed from the
DID: a real secp256k1 self-certifying id is (mis)detected as Ed25519.
Callers must therefore pass the algorithm explicitly to ``sign_request``
rather than relying on this helper.
"""
secp_did = generate_did("secp256k1")["did"]
assert secp_did.startswith("did:willow:z")
# The truth is secp256k1, but the string carries no algorithm marker.
assert detect_algorithm_from_did(secp_did) == "Ed25519"


class TestSignRequest:
"""Test per-request auth signing selects the correct algorithm.

Willow DIDs are self-certifying (``did:willow:z...``) and no longer encode
the key algorithm, so ``sign_request`` takes the algorithm explicitly.
"""

@staticmethod
def _message_for(headers, method, path):
return f"{method}:{path}:{headers['X-Timestamp']}"

def test_secp256k1_identity_signs_with_secp256k1(self):
"""A secp256k1 identity must produce a signature that verifies as
secp256k1 (the regression the self-certifying migration introduced)."""
info = generate_did("secp256k1")
method, path = "POST", "/data/my-subgrove/my-key"

headers = sign_request(
info["did"],
info["private_key"],
info["public_key_id"],
method,
path,
"secp256k1",
)
message = self._message_for(headers, method, path)

# Verifies under secp256k1 (the true algorithm)...
assert verify_signature(
message, headers["X-Signature"], info["public_key"], "secp256k1"
)
# ...and would NOT have verified had it defaulted to Ed25519.
assert not verify_signature(
message, headers["X-Signature"], info["public_key"], "Ed25519"
)
assert headers["X-DID"] == info["did"]
assert headers["X-Public-Key-ID"] == info["public_key_id"]

def test_ed25519_identity_defaults_correctly(self):
"""With no explicit algorithm, Ed25519 (the SDK default) is used."""
info = generate_did("Ed25519")
method, path = "GET", "/data/my-subgrove/my-key"

headers = sign_request(
info["did"], info["private_key"], info["public_key_id"], method, path
)
message = self._message_for(headers, method, path)

assert verify_signature(
message, headers["X-Signature"], info["public_key"], "Ed25519"
)

def test_explicit_ed25519_matches_default(self):
"""Passing ``"Ed25519"`` explicitly is equivalent to the default."""
info = generate_did("Ed25519")
method, path = "GET", "/x"

headers = sign_request(
info["did"],
info["private_key"],
info["public_key_id"],
method,
path,
"Ed25519",
)
message = self._message_for(headers, method, path)
assert verify_signature(
message, headers["X-Signature"], info["public_key"], "Ed25519"
)


class TestIntegration:
"""Integration tests for auth flow."""
Expand Down
Loading
Loading