From b24ebf9e9cfd07174443fad8c1da66fad42dd26f Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 2 Jul 2026 17:38:13 +0700 Subject: [PATCH] feat: derive self-certifying Willow DIDs The chain's RegisterDid check now requires new DID ids to be self-certifying, so the old formats this SDK emitted (did:willow::) are rejected on-chain and SDK-driven registration fails. Derive the id from the public key instead: did = "did:willow:z" + base58btc(SHA3-256(multicodec_prefix || pubkey)) - FIPS-202 SHA3-256 (hashlib.sha3_256), not Keccak-256. - multicodec prefix 0xED01 (Ed25519) / 0xE701 (secp256k1); secp256k1 hashes the 33-byte compressed key (uncompressed input is normalised). - base58btc (Bitcoin alphabet), leading 0x00 bytes -> '1', with the multibase 'z' marker. Add derive_did() so a DID can be computed from a public key alone, which enables the pre-fund step of onboarding, and keep generate_did()'s return shape unchanged (the id is just derived now). Document the two-step bootstrap (pre-fund the derived id, then register) on the register_did paths and in the README. Add a unit test asserting the mandatory Ed25519 acceptance vector. Co-Authored-By: Claude Fable 5 --- README.md | 36 +++++++-- src/willow/__init__.py | 2 + src/willow/auth.py | 136 +++++++++++++++++++++++++++++++-- src/willow/client.py | 13 +++- src/willow/consensus/client.py | 13 +++- tests/test_auth.py | 60 +++++++++++++-- 6 files changed, 239 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 6d06493..bd880e5 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,20 @@ 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() @@ -102,20 +114,34 @@ 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(): diff --git a/src/willow/__init__.py b/src/willow/__init__.py index 33d51f5..a6daac8 100644 --- a/src/willow/__init__.py +++ b/src/willow/__init__.py @@ -57,6 +57,7 @@ async def main(): ) from .auth import ( generate_did, + derive_did, sign_challenge, verify_signature, detect_algorithm_from_did, @@ -310,6 +311,7 @@ async def main(): "IndexingOperations", # Auth "generate_did", + "derive_did", "sign_challenge", "verify_signature", "detect_algorithm_from_did", diff --git a/src/willow/auth.py b/src/willow/auth.py index 831e404..80effe5 100644 --- a/src/willow/auth.py +++ b/src/willow/auth.py @@ -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: @@ -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 @@ -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( diff --git a/src/willow/client.py b/src/willow/client.py index d3355ba..ef33074 100644 --- a/src/willow/client.py +++ b/src/willow/client.py @@ -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 diff --git a/src/willow/consensus/client.py b/src/willow/consensus/client.py index 27bd229..0d66fcb 100644 --- a/src/willow/consensus/client.py +++ b/src/willow/consensus/client.py @@ -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 """ diff --git a/tests/test_auth.py b/tests/test_auth.py index 629dae0..b0941a9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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, @@ -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" @@ -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" @@ -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."""