Lab: sign something you cannot read
Build a typed payload, watch a guardrail catch a drain, then hand the same guardrail a raw digest and watch it wave the drain through.
The signing lesson said that raw digest signing bypasses your guardrails, and that a 32-byte hash tells you nothing about what it authorises. Both are easy to accept in the abstract and easy to trade away when an integration is blocked. Build it once and the trade becomes concrete.
Real EIP-712 hashes with keccak256, which Python has no stdlib implementation of. This lab substitutes sha256 so it runs anywhere with no installs. The digests will not match a real chain; the structure and the conclusion are identical.
Each step appends to the same script, so every run reprints the sections before it. The expected output shown under each step is the new part; scroll to the bottom of your terminal to find it.
- 1
Build the typed structure and its digest. This is the EIP-712 shape: a domain separator, a hash of the typed struct, and the two combined behind the 0x1901 prefix.
bashmkdir -p /tmp/blindlab && cd /tmp/blindlab cat > typed.py <<'EOF' import hashlib, json h = lambda b: hashlib.sha256(b).digest() DOMAIN = {"name": "DemoDEX", "version": "1", "chainId": 8453, "verifyingContract": "0xDEX0000000000000000000000000000000000dex"} TYPE = "Transfer(address to,uint256 value,uint256 deadline)" domain_separator = lambda d: h(json.dumps(d, sort_keys=True).encode()) struct_hash = lambda m: h(TYPE.encode() + json.dumps(m, sort_keys=True).encode()) digest = lambda d, m: h(b"\x19\x01" + domain_separator(d) + struct_hash(m)) benign = {"to": "0xAlice000000000000000000000000000000alice", "value": 1, "deadline": 1767225600} drain = {"to": "0xMallory00000000000000000000000000mallory", "value": 10**24, "deadline": 1767225600} for name, m in (("benign", benign), ("drain", drain)): print(f"{name:8} to={m['to'][:14]}... value={m['value']:<25} " f"digest={digest(DOMAIN, m).hex()[:32]}...") EOF python3 typed.py - 2
Two payloads, two digests. Reading the typed data, the difference is obvious.
textbenign to=0xAlice0000000... value=1 digest=f7ea1df01073d3017aa7f99520be29be... drain to=0xMallory00000... value=1000000000000000000000000 digest=14aa45ebac6ed98e2a66325486425220... - 3
Now add a guardrail of the kind the Intents API applies: a recipient allowlist and a value cap.
bashcat >> typed.py <<'EOF' MAX_VALUE = 1000 ALLOWED = {"0xAlice000000000000000000000000000000alice"} def guard_typed(m): if m["to"] not in ALLOWED: return f"DENY: recipient {m['to'][:14]}... not on the allowlist" if m["value"] > MAX_VALUE: return f"DENY: value {m['value']} exceeds cap {MAX_VALUE}" return "ALLOW" print("\n=== guardrail with the typed data ===") for name, m in (("benign", benign), ("drain", drain)): print(f" {name:8} {guard_typed(m)}") EOF python3 typed.py - 4
It works exactly as intended. The drain is refused on the recipient before the value is even considered.
text=== guardrail with the typed data === benign ALLOW drain DENY: recipient 0xMallory00000... not on the allowlist - 5
Now give the same guardrail what a raw signing request actually contains: 32 bytes.
bashcat >> typed.py <<'EOF' def guard_digest(d32): # Nothing to inspect. A hash is one-way by construction. return "ALLOW (cannot determine recipient or value from 32 bytes)" print("\n=== the same guardrail with only a digest ===") for name, m in (("benign", benign), ("drain", drain)): print(f" {name:8} {guard_digest(digest(DOMAIN, m))}") EOF python3 typed.py - 6
This is the whole lab. The guardrail that caught the drain a moment ago now allows it, and nothing failed, errored or warned.
text=== the same guardrail with only a digest === benign ALLOW (cannot determine recipient or value from 32 bytes) drain ALLOW (cannot determine recipient or value from 32 bytes) - 7
Finish the job: sign the drain digest without ever seeing what it authorises.
bashopenssl genpkey -algorithm ed25519 -out k.pem python3 -c " import typed open('digest.bin','wb').write(typed.digest(typed.DOMAIN, typed.drain)) " openssl pkeyutl -sign -inkey k.pem -rawin -in digest.bin -out d.sig wc -c < d.sig # a valid 64-byte signature over bytes you never inspected - 8
Clean up.
bashcd /tmp && rm -rf blindlab
Nothing in that sequence was a bug. The hash function did its job, the guardrail ran, the signature is cryptographically valid, and the transfer is authorised. The guardrail simply had nothing to inspect, because a digest is one-way by construction and that is the property it exists to have.
- Guardrails on typed data work because the server can read the fields: recipient, value, chain, verifying contract.
- Guardrails on a raw digest cannot work, in principle rather than in this implementation. No amount of engineering recovers the preimage.
- This is why raw_signing_enabled is off by default, human-set only, and audit-logged as signing_key.raw_digest_sign. It is not a lesser setting, it is a different security model.
If you enable raw signing, you have moved the entire burden of validation to whoever constructs the hash. That is defensible when the flow genuinely requires it, such as nested ERC-1271 or ERC-7739, and it means the caller is now the control.
Rebuild the guardrails somewhere else when you make that trade. Recipient allowlists on the signing key, per-transaction value caps, daily budgets and human approval above a threshold all still apply, and they are what stands between a blind signature and a loss.
A useful habit whenever you meet a signing request: ask what the signer can see. If the answer is 32 bytes, then every control you believe you have is running somewhere else, and you should be able to name where.
Check your understanding
3 questionsWhy can a guardrail not inspect a raw digest?
The guardrail allowed the drain in the second run. What failed?
You enable raw signing for a legitimate ERC-1271 flow. What must you do?