Two problems, one root cause: the identity layer produces an identifier that does not commit to the key it claims to represent.
The truncation
pubkey_b64 = self.verify_key.encode(
encoder=nacl.encoding.Base64Encoder
).decode("utf-8")
return f"did:ecn:{pubkey_b64[:20]}"
core/identity.py:45-48
An Ed25519 public key is 32 bytes → 44 base64 characters. Slicing to 20 characters keeps 120 bits, and — more to the point — makes the DID a prefix of the key rather than a commitment to it. Two consequences:
- Collisions are grindable. Finding another keypair whose base64 public key shares a 20-char prefix is a ~2^120 search, which is genuinely hard, so this is not the immediate break. But the DID is used as a dictionary key for neighbours (
core/memory.py:49, neighbor_data[node_id]), and a truncated identifier in a routing table is the kind of thing that becomes load-bearing later. Hash the full key instead — did:ecn: + base64url(sha256(pubkey))[:32] — so the identifier is a commitment, not a prefix.
- Base64 alphabet.
Base64Encoder emits + and /. Those characters in a DID method-specific-id are not URL-safe and will bite the first time a DID appears in a path or query string. Use URLSafeBase64Encoder.
The missing binding — this is the real bug
verify_key = nacl.signing.VerifyKey(
public_key_b64.encode("utf-8"),
encoder=nacl.encoding.Base64Encoder
)
signature = base64.b64decode(signature_b64)
verify_key.verify(message.encode("utf-8"), signature)
return True
core/identity.py:96-102
The function takes the public key as an argument. It answers "is this signature valid under this key", which is a tautology when the attacker supplies both. It never answers the only question that matters: "does this key belong to the node that claims to be did:ecn:XYZ?"
Trigger: a node claims sender_id = "did:ecn:AAAA..." (the DID of a trusted high-reputation agent), attaches its own freshly generated public key, and signs its own message.
Observed: verify_signature(attacker_pubkey, msg, attacker_sig) returns True. The message is accepted as coming from the impersonated node.
Expected: verification recomputes the DID from public_key_b64 and rejects the message unless it equals the claimed DID.
The one-line version of the fix:
def verify_signature(public_key_b64, message, signature_b64, expected_did=None) -> bool:
...
if expected_did is not None and did_from_pubkey(public_key_b64) != expected_did:
return False
with did_from_pubkey factored out of generate_did so both sides derive the DID identically.
Two smaller notes
except Exception as e: print(...) at core/identity.py:103-105 swallows every failure into a False plus a stdout line — including a malformed base64 key, which is an input-validation error and not a signature failure. In a distributed system, "invalid signature" and "your peer sent garbage" want different handling.
- More structurally: nothing in
infra/ calls any of this. NetworkAdapter ships plain JSON, and ISEPClient never signs a Beacon or a TaskResult. The identity module is well-written and completely unwired — which makes the security posture of the whole system "trust the LAN". If that is intentional for the research prototype, it deserves an explicit note in the README so nobody deploys it; if it is not, wiring Identity.sign into NetworkAdapter.send is a contained change.
Two problems, one root cause: the identity layer produces an identifier that does not commit to the key it claims to represent.
The truncation
core/identity.py:45-48An Ed25519 public key is 32 bytes → 44 base64 characters. Slicing to 20 characters keeps 120 bits, and — more to the point — makes the DID a prefix of the key rather than a commitment to it. Two consequences:
core/memory.py:49,neighbor_data[node_id]), and a truncated identifier in a routing table is the kind of thing that becomes load-bearing later. Hash the full key instead —did:ecn:+base64url(sha256(pubkey))[:32]— so the identifier is a commitment, not a prefix.Base64Encoderemits+and/. Those characters in a DID method-specific-id are not URL-safe and will bite the first time a DID appears in a path or query string. UseURLSafeBase64Encoder.The missing binding — this is the real bug
core/identity.py:96-102The function takes the public key as an argument. It answers "is this signature valid under this key", which is a tautology when the attacker supplies both. It never answers the only question that matters: "does this key belong to the node that claims to be
did:ecn:XYZ?"Trigger: a node claims
sender_id = "did:ecn:AAAA..."(the DID of a trusted high-reputation agent), attaches its own freshly generated public key, and signs its own message.Observed:
verify_signature(attacker_pubkey, msg, attacker_sig)returnsTrue. The message is accepted as coming from the impersonated node.Expected: verification recomputes the DID from
public_key_b64and rejects the message unless it equals the claimed DID.The one-line version of the fix:
with
did_from_pubkeyfactored out ofgenerate_didso both sides derive the DID identically.Two smaller notes
except Exception as e: print(...)atcore/identity.py:103-105swallows every failure into aFalseplus a stdout line — including a malformed base64 key, which is an input-validation error and not a signature failure. In a distributed system, "invalid signature" and "your peer sent garbage" want different handling.infra/calls any of this.NetworkAdapterships plain JSON, andISEPClientnever signs aBeaconor aTaskResult. The identity module is well-written and completely unwired — which makes the security posture of the whole system "trust the LAN". If that is intentional for the research prototype, it deserves an explicit note in the README so nobody deploys it; if it is not, wiringIdentity.signintoNetworkAdapter.sendis a contained change.