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
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,29 +93,55 @@ async with WillowClient("http://localhost:3031") as client:

### DID Operations

Willow DIDs are **self-certifying**: the id is derived from the public key, not
chosen. The derivation is:

```
did = "did:willow:z" + base58btc( SHA3-256( multicodec_prefix || public_key ) )
```

where `multicodec_prefix` is `0xED 0x01` for Ed25519 and `0xE7 0x01` for
secp256k1 (hashing the 33-byte compressed key), and the leading `z` is the
multibase base58btc marker. The chain's `RegisterDid` check recomputes this and
rejects any other id.

```python
from willow import generate_did
from willow import generate_did, derive_did

# Ed25519 (default)
did_info = generate_did()

# secp256k1 (Ethereum-compatible)
did_info = generate_did(algorithm="secp256k1")

await client.register_did(did_info["did_document"])
# Derive the DID for an existing public key (hex or bytes) without a keypair:
derived = derive_did("a003201e65e47d578ad9bb17cb1d3590e9f504f55eac6ee40002e3ab9517c49c")
# -> {"did": "did:willow:zDZ1Qqspppayjd9LF3Pkebq64Fa2PuK8zFQDDc11citB2", ...}

# did_info keys: did, private_key, public_key, public_key_id, did_document, algorithm
```

#### Onboarding: pre-fund, then register

Because the id is bound to the key, it is known *before* registration and must
be funded first:

```python
# 1. Pre-fund: a funded account sends >= the registration fee to the derived id.
# (e.g. via the consensus client's transfer)
# 2. Register: the holder registers; the fee is paid from that balance.
await client.register_did(did_info["did_document"])
```

### Authentication (per-request signing)

There is no server-side session. Each authenticated request is signed locally with the identity you set via `client.set_identity(...)`. The call is synchronous.

