Feature proposal, not a vulnerability report.
Status: working.
-
Implemented, flashed to hardware (Waveshare ESP32-S3), and signed end to end over airgapped QR against Sparrow on testnet4.
-
Negative tests pass in QEMU and on-device: a signer that ignores the host entropy, grinds a biased nonce, or commits to one sighash and signs another is caught every time.
-
The raw PSBTs from the hardware run are included below, so the round ordering and the nonce binding can be checked independently of this write-up.
Small footprint.
-
Around 85 lines of production logic in sign_psbt.c, plus two lines of dispatch in dashboard.c; the rest of the diff is comments and CONFIG_DEBUG_MODE test code.
-
No new cryptography: it calls the same libwally anti-exfil functions wallet.c already uses for sign_tx (wally_ae_signer_commit_from_bytes, wally_ae_sig_from_bytes, wally_ae_host_commit_from_bytes). No new RPC, no change to qrmode.c, and the non-AE signing path is byte-identical, only moved into an else.
Gap
-
Anti-exfil exists only in sign_tx. sign_psbt — used by both USB and QR — has none.
-
Jade already implements the strong (negotiated nonce, "Level 3") form of anti-exfil in the legacy sign_tx path.
-
What is missing is that same protection on the transport where it is arguably needed most: QR signing, where the device is airgapped and its firmware is the only thing standing between the user and a leaked seed.
-
This extends the existing mechanism to sign_psbt rather than introducing a new one.
-
For airgapped signing specifically, moonsettler's proposal offers a weaker single-round (Level 2) scheme, on the stated premise that Level 3 "is not really compatible with airgapped signing" (Optech #317) because of the second round trip.
-
The two-round exchange below tests that premise for one transport in particular.
-
The objection as raised describes moving the transaction to the device and back twice, which is a fair cost for a microSD or file-based airgap.
-
For camera QR with the signer beside the machine it is a second show-and-scan: on the hardware described, about ten seconds added to a single-input signing, scaling with PSBT size rather than fixed.
-
Question 4 asks whether that trade is the right one for Jade.
-
I am not aware of any signing device that offers Level 3 anti-exfil over an airgap today — the protection exists over USB, and airgapped users get either nothing or the weaker single-round form. Merging this would make Jade the first, on the transport where users have deliberately chosen to trust the device's firmware over a cable.
Approach
-
Carry the three AE messages in PSBT proprietary fields, so one implementation covers both transports with no new RPC.
-
Per-input, keyed by 33-byte signer pubkey. Identifier ae (0xFC 0x02 "ae"); subtypes 0x00 host_commitment, 0x01 signer_commitment, 0x02 host_entropy.
-
Round is inferred from PSBT contents rather than RPC state (this is what lets the QR path work unchanged):
- host_commitment, no host_entropy → round 1: emit signer_commitment, don't sign.
- host_commitment + host_entropy → round 2: verify the opening, sign with s2c tweak.
-
Device stays stateless across rounds — signer_commitment is deterministic in (privkey, sighash, host_commitment), so round 2 re-derives rather than recalls, and two independent QR scans need no session state.
-
Uses libwally directly (wally_ae_host_commit_from_bytes).
-
ECDSA only; taproot inputs bearing an ae field are refused, not signed.
-
The non-AE path is byte-identical (moved to else).
Verification
Patch vs 5ad8566: +223/−7 across sign_psbt.c, debug_set_ae_cheat.c (new, cheat modes for negative testing), dashboard.c.
-
Java verifier, 18 libwally ground-truth vectors, in AntiExfilVerifierTest (./gradlew :lark:test): 8 honest, 8 where the signer ignored the host entropy, 2 mismatched entropy/commitment pairings.
-
QEMU emulator: honest sig verifies; ignore-entropy / biased-nonce / commit-A-sign-B all caught.
-
DIY hardware (Waveshare ESP32-S3, flashed and run on-device): same four results green.
-
End-to-end over airgapped QR (Sparrow host <-> DIY Jade, testnet4): the four PSBTs re-parsed independently confirm the ordering.
Round 1 returns a signer commitment with zero signatures, and the PSBT the device saw at that point carried no host_entropy field; the signature appears only in round 2, after the reveal.
The revealed entropy hashes to the round-1 host_commitment, and sig.r == (R0 + t·G).x holds for the returned signature (canonical DER, low-S, scalars in range). All four PSBTs carry the identical unsigned tx.
On signature encoding: the host verifier also requires strictly canonical DER, both scalars inside the curve order, and low S, on the same reasoning as cc37a3b in lark — the s2c check constrains only R, so a signer left free to pick between the low and high S encodings, or to pad a DER integer, retains a covert channel worth roughly a bit per signature.
This is not a constraint the patch has to satisfy specially: wally_ec_sig_to_der over a wally_ae_sig_from_bytes result already produces canonical low-S DER, so every signature from the patched firmware passes unchanged, on-device and in QEMU. No firmware-side change was needed, and none of the 18 libwally vectors is high-S.
Raw PSBTs from the testnet4 run (base64) — paste into Sparrow or bitcoin-cli decodepsbt to check any of the above
1-round1-out — 455 bytes, host_commitment only, 0 signatures
cHNidP8BAHECAAAAAXpiHHv0vbMTinDyORybtvCt+k2P6LEQiqAPeq7dj+gNAAAAAAD9////AtetBAAAAAAAFgAUWkZSP9IEoHCO33qslptpJid91mPECQAAAAAAABYAFIyERlj3zIAceTlZw7RePzB2wyH5FEgCAE8BBDWHzwP6XrUdgAAAADWpq3uDb1Elgd7d0ZSOwY/MDsVA8v6kO1Px7B8sca38A1a3JrPTU1LXWf9+IIY81GPpaK9OOISCUwGT14IhEFNiEOxmhqhUAACAAQAAgAAAAIAAAQEf4bcEAAAAAAAWABQ1WG1nXXBE35ifVrgOTFKc3lWlzQEDBAEAAAAiBgKRX9Y4ioP9NfQe+OxQBLNyy2Fmg9OsEU1IExpiVJyNGhjsZoaoVAAAgAEAAIAAAACAAQAAAAUAAAAm/AJhZQACkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRogxtZdV8oySW4MxrHKx6nE4+5PtwyT40FB6ZYTi0VWQxoAIgICNEbnW0vkGuV6YksezfrKNfpGX1j1O9veU+72uoYP82kY7GaGqFQAAIABAACAAAAAgAEAAAAGAAAAAAA=
2-round1-reply — 528 bytes, signer_commitment added, still 0 signatures
cHNidP8BAHECAAAAAXpiHHv0vbMTinDyORybtvCt+k2P6LEQiqAPeq7dj+gNAAAAAAD9////AtetBAAAAAAAFgAUWkZSP9IEoHCO33qslptpJid91mPECQAAAAAAABYAFIyERlj3zIAceTlZw7RePzB2wyH5FEgCAE8BBDWHzwP6XrUdgAAAADWpq3uDb1Elgd7d0ZSOwY/MDsVA8v6kO1Px7B8sca38A1a3JrPTU1LXWf9+IIY81GPpaK9OOISCUwGT14IhEFNiEOxmhqhUAACAAQAAgAAAAIAAAQEf4bcEAAAAAAAWABQ1WG1nXXBE35ifVrgOTFKc3lWlzQEDBAEAAAAiBgKRX9Y4ioP9NfQe+OxQBLNyy2Fmg9OsEU1IExpiVJyNGhjsZoaoVAAAgAEAAIAAAACAAQAAAAUAAAAm/AJhZQACkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRogxtZdV8oySW4MxrHKx6nE4+5PtwyT40FB6ZYTi0VWQxom/AJhZQECkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRohAu3SNG/Nsogshm8IFFKxa0Uo5ygaZmenoW/ZdsPHV5h0ACICAjRG51tL5BrlemJLHs36yjX6Rl9Y9Tvb3lPu9rqGD/NpGOxmhqhUAACAAQAAgAAAAIABAAAABgAAAAAA
3-round2-out — 600 bytes, host_entropy revealed, still 0 signatures
cHNidP8BAHECAAAAAXpiHHv0vbMTinDyORybtvCt+k2P6LEQiqAPeq7dj+gNAAAAAAD9////AtetBAAAAAAAFgAUWkZSP9IEoHCO33qslptpJid91mPECQAAAAAAABYAFIyERlj3zIAceTlZw7RePzB2wyH5FEgCAE8BBDWHzwP6XrUdgAAAADWpq3uDb1Elgd7d0ZSOwY/MDsVA8v6kO1Px7B8sca38A1a3JrPTU1LXWf9+IIY81GPpaK9OOISCUwGT14IhEFNiEOxmhqhUAACAAQAAgAAAAIAAAQEf4bcEAAAAAAAWABQ1WG1nXXBE35ifVrgOTFKc3lWlzQEDBAEAAAAiBgKRX9Y4ioP9NfQe+OxQBLNyy2Fmg9OsEU1IExpiVJyNGhjsZoaoVAAAgAEAAIAAAACAAQAAAAUAAAAm/AJhZQACkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRogxtZdV8oySW4MxrHKx6nE4+5PtwyT40FB6ZYTi0VWQxom/AJhZQECkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRohAu3SNG/Nsogshm8IFFKxa0Uo5ygaZmenoW/ZdsPHV5h0JvwCYWUCApFf1jiKg/019B747FAEs3LLYWaD06wRTUgTGmJUnI0aIEDQGjxnxvdiUZlZ6vAT+HG10CTK9wtozGVS0KE5OUk6ACICAjRG51tL5BrlemJLHs36yjX6Rl9Y9Tvb3lPu9rqGD/NpGOxmhqhUAACAAQAAgAAAAIABAAAABgAAAAAA
4-round2-reply — 708 bytes, signed, 1 signature
cHNidP8BAHECAAAAAXpiHHv0vbMTinDyORybtvCt+k2P6LEQiqAPeq7dj+gNAAAAAAD9////AtetBAAAAAAAFgAUWkZSP9IEoHCO33qslptpJid91mPECQAAAAAAABYAFIyERlj3zIAceTlZw7RePzB2wyH5FEgCAE8BBDWHzwP6XrUdgAAAADWpq3uDb1Elgd7d0ZSOwY/MDsVA8v6kO1Px7B8sca38A1a3JrPTU1LXWf9+IIY81GPpaK9OOISCUwGT14IhEFNiEOxmhqhUAACAAQAAgAAAAIAAAQEf4bcEAAAAAAAWABQ1WG1nXXBE35ifVrgOTFKc3lWlzSICApFf1jiKg/019B747FAEs3LLYWaD06wRTUgTGmJUnI0aSDBFAiEAjtkpS9R7t1LMXBKwTDOaUhR9FazhdvbVeLu32ynBlHICIHd4iJVp/qsKIgbEXSYDF04BreU17L5y7zsLVV6xY7JXAQEDBAEAAAAiBgKRX9Y4ioP9NfQe+OxQBLNyy2Fmg9OsEU1IExpiVJyNGhjsZoaoVAAAgAEAAIAAAACAAQAAAAUAAAAm/AJhZQACkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRogxtZdV8oySW4MxrHKx6nE4+5PtwyT40FB6ZYTi0VWQxom/AJhZQECkV/WOIqD/TX0HvjsUASzcsthZoPTrBFNSBMaYlScjRohAu3SNG/Nsogshm8IFFKxa0Uo5ygaZmenoW/ZdsPHV5h0JvwCYWUCApFf1jiKg/019B747FAEs3LLYWaD06wRTUgTGmJUnI0aIEDQGjxnxvdiUZlZ6vAT+HG10CTK9wtozGVS0KE5OUk6ACICAjRG51tL5BrlemJLHs36yjX6Rl9Y9Tvb3lPu9rqGD/NpGOxmhqhUAACAAQAAgAAAAIABAAAABgAAAAAA
Scope
-
Closes the nonce channel only.
-
Transaction-level channels (input/output selection, fee grinding, sighash) are out of scope here and are better handled host-side (canonical tx + assert the signed PSBT differs only by signatures).
-
Assumes a trusted host (Sparrow).
-
Testing to date is singlesig only.
Questions
Q1 and Q2 are put as recommendations rather than open questions, since they fix a wire format that is hard to change once firmware ships.
Both are easy to reverse now and awkward later.
- Proprietary fields rather than extending the RPC.
Over QR the payload is the PSBT, so there is no side channel for RPC parameters; carrying the three AE messages in the document is what lets one implementation serve both transports.
BIP-174 provides 0xFC for exactly this, and requires parsers to tolerate and Combiners to preserve unknown fields, so an AE-bearing PSBT still passes through tools that do not understand it.
Where an implementation does drop proprietary fields, the protocol fails closed rather than silently unprotected: the host sees a round-1 reply with no signer commitment and aborts with "device may not support anti-exfil" instead of signing.
An RPC extension would give firmware explicit control of the format, but it cannot reach the QR path, which is the case anti-exfil most needs to cover.
Is that trade the right one?
- Suggest lengthening the identifier from
ae. There is no registry for BIP-174 proprietary identifiers, and PSBTs travel between wallets, so a two-byte generic string risks colliding with an unrelated use and having fields misread mid-signing.
jade-ae costs a few bytes of QR density and removes that risk.
Happy to use whatever prefix you would want to own.
If the preference is for something cross-vendor rather than Jade-specific, that points at a BIP rather than a repo-local choice, and is worth saying now.
- Round 2 re-deriving the signer commitment — acceptable, or bind to the tx differently?
- Two device confirmations over QR — collapse to one, or fine as-is?
Related: is the extra round worth it to you versus the single-round Level 2 scheme, given that dropping to one round reopens nonce grinding?
- Cheat modes in-tree behind
CONFIG_DEBUG_MODE, or dropped?
- Singlesig tested only; multisig untested (signer selection by fingerprint,
ae fields keyed by pubkey) — any known pitfalls there?
- The patch calls
wally_ae_signer_commit_from_bytes directly rather than wallet.c's wallet_get_signer_commitment, which was considered and not used: that helper takes a derivation path and re-derives the privkey, whereas the signing loop already holds iter.hdkey, and there is no equivalent helper for the round-2 AE signing over a PSBT, so reusing it for the commitment only would leave the two halves asymmetric.
sign_psbt.c already signs via wally directly (wally_psbt_sign_input_bip32) while sign_tx.c uses the wallet.c helpers, so this follows the existing split.
Happy to change it if you would rather the helper covered both paths.
One note on BIP-174 roles: in round 1 the device adds a proprietary field and deliberately does not sign, which is closer to Updater behaviour than Signer.
That is inherent to a commit-then-reveal exchange rather than an artifact of this encoding, but flagging it in case the role boundary matters to you.
The debug/cheat-mode code referenced is test-only and CONFIG_DEBUG_MODE-gated; never in a release build.*
Happy to open a PR if the direction fits.
Feature proposal, not a vulnerability report.
Status: working.
Implemented, flashed to hardware (Waveshare ESP32-S3), and signed end to end over airgapped QR against Sparrow on testnet4.
Negative tests pass in QEMU and on-device: a signer that ignores the host entropy, grinds a biased nonce, or commits to one sighash and signs another is caught every time.
The raw PSBTs from the hardware run are included below, so the round ordering and the nonce binding can be checked independently of this write-up.
Small footprint.
Around 85 lines of production logic in
sign_psbt.c, plus two lines of dispatch indashboard.c; the rest of the diff is comments andCONFIG_DEBUG_MODEtest code.No new cryptography: it calls the same libwally anti-exfil functions
wallet.calready uses forsign_tx(wally_ae_signer_commit_from_bytes,wally_ae_sig_from_bytes,wally_ae_host_commit_from_bytes). No new RPC, no change toqrmode.c, and the non-AE signing path is byte-identical, only moved into anelse.Gap
Anti-exfil exists only in
sign_tx.sign_psbt— used by both USB and QR — has none.Jade already implements the strong (negotiated nonce, "Level 3") form of anti-exfil in the legacy
sign_txpath.What is missing is that same protection on the transport where it is arguably needed most: QR signing, where the device is airgapped and its firmware is the only thing standing between the user and a leaked seed.
This extends the existing mechanism to
sign_psbtrather than introducing a new one.For airgapped signing specifically, moonsettler's proposal offers a weaker single-round (Level 2) scheme, on the stated premise that Level 3 "is not really compatible with airgapped signing" (Optech #317) because of the second round trip.
The two-round exchange below tests that premise for one transport in particular.
The objection as raised describes moving the transaction to the device and back twice, which is a fair cost for a microSD or file-based airgap.
For camera QR with the signer beside the machine it is a second show-and-scan: on the hardware described, about ten seconds added to a single-input signing, scaling with PSBT size rather than fixed.
Question 4 asks whether that trade is the right one for Jade.
I am not aware of any signing device that offers Level 3 anti-exfil over an airgap today — the protection exists over USB, and airgapped users get either nothing or the weaker single-round form. Merging this would make Jade the first, on the transport where users have deliberately chosen to trust the device's firmware over a cable.
Approach
Carry the three AE messages in PSBT proprietary fields, so one implementation covers both transports with no new RPC.
Per-input, keyed by 33-byte signer pubkey. Identifier
ae(0xFC 0x02 "ae"); subtypes0x00host_commitment,0x01signer_commitment,0x02host_entropy.Round is inferred from PSBT contents rather than RPC state (this is what lets the QR path work unchanged):
Device stays stateless across rounds — signer_commitment is deterministic in (privkey, sighash, host_commitment), so round 2 re-derives rather than recalls, and two independent QR scans need no session state.
Uses libwally directly (
wally_ae_host_commit_from_bytes).ECDSA only; taproot inputs bearing an
aefield are refused, not signed.The non-AE path is byte-identical (moved to
else).Verification
Patch vs
5ad8566: +223/−7 acrosssign_psbt.c,debug_set_ae_cheat.c(new, cheat modes for negative testing),dashboard.c.Java verifier, 18 libwally ground-truth vectors, in
AntiExfilVerifierTest(./gradlew :lark:test): 8 honest, 8 where the signer ignored the host entropy, 2 mismatched entropy/commitment pairings.QEMU emulator: honest sig verifies; ignore-entropy / biased-nonce / commit-A-sign-B all caught.
DIY hardware (Waveshare ESP32-S3, flashed and run on-device): same four results green.
End-to-end over airgapped QR (Sparrow host <-> DIY Jade, testnet4): the four PSBTs re-parsed independently confirm the ordering.
Round 1 returns a signer commitment with zero signatures, and the PSBT the device saw at that point carried no host_entropy field; the signature appears only in round 2, after the reveal.
The revealed entropy hashes to the round-1 host_commitment, and
sig.r == (R0 + t·G).xholds for the returned signature (canonical DER, low-S, scalars in range). All four PSBTs carry the identical unsigned tx.On signature encoding: the host verifier also requires strictly canonical DER, both scalars inside the curve order, and low S, on the same reasoning as
cc37a3bin lark — the s2c check constrains only R, so a signer left free to pick between the low and high S encodings, or to pad a DER integer, retains a covert channel worth roughly a bit per signature.This is not a constraint the patch has to satisfy specially:
wally_ec_sig_to_derover awally_ae_sig_from_bytesresult already produces canonical low-S DER, so every signature from the patched firmware passes unchanged, on-device and in QEMU. No firmware-side change was needed, and none of the 18 libwally vectors is high-S.Raw PSBTs from the testnet4 run (base64) — paste into Sparrow or
bitcoin-cli decodepsbtto check any of the above1-round1-out — 455 bytes, host_commitment only, 0 signatures
2-round1-reply — 528 bytes, signer_commitment added, still 0 signatures
3-round2-out — 600 bytes, host_entropy revealed, still 0 signatures
4-round2-reply — 708 bytes, signed, 1 signature
Jade: https://github.com/bitcoinshooter/Jade/tree/sparrow-anti-exfil-airgap
Host (Sparrow): https://github.com/bitcoinshooter/lark/tree/jade-anti-exfil-airgap , https://github.com/bitcoinshooter/sparrow/tree/jade-anti-exfil-airgap
Scope
Closes the nonce channel only.
Transaction-level channels (input/output selection, fee grinding, sighash) are out of scope here and are better handled host-side (canonical tx + assert the signed PSBT differs only by signatures).
Assumes a trusted host (Sparrow).
Testing to date is singlesig only.
Questions
Q1 and Q2 are put as recommendations rather than open questions, since they fix a wire format that is hard to change once firmware ships.
Both are easy to reverse now and awkward later.
Over QR the payload is the PSBT, so there is no side channel for RPC parameters; carrying the three AE messages in the document is what lets one implementation serve both transports.
BIP-174 provides
0xFCfor exactly this, and requires parsers to tolerate and Combiners to preserve unknown fields, so an AE-bearing PSBT still passes through tools that do not understand it.Where an implementation does drop proprietary fields, the protocol fails closed rather than silently unprotected: the host sees a round-1 reply with no signer commitment and aborts with "device may not support anti-exfil" instead of signing.
An RPC extension would give firmware explicit control of the format, but it cannot reach the QR path, which is the case anti-exfil most needs to cover.
Is that trade the right one?
ae. There is no registry for BIP-174 proprietary identifiers, and PSBTs travel between wallets, so a two-byte generic string risks colliding with an unrelated use and having fields misread mid-signing.jade-aecosts a few bytes of QR density and removes that risk.Happy to use whatever prefix you would want to own.
If the preference is for something cross-vendor rather than Jade-specific, that points at a BIP rather than a repo-local choice, and is worth saying now.
Related: is the extra round worth it to you versus the single-round Level 2 scheme, given that dropping to one round reopens nonce grinding?
CONFIG_DEBUG_MODE, or dropped?aefields keyed by pubkey) — any known pitfalls there?wally_ae_signer_commit_from_bytesdirectly rather thanwallet.c'swallet_get_signer_commitment, which was considered and not used: that helper takes a derivation path and re-derives the privkey, whereas the signing loop already holdsiter.hdkey, and there is no equivalent helper for the round-2 AE signing over a PSBT, so reusing it for the commitment only would leave the two halves asymmetric.sign_psbt.calready signs via wally directly (wally_psbt_sign_input_bip32) whilesign_tx.cuses thewallet.chelpers, so this follows the existing split.Happy to change it if you would rather the helper covered both paths.
One note on BIP-174 roles: in round 1 the device adds a proprietary field and deliberately does not sign, which is closer to Updater behaviour than Signer.
That is inherent to a commit-then-reveal exchange rather than an artifact of this encoding, but flagging it in case the role boundary matters to you.
The debug/cheat-mode code referenced is test-only and
CONFIG_DEBUG_MODE-gated; never in a release build.*Happy to open a PR if the direction fits.