Blockstream / Blockstream/Jade

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

Open
#332 7 comments 1 reaction 0 assignees View on GitHub
Dominant language
C
Stars
496
Forks
127
PR merge metrics
No merged PRs in 30d

Description

*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](https://delvingbitcoin.org/t/non-interactive-anti-exfil-airgap-compatible/1081) offers a weaker single-round (Level 2) scheme, on the stated premise that Level 3 "is not really compatible with airgapped signing" ([Optech #317](https://bitcoinops.org/en/podcast/2024/08/27/)) 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
```

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

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?


2. **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.


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


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


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


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


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

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.