```python
client.set_identity(
did="did:willow:ed25519:abc123",
private_key_hex="your_private_key_hex",
public_key_id="did:willow:ed25519:abc123#key-1",
did=did_info["did"],
private_key_hex=did_info["private_key"],
public_key_id=did_info["public_key_id"],
)

if client.is_authenticated():
Expand Down
2 changes: 2 additions & 0 deletions src/willow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async def main():
)
from .auth import (
generate_did,
derive_did,
sign_challenge,
verify_signature,
detect_algorithm_from_did,
Expand Down Expand Up @@ -310,6 +311,7 @@ async def main():
"IndexingOperations",
# Auth
"generate_did",
"derive_did",
"sign_challenge",
"verify_signature",
"detect_algorithm_from_did",
Expand Down
136 changes: 129 additions & 7 deletions src/willow/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,119 @@
SignatureAlgorithm = Literal["Ed25519", "secp256k1"]


# ---------------------------------------------------------------------------
# Self-certifying DID derivation.
#
# Willow DIDs are bound to the public key, not chosen. The chain's RegisterDid
# check recomputes this exact derivation and rejects any id that does not match:
#
# did = "did:willow:z" + base58btc( SHA3-256( multicodec_prefix || pubkey ) )
#
# - SHA3-256 is FIPS-202 SHA3-256 (hashlib.sha3_256), NOT Keccak-256.
# - multicodec_prefix: Ed25519 => 0xED 0x01 ; secp256k1 => 0xE7 0x01.
# - secp256k1 hashes the 33-byte COMPRESSED public key.
# - base58btc uses the Bitcoin alphabet; each leading 0x00 byte -> '1'.
# - the literal leading 'z' is the multibase base58btc marker.
# ---------------------------------------------------------------------------

_BASE58BTC_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

_MULTICODEC_PREFIX: Dict[SignatureAlgorithm, bytes] = {
"Ed25519": bytes([0xED, 0x01]),
"secp256k1": bytes([0xE7, 0x01]),
}


def _base58btc_encode(data: bytes) -> str:
"""Encode bytes with the Bitcoin/base58btc alphabet.

Each leading 0x00 byte is preserved as a leading '1', matching the
canonical base58check/base58btc convention.
"""
num = int.from_bytes(data, "big")
encoded = ""
while num > 0:
num, remainder = divmod(num, 58)
encoded = _BASE58BTC_ALPHABET[remainder] + encoded
# Preserve leading zero bytes as leading '1's.
leading_zeros = 0
for byte in data:
if byte == 0:
leading_zeros += 1
else:
break
return "1" * leading_zeros + encoded


def _secp256k1_compress(public_key_bytes: bytes) -> bytes:
"""Normalise a secp256k1 public key to its 33-byte compressed form.

Accepts the SDK's 64-byte uncompressed form (X || Y, no prefix), the
65-byte SEC1 uncompressed form (0x04 || X || Y), or an already-compressed
33-byte key (0x02/0x03 || X).
"""
if len(public_key_bytes) == 33 and public_key_bytes[0] in (0x02, 0x03):
return public_key_bytes
if len(public_key_bytes) == 65 and public_key_bytes[0] == 0x04:
body = public_key_bytes[1:]
elif len(public_key_bytes) == 64:
body = public_key_bytes
else:
raise ValueError(
"secp256k1 public key must be 64-byte uncompressed (X||Y), "
"65-byte SEC1 (0x04||X||Y), or 33-byte compressed"
)
x = body[:32]
y = body[32:]
prefix = 0x02 if (y[-1] & 1) == 0 else 0x03
return bytes([prefix]) + x


def _did_key_material(public_key_bytes: bytes, algorithm: SignatureAlgorithm) -> bytes:
"""Return the key bytes that are hashed (after the multicodec prefix)."""
if algorithm == "Ed25519":
if len(public_key_bytes) != 32:
raise ValueError("Ed25519 public key must be 32 bytes")
return public_key_bytes
if algorithm == "secp256k1":
return _secp256k1_compress(public_key_bytes)
raise ValueError(f"Unsupported algorithm: {algorithm}")


def derive_did(
public_key: Union[bytes, str],
algorithm: SignatureAlgorithm = "Ed25519",
) -> Dict[str, str]:
"""Derive the self-certifying Willow DID for a public key.

The id is bound to the key, so anyone holding the public key can compute
it (this is what enables the pre-fund step of onboarding: fund the derived
id *before* it is registered). The chain recomputes this exact value.

Args:
public_key: The public key as raw ``bytes`` or a hex string. Ed25519
is the 32-byte key; secp256k1 may be 64-byte uncompressed (the
SDK's wire form), 65-byte SEC1, or 33-byte compressed (it is
normalised to compressed before hashing).
algorithm: "Ed25519" or "secp256k1".

Returns:
``{"did": ..., "public_key_id": "{did}#key-1"}``
"""
if isinstance(public_key, str):
public_key = bytes.fromhex(public_key)

try:
prefix = _MULTICODEC_PREFIX[algorithm]
except KeyError:
raise ValueError(f"Unsupported algorithm: {algorithm}")

material = _did_key_material(public_key, algorithm)
digest = hashlib.sha3_256(prefix + material).digest()
did = "did:willow:z" + _base58btc_encode(digest)
return {"did": did, "public_key_id": f"{did}#key-1"}


# ---------------------------------------------------------------------------
# secp256k1 helpers backed by `cryptography`. Formats match the Willow
# wire protocol:
Expand Down Expand Up @@ -86,18 +199,25 @@ def _secp256k1_verify_prehashed(

def generate_did(algorithm: SignatureAlgorithm = "Ed25519") -> Dict[str, Union[str, DidDocument]]:
"""
Generate a new DID with keypair.
Generate a new keypair and derive its self-certifying Willow DID.

The DID is *derived* from the public key (see ``derive_did``); it is not
chosen. Because the id is bound to the key, onboarding is a two-step
bootstrap: a funded account must transfer >= the registration fee to the
derived ``did`` *first*, then the holder registers the DID document (the
fee is paid from that pre-funded balance).

Args:
algorithm: Signature algorithm to use ("Ed25519" or "secp256k1")

Returns:
Dictionary containing:
- did: The generated DID string
- did: The derived, self-certifying DID string
- private_key: Hex-encoded private key
- public_key: Hex-encoded public key
- public_key_id: Public key identifier
- public_key_id: Public key identifier ("{did}#key-1")
- did_document: The DID document
- algorithm: The signature algorithm
"""
if algorithm == "Ed25519":
# Generate Ed25519 keypair using cryptography library
Expand All @@ -122,10 +242,12 @@ def generate_did(algorithm: SignatureAlgorithm = "Ed25519") -> Dict[str, Union[s
else:
raise ValueError(f"Unsupported algorithm: {algorithm}")

# Create DID
did_suffix = public_key_bytes[:8].hex()
did = f"did:willow:{algorithm.lower()}:{did_suffix}"
public_key_id = f"{did}#key-1"
# Derive the self-certifying DID from the public key. The id is bound to
# the key (not chosen): the chain's RegisterDid check recomputes this exact
# value and rejects anything else. See ``derive_did`` for the derivation.
derived = derive_did(public_key_bytes, algorithm)
did = derived["did"]
public_key_id = derived["public_key_id"]

# Create DID document
did_document = DidDocument(
Expand Down
13 changes: 12 additions & 1 deletion src/willow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,8 +1063,19 @@ async def _call_indexer(info: IndexerInfo) -> Dict[str, Any]:
async def register_did(self, did_document: DidDocument) -> DidDocument:
"""Register a DID document.

Willow DIDs are self-certifying: the id is derived from the public key
(see ``generate_did`` / ``derive_did``), not chosen. Because the id is
known before registration, onboarding is a two-step bootstrap:

1. Pre-fund: a funded account transfers >= the registration fee to the
derived ``did_document.id`` (e.g. via the consensus client's
``transfer``).
2. Register: the holder calls this method; the fee is paid from that
pre-funded balance.

Args:
did_document: DID document to register
did_document: DID document to register (its ``id`` must be the
self-certifying DID derived from the key).

Returns:
Registered DID document
Expand Down
13 changes: 10 additions & 3 deletions src/willow/consensus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,19 @@ async def register_did(
) -> BroadcastResult:
"""
Register a DID on the blockchain.


The chain's RegisterDid check requires ``did_document['id']`` to be the
self-certifying id derived from the public key (see ``derive_did``); a
chosen or legacy-format id is rejected. Since the id is bound to the
key, it must be pre-funded before registration: transfer >= the
registration fee to the derived id first (see ``transfer``), then call
this method so the fee is paid from that balance.

Args:
did_document: DID document to register
did_document: DID document to register (self-certifying ``id``)
private_key: Private key for signing (hex-encoded)
public_key_id: Public key identifier in the DID document

Returns:
BroadcastResult with transaction status
"""
Expand Down
60 changes: 55 additions & 5 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from willow.auth import (
derive_did,
detect_algorithm_from_did,
generate_did,
sign_challenge,
Expand All @@ -17,8 +18,8 @@ class TestGenerateDid:
def test_generate_ed25519_did(self):
"""Test Ed25519 DID generation."""
result = generate_did("Ed25519")
assert result["did"].startswith("did:willow:ed25519:")

assert result["did"].startswith("did:willow:z")
assert len(result["private_key"]) == 64 # 32 bytes hex
assert len(result["public_key"]) == 64 # 32 bytes hex
assert result["public_key_id"] == f"{result['did']}#key-1"
Expand All @@ -33,8 +34,8 @@ def test_generate_ed25519_did(self):
def test_generate_secp256k1_did(self):
"""Test secp256k1 DID generation."""
result = generate_did("secp256k1")
assert result["did"].startswith("did:willow:secp256k1:")

assert result["did"].startswith("did:willow:z")
assert len(result["private_key"]) == 64 # 32 bytes hex
assert len(result["public_key"]) == 128 # 64 bytes hex (uncompressed)
assert result["algorithm"] == "secp256k1"
Expand All @@ -47,12 +48,61 @@ def test_generate_unique_dids(self):
"""Test that generated DIDs are unique."""
did1 = generate_did()
did2 = generate_did()

assert did1["did"] != did2["did"]
assert did1["private_key"] != did2["private_key"]
assert did1["public_key"] != did2["public_key"]


class TestSelfCertifyingDid:
"""Test the self-certifying DID derivation.

did = "did:willow:z" + base58btc( SHA3-256( multicodec_prefix || pubkey ) )
"""

def test_ed25519_acceptance_vector(self):
"""MANDATORY: this exact vector is what the chain's RegisterDid enforces.

If this fails, the derivation is wrong (e.g. Keccak-256 instead of
FIPS-202 SHA3-256, wrong multicodec prefix, or bad base58btc).
"""
public_key_hex = (
"a003201e65e47d578ad9bb17cb1d3590e9f504f55eac6ee40002e3ab9517c49c"
)
expected = "did:willow:zDZ1Qqspppayjd9LF3Pkebq64Fa2PuK8zFQDDc11citB2"

derived = derive_did(public_key_hex, "Ed25519")

assert derived["did"] == expected
assert derived["public_key_id"] == f"{expected}#key-1"

def test_derive_accepts_bytes_and_hex(self):
"""derive_did accepts both raw bytes and a hex string."""
public_key_hex = (
"a003201e65e47d578ad9bb17cb1d3590e9f504f55eac6ee40002e3ab9517c49c"
)
from_hex = derive_did(public_key_hex, "Ed25519")
from_bytes = derive_did(bytes.fromhex(public_key_hex), "Ed25519")
assert from_hex == from_bytes

def test_did_is_derived_from_key(self):
"""The generated DID must equal the derivation of its own public key."""
result = generate_did("Ed25519")
assert result["did"] == derive_did(result["public_key"], "Ed25519")["did"]

def test_secp256k1_did_is_derived_from_compressed_key(self):
"""secp256k1 DID is derived from the 33-byte compressed public key."""
result = generate_did("secp256k1")
# generate_did stores the 64-byte uncompressed key; derive_did must
# normalise it to compressed and reproduce the same id.
assert result["did"] == derive_did(result["public_key"], "secp256k1")["did"]
assert result["did"].startswith("did:willow:z")

def test_unsupported_algorithm_raises(self):
with pytest.raises(ValueError, match="Unsupported algorithm"):
derive_did("aa" * 32, "RSA")


class TestSignChallenge:
"""Test challenge signing."""

Expand Down
Loading