Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 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 areRead
Reviewing the cryptographyThe protocol, then spec/security-argument.md and What isn’t proved or tested
Writing wallet softwareThe protocol, Host rules, PSBT fields
Writing device firmwareThe protocol, Device rules, PSBT fields, Test vectors
Checking the workRunning the checks and EVIDENCE.md

Reviewing the drafts lists the questions where comments would help most.

What’s in the repository

FolderContents
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:

  1. The host commits to a random value without revealing it.
  2. The device commits to its own nonce.
  3. The host reveals its value, and the device signs with a nonce made from both.
  4. 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:

  • k0 fails if the hash is 0 or at least n, rather than being reduced.
  • The device normalizes to low s, and the host refuses a high s, because otherwise the choice of s or n - s would give the device one free bit per signature.
  • The host checks that the verification point equals W or -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 not k0 + 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 same rho and 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 rho for 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_bytes in memory with rho. A restart ends the attempt.
  • Use a link that keeps rho from third parties. Anyone who learns rho can 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:

  1. R0_bytes parses as a point.
  2. t = hash(R0_bytes, rho) is below n.
  3. W = R0 + t * G is not the point at infinity.
  4. The signature’s r matches W.
  5. The signature verifies for your own pk and m.

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. If R0_bytes comes 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 k0 exactly as the BIP specifies, from the key, m and hc. The derivation masks the key with a hash of hc and uses tags reserved for this protocol.
  • Send R0_bytes with the real parity of k0 * G. Don’t negate k0 at 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 hc from rho, and k0 and R0_bytes as 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 low s.
  • 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

NameKey typeKey dataValue
PSBT_IN_AE_HOST_COMMITMENT0x21key identifierhc, 32 bytes
PSBT_IN_AE_NONCE_COMMITMENT0x22key identifierR0_bytes, 33 bytes
PSBT_IN_AE_HOST_RANDOMNESS0x23key identifierrho, 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_UTXO or because a Taproot pair without ANYONECANPAY commits 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 device sk, m and rho and check that it produces exactly pk, hc, R0_bytes and sig. Give a host pk, m, rho, R0_bytes and sig and check that it accepts. The cases cover every combination of odd and even R0, P and W.
  • 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 as k0 = 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:

SectionCountWhat it holds
exchanges86Full exchanges for each spend form and PSBT version: both requests, both responses, the host’s result and the finalized transaction
signer_refusals79Requests a signer must fail, each with the rule it tests
host_refusals40Responses a host must reject
host_accepts4Responses that differ from the request in an allowed way
request_refusals26Host PSBTs the host must refuse before starting
lifecycle_scenarios18Runs against one store, with restarts, stops and resets, and the expected outcome and count
user_facts11What 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

StepWhat it checks
vec-bip, vec-boundary, vec-psbtEach vector file is exactly what its generator writes. The PSBT step replays every vector through the reference host and signer
vec-indepEvery protocol vector, recomputed with separate curve code
psbt-suiteUnit, lifecycle and known-answer tests for the reference host and signer
c-build, c-vectorsThe C module built on both pinned bases, reproducing every valid vector and rejecting every rejection case
secp1140The 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

OptionAddsNeeds
--regtestSpends of every form on a Bitcoin Core regtest nodeBITCOIN_BIN set to a folder with Bitcoin Core 31.1
--jadeThe Jade native build, and the seed vectors replayed against itJADE_SRC, IDF_PATH, cmake and a Python with wallycore, cbor2 and pyserial. The top of check.sh has the details
--long --yesThe long mutation, lifecycle and C module runsAbout 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.
  • rho must 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^b tries per signature leak b bits 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 uses k0 + 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 rho would 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.