Skip to content

Anti-exfil (sign-to-contract) for `sign_psbt' #332

Description

@bitcoinshooter

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.

Image

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.

  1. 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.

  2. QEMU emulator: honest sig verifies; ignore-entropy / biased-nonce / commit-A-sign-B all caught.

  3. DIY hardware (Waveshare ESP32-S3, flashed and run on-device): same four results green.

  4. 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.

  1. 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?


  1. 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.


  1. Round 2 re-deriving the signer commitment — acceptable, or bind to the tx differently?

  1. 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?


  1. Cheat modes in-tree behind CONFIG_DEBUG_MODE, or dropped?

  1. Singlesig tested only; multisig untested (signer selection by fingerprint, ae fields keyed by pubkey) — any known pitfalls there?

  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions