Anti-exfil signing
A signing device picks the nonce in every signature it makes. A compromised device can pick nonces that reveal its key to an attacker, and the signatures still look normal. Anti-exfil signing has the host add its own randomness to each nonce and check that the device used it, so the device can no longer choose the nonce on its own.
This handbook explains two draft BIPs from Avila Labs:
- The protocol (
spec/bip-anti-exfil.md), for BIP340 and ECDSA signatures. - The PSBT fields (
spec/bip-anti-exfil-psbt.md), three input fields that carry the protocol between a wallet and a signing device.
The BIPs are the normative text. This handbook is a guide to them. Where the two differ, the BIPs are right and the handbook needs fixing.
Status
Both are drafts with no BIP number. No cryptographer has reviewed them and there has been no outside audit. Nothing has run on a hardware device yet. The Python code in the repository uses variable-time arithmetic and must not be used with real keys.
Where to start
| If you are | Read |
|---|---|
| Reviewing the cryptography | The protocol, then spec/security-argument.md and What isn’t proved or tested |
| Writing wallet software | The protocol, Host rules, PSBT fields |
| Writing device firmware | The protocol, Device rules, PSBT fields, Test vectors |
| Checking the work | Running the checks and EVIDENCE.md |
Reviewing the drafts lists the questions where comments would help most.
What’s in the repository
| Folder | Contents |
|---|---|
spec/ | The two BIPs, the security argument and the transport analysis |
vectors/ | Test vectors for the protocol and the PSBT fields |
reference/, psbt/ | Reference arithmetic, and a reference host and signer, in Python |
c/ | A C module for libsecp256k1 |
jade/ | A patch series adding the protocol to Blockstream Jade’s sign_psbt |
secp1140/ | The host checks, run against secp256k1 pull request 1140 and secp256k1-zkp |
proof/, validation/ | Proof documents and their checks, and the testing behind EVIDENCE.md |
How a device can leak its key
Nonces
A Schnorr or ECDSA signature uses a secret number, the nonce, that the signer picks. Anyone who knows the nonce of one signature can compute the secret key from it. Two signatures whose nonces have a known relation give the key away in the same way.
So the signer has to keep its nonces secret. The trouble is that only the signer knows how it chose them.
The attack
Suppose a device’s firmware has been changed, at the factory, in the supply chain or by an update. Instead of random or properly derived nonces, it picks nonces the attacker can predict, or nonces that encode bits of the seed.
Every signature it makes is still valid. The transaction pays the right addresses and the wallet accepts it. Once it’s on the chain, the attacker reads the signature and recovers the key. Nobody else can tell anything happened, and that includes the owner.
Dark Skippy showed that a seed can leak in as few as two signatures this way.
Checking signatures doesn’t help
The wallet can check that a signature is valid, but a valid signature with a bad nonce looks the same as one with a good nonce. Deterministic nonces, such as RFC 6979 or BIP 461, make a device checkable if you hold the same key somewhere else and sign the same message there. A watch-only wallet can’t do that.
What the host can do
The host, which is the wallet software the device talks to, can contribute randomness to the nonce:
- The host commits to a random value without revealing it.
- The device commits to its own nonce.
- The host reveals its value, and the device signs with a nonce made from both.
- The host checks that the final nonce really came from both.
The device chose its part before it saw the host’s value, and it can’t change it afterwards. So it can’t steer the final nonce. The one thing left to it is refusing to finish, and the protocol makes refusals visible to the user. The protocol gives the exact steps.
This protects against a malicious device with an honest host. It doesn’t help if the host is also compromised, and it doesn’t cover physical attacks, timing or radio channels, or key generation.
The protocol
The protocol has five steps across two round trips. The host holds no secret key and checks the result. The device holds the key and signs.
Host Device
1. draw rho, compute hc
---- m, hc ---------------------->
2. derive k0 from the key, m and hc
<--- R0_bytes -------------------- send R0_bytes = k0 * G
store R0_bytes
3. ---- m, rho --------------------->
4. user approves
recompute hc and k0 from rho
t = hash(R0_bytes, rho)
<--- sig ------------------------- sign with nonce k0 + t
5. check that sig uses R0 + t * G
and verifies
The device needs no state between the two round trips. It recomputes everything in step 4.
The steps, for BIP340
Values are as in BIP340. tagged_hash is BIP340’s tagged hash, and ser33 is a 33-byte compressed point that keeps its real y parity.
1. Host commits. The host draws rho, 32 random bytes from a secure source, and sends m and
hc = tagged_hash("s2c/schnorr/data", rho)
2. Device commits. The device derives a base nonce from its key, the message and hc, and sends the point:
t0 = bytes32(d) xor tagged_hash("BIP0340/aux", hc)
k0 = int(tagged_hash("s2c/schnorr/nonce", t0 || pk || m || hc)) mod n
R0_bytes = ser33(k0 * G)
Because k0 depends on hc, a host can’t get two signatures from one base nonce. Step 2 gives out no signature, so it doesn’t need the user’s approval.
3. Host reveals. Only after it has received R0_bytes, checked it’s a valid point and stored it, the host sends rho and m again.
4. Device signs. After the user approves, the device recomputes hc, k0 and R0_bytes itself. It never takes them from the host. Then:
t = int(tagged_hash("s2c/schnorr/point", R0_bytes || rho))
k' = (k0 + t) mod n
and it signs with k' as in BIP340.
5. Host verifies. The host uses its own pk, m, rho and the R0_bytes it stored, and takes only sig from the device. It checks that W = R0 + t * G has the x coordinate in the signature, and that the signature verifies. It never forwards R0_bytes or anything else the device sent.
ECDSA
ECDSA follows the same five steps with its own tags (s2c/ecdsa/...). pk is a 33-byte compressed key and m is exactly 32 bytes. Three things differ:
k0fails if the hash is 0 or at leastn, rather than being reduced.- The device normalizes to low
s, and the host refuses a highs, because otherwise the choice ofsorn - swould give the device one free bit per signature. - The host checks that the verification point equals
Wor-W, not only that the x coordinates match.
Design choices
These are covered in full in the BIP’s Rationale section.
- Two round trips. With one, the device sees the host’s value before it chooses its nonce, and can try nonces until the signature carries the bits it wants.
- A hashed tweak. The nonce is
k0 + hash(R0_bytes, rho)and notk0 + rho. This ties the tweak to the device’s commitment and matches the sign-to-contract form that existing libraries implement. Wuille’s 2020 overview calls this a variant of its Scheme 6. - A nonce derivation of its own. The tags are used only by this protocol, so the same inputs can’t reach an ordinary signing path.
- A deterministic
k0. A device can then be stateless, and the host can retry step 4 with the samerhoand get the same nonce.
What’s left to the device
A malicious device can still refuse. If it only finishes when the signature happens to carry the bits it wants, each refusal leaks a little. The security argument puts this at about 1.44 bits per expected failed attempt after the reveal. That’s why the host has to count failures and tell the user (see Host rules). There is no overall bound unless the host also caps the number of failed attempts.
Host rules
The host does most of the work. A device can be dishonest, so every rule that matters for the protection is enforced by the host. These are the main ones. The full rules are in the protocol BIP, under Host randomness, Attempts and failures, and step 5.
rho
- Draw a new
rhofor every attempt, and for every message and key within it, from a secure random source. Never derive it from something else. - Never log it, publish it, share it or write it to durable storage, during or after signing. Erase it when the attempt ends.
- Keep
R0_bytesin memory withrho. A restart ends the attempt. - Use a link that keeps
rhofrom third parties. Anyone who learnsrhocan recover the key from a backdoored device’s signature. QR codes and files don’t meet this on their own, and the drafts don’t define a transport that does.
The checks
Before sending rho, check that R0_bytes is a valid point and store it. After step 4, check, using only your own values and the stored R0_bytes:
R0_bytesparses as a point.t = hash(R0_bytes, rho)is belown.W = R0 + t * Gis not the point at infinity.- The signature’s
rmatchesW. - The signature verifies for your own
pkandm.
For ECDSA, also require low s, and check that the verification point equals W or -W. Use nothing the device sent other than the signature. Don’t forward R0_bytes.
Attempts
An attempt is one run of the protocol with one set of rho values. Before sending hc, record durably that an attempt is open for that device. That way a crash can’t hide a failure.
An attempt ends as completed, abandoned or failed. It is abandoned only if the user stops or changes the request before rho is sent, for a reason that has nothing to do with the device. Anything else that ends it unsuccessfully is a failure.
Counting failures
Timeouts, errors, refusals, malformed responses, a changed R0_bytes, failed checks and host restarts all count as failure events. Don’t try to tell a transport fault from a refusal, because a device can make one look like the other.
- Keep a durable failure count per device. Identify the device by the keys in your own wallet data, never by an identifier the device reports.
- Add one at the first failure event in an attempt, and at most once per attempt, in the same atomic write as the attempt’s state. A later success doesn’t undo it.
- An attempt still open at startup counts as failed.
- Reset the count only when the user asks, with a clear statement of what is being cleared.
Retries
Within an attempt you can retry a few times. Three retries each for steps 2 and 4 is the suggested default.
- You can repeat step 2 with the same
hc. IfR0_bytescomes back different, the attempt fails, because a second value is a second choice of nonce. - After a failure past step 2, repeat steps 3 and 4 with the same
rho, rather than starting a new attempt. A new attempt gives the device a fresh choice.
Warnings
If the count has gone up since the user last acknowledged a warning, warn the user before starting another attempt with that device. Say that an attempt failed and that repeated failures can mean a malicious device. Changing the message, reconnecting or restarting doesn’t clear this.
Don’t start a new attempt silently after a failure, and don’t change the fee or coins by yourself because of one, because an observer can see a changed transaction. If a device can’t do anti-exfil signing, don’t fall back to ordinary signing unless the user decides to, knowing they lose the protection.
Device rules
The rules for an honest device protect its key from a malicious host. The full rules are in the protocol BIP, under The message, steps 2 and 4, User approval and Key separation.
The message
Compute m yourself from data the user approves, such as the transaction. Don’t offer an entry point that signs a hash the host supplies, because the user can’t read it. Refuse SIGHASH_SINGLE with no matching output.
Step 2
- Derive
k0exactly as the BIP specifies, from the key,mandhc. The derivation masks the key with a hash ofhcand uses tags reserved for this protocol. - Send
R0_byteswith the real parity ofk0 * G. Don’t negatek0at this step. - You don’t need to keep any state, or ask for approval.
Step 4
- Get the user’s approval first. Approval has to cover everything the signature commits to, including a non-default sighash type and, for a Taproot script path, the leaf. Approval given around step 2 doesn’t count for step 4.
- Recompute
hcfromrho, andk0andR0_bytesas in step 2. Never use a stored value, and never accept any of these from the host. A host that could supply them could get two signatures on one base nonce and solve for the key. - Sign with
k0 + t. For ECDSA, normalize to lows. - It’s recommended to run the host’s checks on your own signature before sending it, to catch faults.
Key separation
Don’t expose these nonce functions through an ordinary signing interface, and don’t use them for any signature outside this protocol. An ordinary signing interface under the same key mustn’t let a host choose inputs that make it produce k0.
A device that keeps an older derivation, such as the one in secp256k1-zkp, must make sure no ordinary signing interface lets a host supply hc as extra nonce data under the same key, because equal inputs can produce an equal base nonce.
Secrets
Don’t log or disclose t, k0, k', the masked key or intermediate hash states, and erase them after use. Process rho and values derived from it in constant time. A signing library can leave such values on the stack, so a device concerned about memory readout has to clear them.
PSBT fields
The PSBT BIP carries the protocol between one host and one signer, in PSBT versions 0 and 2.
The three fields
| Name | Key type | Key data | Value |
|---|---|---|---|
PSBT_IN_AE_HOST_COMMITMENT | 0x21 | key identifier | hc, 32 bytes |
PSBT_IN_AE_NONCE_COMMITMENT | 0x22 | key identifier | R0_bytes, 33 bytes |
PSBT_IN_AE_HOST_RANDOMNESS | 0x23 | key identifier | rho, 32 bytes |
The key type numbers are proposed and depend on assignment. The fields belong only in input maps.
The key identifier is the 33-byte public key for ECDSA, the 32-byte x-only internal key for a Taproot key path, and the x-only key followed by the leaf hash for a Taproot script path. An input index and a key identifier make a pair, and each pair gets its own rho.
Keeping them private
rho has to stay secret, so these fields must never appear in a PSBT given to anyone other than the host and the signer. The host keeps its own PSBT, which never contains them, and builds a separate request for each exchange.
Two passes
First pass. The host sends a request with a host commitment for each pair. The signer runs step 2 and adds a nonce commitment for each one. It adds no signature and needs no approval. The host stores the nonce commitments and discards the response.
Second pass. The host sends a new request with the same transaction and pairs, holding only the randomness field for each pair. The signer gets one approval for the whole PSBT, runs step 4 for each pair, adds the signatures, removes every anti-exfil field and returns the PSBT. The host runs step 5 on each signature and adds to its own PSBT only fields it builds itself from the verified signatures.
If the transaction or the pairs change between passes, the host ends the attempt and starts over with new rho values.
What the host takes from a response
Only the requested commitments or signatures. It fails the attempt if the core transaction changed, a requested value is missing, a commitment changed, a response holds an anti-exfil field it shouldn’t, or a signature field was added, changed or removed where it shouldn’t be. It ignores other differences, such as reserialized fields or added metadata, because it discards the response anyway.
Signer rules
- Handle exactly the pairs named by these fields, and add no other signature. If any pair fails, fail the whole PSBT and return nothing.
- Validate everything before prompting the user: structure, pairs, key ownership, transaction data, sighash types and script paths.
- Fail if a pair already has a signature, if you don’t hold the key, or if one input has both a key-path and a script-path pair.
- Don’t present a fee as verified unless every input amount was verified, either against
PSBT_IN_NON_WITNESS_UTXOor because a Taproot pair withoutANYONECANPAYcommits to them all. Otherwise tell the user the fee is unverified, or fail.
Spend forms
Signers must support P2PKH, P2WPKH, P2SH-P2WPKH, the Taproot key path, and P2WSH and P2SH-P2WSH with a witness script that is either <key> OP_CHECKSIG or a bare OP_CHECKMULTISIG of up to 16 keys. The Taproot script path and some other scripts are optional. A host shouldn’t request an optional form unless it knows the signer supports it, because a refusal counts as a failed attempt.
Accepted sighash types are ALL, NONE and SINGLE, with or without ANYONECANPAY, and DEFAULT for BIP340. A signer may accept only ALL and DEFAULT if it says so.
Transports
The PSBT BIP assumes a direct link, such as USB, that keeps the exchange from third parties. On a QR code or a file, rho is visible to anything that can see the screen or keep the file. A QR or file profile needs its own specification. spec/transport-analysis.md looks at the options.
Test vectors
All vector files are in vectors/, and each has a README with its exact layout. They are available under CC0-1.0 or MIT. Every key in them is artificial and must never hold funds.
Protocol vectors
bip-anti-exfil.json covers both schemes.
valid: complete honest runs. Give a devicesk,mandrhoand check that it produces exactlypk,hc,R0_bytesandsig. Give a hostpk,m,rho,R0_bytesandsigand check that it accepts. The cases cover every combination of odd and evenR0,PandW.reject: tuples a host must refuse. Each is a valid case with exactly one value changed, and it names the change.forced_hash: failures that real hashes can’t reach, such ask0 = 0. Each states the hash output a test has to substitute.
bip-anti-exfil-boundary.json adds boundary cases in the same format: device runs that must fail, host rejections that each name the check that refuses them, and tuples a host must accept.
PSBT vectors
bip-anti-exfil-psbt.json has:
| Section | Count | What it holds |
|---|---|---|
exchanges | 86 | Full exchanges for each spend form and PSBT version: both requests, both responses, the host’s result and the finalized transaction |
signer_refusals | 79 | Requests a signer must fail, each with the rule it tests |
host_refusals | 40 | Responses a host must reject |
host_accepts | 4 | Responses that differ from the request in an allowed way |
request_refusals | 26 | Host PSBTs the host must refuse before starting |
lifecycle_scenarios | 18 | Runs against one store, with restarts, stops and resets, and the expected outcome and count |
user_facts | 11 | What the user must be told |
Compare PSBTs as parsed maps, because the order of maps and fields doesn’t matter. The vectors fix rho so the bytes are reproducible. A real host draws it at random.
bip-anti-exfil-psbt-seed.json holds the required spend forms with keys from the BIP39 test mnemonic (abandon ... about), for a device that holds a mnemonic.
The vectors don’t repeat the invalid PSBT lists in BIPs 174, 370 and 371. An implementation should pass those too.
Regenerating them
The generators are in scripts/. gen_bip_vectors.py --check, gen_boundary_vectors.py --check and gen_psbt_vectors.py --check each regenerate their file and compare it with the one on disk. check_bip_vectors.py recomputes every protocol vector with separate curve code.
Running the checks
One script reruns most of the checks:
git clone https://github.com/AvilaLabs/anti-exfil
cd anti-exfil
./check.sh
It needs Python 3, a C compiler, autotools and network access to fetch the pinned libsecp256k1 bases. It takes about 15 minutes.
What it runs
| Step | What it checks |
|---|---|
vec-bip, vec-boundary, vec-psbt | Each vector file is exactly what its generator writes. The PSBT step replays every vector through the reference host and signer |
vec-indep | Every protocol vector, recomputed with separate curve code |
psbt-suite | Unit, lifecycle and known-answer tests for the reference host and signer |
c-build, c-vectors | The C module built on both pinned bases, reproducing every valid vector and rejecting every rejection case |
secp1140 | The host checks run against secp256k1 pull request 1140 and secp256k1-zkp, unmodified |
Each step is followed by a control, which runs the same check on something it must refuse, such as a vector file with one digit changed. If the check accepts it, the check isn’t testing anything, and the script reports the control as failed. The last line reads RESULT: all steps and controls passed when everything passed.
Options
| Option | Adds | Needs |
|---|---|---|
--regtest | Spends of every form on a Bitcoin Core regtest node | BITCOIN_BIN set to a folder with Bitcoin Core 31.1 |
--jade | The Jade native build, and the seed vectors replayed against it | JADE_SRC, IDF_PATH, cmake and a Python with wallycore, cbor2 and pyserial. The top of check.sh has the details |
--long --yes | The long mutation, lifecycle and C module runs | About 6 hours, and the analysis tools listed in check.sh |
The results
EVIDENCE.md lists every check with its result and what it doesn’t show, what failed during development and how it was fixed, and what wasn’t tested.
What isn’t proved or tested
The proof
spec/security-argument.md gives a game-based reduction in the random oracle model, for one honestly generated key used only through this protocol. It covers a malicious host: a forgery on a message the user didn’t approve yields a forgery against plain BIP340 or ECDSA, losing a factor of q_s + 1 plus small statistical terms.
No cryptographer has reviewed it, and the proof isn’t machine-checked as a whole. Parts of it are checked: some steps exactly on small groups, three steps in CryptoVerif, and message order and replay in ProVerif.
It doesn’t cover:
- the malicious-device goal, which has a shorter, less reviewed argument of its own
- the concrete SHA-256 function
- the same key used with ordinary signing, or with both schemes
- related keys, such as a Taproot output key and its internal key, or non-hardened BIP32 children. A candidate extension covers these, but it rests on an assumption that hasn’t been proved.
It also assumes plain ECDSA is unforgeable.
The protocol’s limits
- A device can still leak through refusals. The host makes them visible, but there is no overall bound unless it also caps failed attempts.
rhomust stay secret. QR codes and files don’t keep it secret, and no transport for them is defined.- Only single signers are covered, so MuSig2 and FROST are out of scope.
- Key generation, physical attacks, and timing or radio channels are out of scope.
- A signature made outside the protocol has no protection, so one ordinary signature under a key can leak that key.
Testing
- No hardware. Nothing has run on a device, and there are no timing or power measurements.
- Jade on the native build only. The patch series passes Jade’s own tests and the interoperability checks there, and the firmware builds for five targets, but none of it has been flashed.
- Text-only implementations. Two implementations were written from the text alone by an AI model, as a test that it can be followed. They could share a misreading with the text, and no separate team has implemented it.
- Sampled mutation testing. The mutation runs on the reference used samples, not the full set.
EVIDENCE.md has the full list, including what failed during development.
Related work
The schemes
Pieter Wuille’s 2020 overview, Overview of anti-covert-channel signing techniques, compares the main approaches. Its Scheme 6 is two round trips, a deterministic nonce bound to a precommitted host value, and the host value added to the nonce. The overview describes a variant that uses a sign-to-contract tweak in place of the addition. This protocol is that variant. The overview credits earlier work by Stepan Snigirev and Andrew Poelstra, among others.
True2F’s firewalled ECDSA (Dauterman et al., 2019) is earlier prior art for collaborative nonces in hardware tokens.
Devices and libraries
- Blockstream Jade and BitBox02 already ship anti-exfil for ECDSA, built on secp256k1-zkp’s sign-to-contract module. Their messages have the same layout as this protocol, but their nonce derivation differs, so their bytes don’t match these test vectors. A host running this protocol’s step 5 accepts their signatures.
- secp256k1 pull request 1140, by Marko Bencun and Jonas Nick, proposes sign-to-contract and anti-exfil for BIP340 in libsecp256k1. Review there asked for a specification and a security argument. The
secp1140/folder runs this protocol’s host checks against it unmodified. - Trezor has an open pull request adding ECDSA anti-exfil on the same secp256k1-zkp module.
Other drafts
- Sjors Provoost’s draft (Sjors/bips#2, October 2026) covers single-signer Taproot key-path signing, with proprietary PSBT fields and experimental implementations for HWI, Bitcoin Core and several devices. It adds the host value directly, so it doesn’t interoperate with this protocol. Sjors plans to align those implementations with these drafts.
- Non-interactive anti-exfil (Delving Bitcoin, 2024) uses one round, which suits air-gapped signing over QR. In that thread, Wuille pointed out that a device can still grind:
2^btries per signature leakbbits per signature. - BIP 461 standardizes deterministic ECDSA, so that two signers holding the same key can be compared. It detects a bad nonce only on messages both have signed, and doesn’t help a watch-only wallet. A signature made under this protocol includes host randomness, so it differs from a BIP 461 signature by design.
Reviewing the drafts
Comments are welcome on the bitcoin-dev mailing list thread, or as issues on GitHub. Corrections to the security argument would help most.
Open questions
- The security argument. Does the reduction hold? Part 2, on what a malicious device can leak, is shorter and less reviewed than Part 1. The related-key extension rests on an assumption that hasn’t been proved.
- Hashed or plain tweak. This protocol uses
k0 + hash(R0_bytes, rho)to match sign-to-contract and the existing libraries. Sjors Provoost’s draft usesk0 + rho. Is there a reason to prefer one? - Failure counting. Are the rules for attempts, counts and warnings practical for wallet software, and are they strict enough? There’s no overall bound on leakage without a cap on failed attempts, and the drafts don’t set one.
- A separate nonce derivation. This protocol’s derivation differs from the one Jade and BitBox02 ship. That keeps its nonces apart from ordinary signing, but existing devices would need to change to match the test vectors. Is that the right trade?
- PSBT fields. Are the proposed key types, key identifiers and required spend forms right? Should the PSBT BIP stay separate from the protocol?
- QR and file transports. These are left undefined because
rhowould be visible. Is there a workable profile for air-gapped devices?
Reading order
For the cryptography, start with The protocol and the protocol BIP, then spec/security-argument.md. For the PSBT side, read PSBT fields and the PSBT BIP, then try the vectors against your own implementation.