From 5481f58f82c2ceace267876aa267bb89997ccbba Mon Sep 17 00:00:00 2001 From: BitcoinQnA Date: Thu, 13 Aug 2026 09:22:09 +0100 Subject: [PATCH 1/2] Add air-gapped signer support Add generic air-gapped signer workflows to the desktop app for account import, wallet-policy registration, address verification, and PSBT signing over animated BC-UR or microSD. Passport Core and Prime provide the initial interoperable protocol implementation. Use bounded camera capture and decoding, visible scan progress, three distinct QR density presets, strict payload and fragment limits, and signature-only PSBT merging. Persist only public signer configuration and require explicit registration confirmation. Document the protocol, dependency purposes and licenses, security bounds, and manual test flow. Add fixtures and tests for UR, CBOR accounts, registration, address verification, multipart scanning, PSBT validation, cancellation, and density selection, plus the minimal macOS camera packaging and Linux build prerequisites. --- .github/workflows/main.yml | 4 +- .github/workflows/test-each-commit.yml | 2 +- Cargo.lock | 810 +++++++++++++-- Cargo.toml | 10 + contrib/release/macos/README.md | 2 +- contrib/release/macos/entitlements.plist | 8 + contrib/release/release.sh | 1 + contrib/release/sign.sh | 22 +- doc/passport-airgap-protocol.md | 230 +++++ doc/passport-user-guide.md | 91 ++ flake.nix | 2 + liana-gui/Cargo.toml | 14 +- liana-gui/src/airgap/animation.rs | 211 ++++ liana-gui/src/airgap/camera.rs | 863 ++++++++++++++++ liana-gui/src/airgap/device.rs | 132 +++ liana-gui/src/airgap/error.rs | 81 ++ liana-gui/src/airgap/mod.rs | 30 + liana-gui/src/airgap/passport.rs | 599 +++++++++++ liana-gui/src/airgap/payload.rs | 969 ++++++++++++++++++ liana-gui/src/airgap/session.rs | 251 +++++ liana-gui/src/airgap/ur.rs | 242 +++++ liana-gui/src/app/settings/mod.rs | 34 + liana-gui/src/app/state/airgap.rs | 796 ++++++++++++++ liana-gui/src/app/state/mod.rs | 1 + liana-gui/src/app/state/psbt.rs | 160 ++- liana-gui/src/app/state/receive.rs | 153 ++- liana-gui/src/app/state/settings/wallet.rs | 224 +++- liana-gui/src/app/view/message.rs | 5 + liana-gui/src/app/view/psbt.rs | 24 + liana-gui/src/app/view/receive/mod.rs | 2 +- liana-gui/src/app/view/receive/modals.rs | 30 + liana-gui/src/app/view/settings/mod.rs | 50 +- liana-gui/src/app/wallet.rs | 59 +- liana-gui/src/export.rs | 9 +- liana-gui/src/gui/tab.rs | 2 + liana-gui/src/installer/context.rs | 3 + liana-gui/src/installer/descriptor.rs | 9 +- liana-gui/src/installer/message.rs | 13 +- liana-gui/src/installer/mod.rs | 5 + .../installer/step/descriptor/editor/key.rs | 335 +++++- .../installer/step/descriptor/editor/mod.rs | 67 +- .../src/installer/step/descriptor/mod.rs | 215 +++- liana-gui/src/installer/view/mod.rs | 115 ++- liana-gui/src/lib.rs | 1 + liana-gui/src/window.rs | 4 +- liana-gui/test_assets/passport/README.md | 24 + .../test_assets/passport/account-mainnet.txt | 1 + .../test_assets/passport/account-testnet.txt | 1 + .../passport/address-request-mainnet.json | 1 + .../passport/address-response-mainnet.json | 1 + .../liana-multisig-testnet.descriptor | 1 + .../passport/partially-signed.psbt.base64 | 1 + .../passport/policy-registration-mainnet.json | 1 + .../test_assets/passport/unsigned.psbt.base64 | 1 + .../passport/ur-multipart-bytes.txt | 4 + .../test_assets/passport/ur-single-bytes.txt | 1 + liana-gui/tests/airgap_protocol.rs | 469 +++++++++ liana-ui/src/component/modal/mod.rs | 15 + liana/src/descriptors/mod.rs | 4 + 59 files changed, 7277 insertions(+), 138 deletions(-) create mode 100644 contrib/release/macos/entitlements.plist create mode 100644 doc/passport-airgap-protocol.md create mode 100644 doc/passport-user-guide.md create mode 100644 liana-gui/src/airgap/animation.rs create mode 100644 liana-gui/src/airgap/camera.rs create mode 100644 liana-gui/src/airgap/device.rs create mode 100644 liana-gui/src/airgap/error.rs create mode 100644 liana-gui/src/airgap/mod.rs create mode 100644 liana-gui/src/airgap/passport.rs create mode 100644 liana-gui/src/airgap/payload.rs create mode 100644 liana-gui/src/airgap/session.rs create mode 100644 liana-gui/src/airgap/ur.rs create mode 100644 liana-gui/src/app/state/airgap.rs create mode 100644 liana-gui/test_assets/passport/README.md create mode 100644 liana-gui/test_assets/passport/account-mainnet.txt create mode 100644 liana-gui/test_assets/passport/account-testnet.txt create mode 100644 liana-gui/test_assets/passport/address-request-mainnet.json create mode 100644 liana-gui/test_assets/passport/address-response-mainnet.json create mode 100644 liana-gui/test_assets/passport/liana-multisig-testnet.descriptor create mode 100644 liana-gui/test_assets/passport/partially-signed.psbt.base64 create mode 100644 liana-gui/test_assets/passport/policy-registration-mainnet.json create mode 100644 liana-gui/test_assets/passport/unsigned.psbt.base64 create mode 100644 liana-gui/test_assets/passport/ur-multipart-bytes.txt create mode 100644 liana-gui/test_assets/passport/ur-single-bytes.txt create mode 100644 liana-gui/tests/airgap_protocol.rs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7f7515294d..91aa60ea6b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: - name: clippy run: | sudo apt-get update && - sudo apt-get install --allow-downgrades libudev-dev pkg-config libvulkan-dev && + sudo apt-get install --allow-downgrades clang libclang-dev libudev-dev pkg-config libvulkan-dev && cargo clippy --all-features --all-targets -- -D warnings unit_tests: @@ -53,5 +53,5 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update && - sudo apt-get install libudev-dev libfontconfig1-dev && + sudo apt-get install clang libclang-dev libudev-dev libfontconfig1-dev && cargo test --verbose --color always -- --nocapture diff --git a/.github/workflows/test-each-commit.yml b/.github/workflows/test-each-commit.yml index 9a37fe9f4d..ca354f32c1 100644 --- a/.github/workflows/test-each-commit.yml +++ b/.github/workflows/test-each-commit.yml @@ -64,7 +64,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libudev-dev pkg-config libvulkan-dev libfontconfig1-dev + sudo apt-get install -y clang libclang-dev libudev-dev pkg-config libvulkan-dev libfontconfig1-dev - name: Show commit run: git log -1 --oneline diff --git a/Cargo.lock b/Cargo.lock index 1f37744fdf..6ba03055aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cipher", "cpufeatures", ] @@ -74,7 +74,7 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "getrandom 0.2.15", "once_cell", "version_check", @@ -266,7 +266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7569377d7062165f6f7834d9cb3051974a2d141433cc201c2f94c149e993cccf" dependencies = [ "async-trait", - "cfg-if", + "cfg-if 1.0.0", "pin-project", "rustix 0.38.44", "thiserror 1.0.69", @@ -320,7 +320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" dependencies = [ "async-lock", - "cfg-if", + "cfg-if 1.0.0", "concurrent-queue", "futures-io", "futures-lite", @@ -366,7 +366,7 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.0", "event-listener", "futures-lite", "rustix 0.38.44", @@ -393,7 +393,7 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if", + "cfg-if 1.0.0", "futures-core", "futures-io", "rustix 0.38.44", @@ -440,7 +440,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -461,7 +461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.0", "libc", "miniz_oxide", "object", @@ -551,6 +551,29 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease 0.2.34", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.98", + "which", +] + [[package]] name = "bip329" version = "0.3.0" @@ -720,6 +743,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0982261c82a50d89d1a411602afee0498b3e0debe3d36693f0c661352809639" +dependencies = [ + "hex-conservative 0.3.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -930,6 +962,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -940,6 +981,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.0" @@ -958,7 +1005,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cipher", "cpufeatures", ] @@ -1000,6 +1047,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clipboard-win" version = "5.4.0" @@ -1039,6 +1097,43 @@ dependencies = [ "x11rb", ] +[[package]] +name = "cocoa" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c49e86fc36d5704151f5996b7b3795385f50ce09e3be0f47a0cfde869681cf8" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.7.0", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "objc", +] + +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1116,13 +1211,23 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -1132,16 +1237,34 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.23.2" @@ -1151,7 +1274,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1177,6 +1300,31 @@ dependencies = [ "libc", ] +[[package]] +name = "core-media-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273bf3fc5bf51fd06a7766a84788c1540b6527130a0bce39e00567d6ab9f31f1" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "libc", + "metal 0.18.0", + "objc", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -1219,13 +1367,28 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -1325,6 +1488,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor-lite" version = "0.1.0" @@ -1352,7 +1536,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cpufeatures", "curve25519-dalek-derive", "fiat-crypto", @@ -1581,7 +1765,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -1644,7 +1828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1766,7 +1950,7 @@ version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "libredox", "windows-sys 0.59.0", @@ -1806,6 +1990,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1865,6 +2061,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1872,7 +2077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1886,6 +2091,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1901,6 +2112,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "foundation-ur" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de4d0b63162220b26a3a955478ebc02e51fe77aa38181d058c55f3d1d6664428" +dependencies = [ + "bitcoin_hashes 0.15.0", + "crc", + "heapless", + "itertools 0.10.5", + "minicbor", + "phf", + "rand_xoshiro", +] + [[package]] name = "fs2" version = "0.4.3" @@ -2046,9 +2272,11 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2057,7 +2285,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "wasi 0.13.3+wasi-0.2.2", "windows-targets 0.52.6", @@ -2106,6 +2334,12 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.16.0" @@ -2155,7 +2389,7 @@ dependencies = [ "log", "presser", "thiserror 1.0.69", - "windows", + "windows 0.58.0", ] [[package]] @@ -2230,7 +2464,7 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "crunchy", "num-traits", "zerocopy 0.8.27", @@ -2249,6 +2483,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -2292,6 +2535,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2331,6 +2584,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex_lit" version = "0.1.1" @@ -2350,7 +2612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1b71e1f4791fb9e93b9d7ee03d70b501ab48f6151432fbcadeabc30fe15396e" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.0", "libc", "pkg-config", "windows-sys 0.61.2", @@ -2463,7 +2725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", @@ -2500,7 +2762,7 @@ name = "iced_aw" version = "0.13.1" source = "git+https://github.com/wizardsardine/iced_aw?rev=488248db097769cd2269af75b5f93d5c65f45a38#488248db097769cd2269af75b5f93d5c65f45a38" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "chrono", "iced_core", "iced_fonts", @@ -2675,7 +2937,7 @@ dependencies = [ "log", "num-traits", "ouroboros", - "qrcode", + "qrcode 0.13.0", "rustc-hash 2.1.1", "thiserror 2.0.17", "unicode-segmentation", @@ -2918,7 +3180,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "mach2", ] @@ -2987,7 +3249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.0", "combine", "jni-sys", "log", @@ -3045,7 +3307,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "ecdsa", "elliptic-curve", "once_cell", @@ -3105,6 +3367,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lebe" version = "0.5.2" @@ -3139,7 +3407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e27139d540e4271fa55b67b8cb94c6f100931042dcc663db1c2395fa3ffb8599" dependencies = [ "byteorder", - "cfg-if", + "cfg-if 1.0.0", "hex", "hidapi", "ledger-transport", @@ -3236,6 +3504,8 @@ dependencies = [ "dirs", "email_address", "flate2", + "flume", + "foundation-ur", "fs2", "hex", "iced", @@ -3248,10 +3518,17 @@ dependencies = [ "lianad", "libc", "log", + "minicbor", + "nokhwa", + "nokhwa-bindings-macos", + "objc", "open", + "qrcode 0.14.1", + "quircs", "reqwest", "rfd", "rust-ini", + "rxing", "serde", "serde_json", "tar", @@ -3260,6 +3537,7 @@ dependencies = [ "tracing", "tracing-subscriber", "winresource", + "zeroize", "zip", ] @@ -3317,7 +3595,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "windows-targets 0.52.6", ] @@ -3515,7 +3793,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "rayon", ] @@ -3543,6 +3821,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e198a0ee42bdbe9ef2c09d0b9426f3b2b47d90d93a4a9b0395c4cea605e92dc0" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "log", + "objc", +] + [[package]] name = "metal" version = "0.32.0" @@ -3552,7 +3845,7 @@ dependencies = [ "bitflags 2.11.0", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3564,6 +3857,32 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minicbor" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29be4f60e41fde478b36998b88821946aafac540e53591e76db53921a0cc225b" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniscript" version = "12.3.1" @@ -3642,7 +3961,7 @@ dependencies = [ "arrayvec", "bit-set", "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.0", "cfg_aliases", "codespan-reporting", "half 2.7.1", @@ -3659,6 +3978,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3702,7 +4030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ "bitflags 1.3.2", - "cfg-if", + "cfg-if 1.0.0", "libc", ] @@ -3713,7 +4041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.0", "cfg_aliases", "libc", "memoffset", @@ -3749,6 +4077,82 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nokhwa" +version = "0.10.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d63f10b450319a0ace7aa8e0e25477d1fdb345313a97e220e886175539a1dbb" +dependencies = [ + "flume", + "image", + "nokhwa-bindings-linux", + "nokhwa-bindings-macos", + "nokhwa-bindings-windows", + "nokhwa-core", + "paste", + "thiserror 2.0.17", +] + +[[package]] +name = "nokhwa-bindings-linux" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb67e22201a53322291740ca064b20eaaade7222ef0349f312d9b37b004e1984" +dependencies = [ + "libc", + "nokhwa-core", + "v4l", +] + +[[package]] +name = "nokhwa-bindings-macos" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70d3908ea68324e44a6b3a0f885aa59e433fb1f6678839d09e0df7d226fb42d" +dependencies = [ + "block", + "cocoa-foundation", + "core-foundation 0.10.0", + "core-media-sys", + "core-video-sys", + "flume", + "nokhwa-core", + "objc", + "once_cell", +] + +[[package]] +name = "nokhwa-bindings-windows" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be28886bad8abcec3655c1f24b965b4cb596a72b23164c910c54439ce55d2a4" +dependencies = [ + "nokhwa-core", + "once_cell", + "windows 0.62.2", +] + +[[package]] +name = "nokhwa-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1cba20bebd3bd9ae22f9273ade5bbe49da3e047c8512b53fbaf8b4b9c80d496" +dependencies = [ + "bytes", + "image", + "thiserror 2.0.17", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -3774,6 +4178,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3784,6 +4202,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3814,6 +4241,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -3863,6 +4300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", + "objc_exception", ] [[package]] @@ -4135,6 +4573,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "object" version = "0.36.7" @@ -4272,7 +4719,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "redox_syscall 0.5.8", "smallvec", @@ -4291,6 +4738,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -4307,6 +4760,48 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4391,7 +4886,7 @@ version = "3.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "concurrent-queue", "hermit-abi", "pin-project-lite", @@ -4423,7 +4918,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cpufeatures", "opaque-debug", "universal-hash", @@ -4469,6 +4964,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.98", +] + [[package]] name = "proc-macro-crate" version = "3.2.0" @@ -4552,7 +5057,7 @@ dependencies = [ "log", "multimap", "petgraph", - "prettyplease", + "prettyplease 0.1.25", "prost 0.11.9", "prost-types", "regex", @@ -4611,6 +5116,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" @@ -4626,6 +5137,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "quircs" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71dee9e56835add6f9c26227e6c690b19eda9414f5affc666c3ec833ba140dc" +dependencies = [ + "num-derive", + "num-traits", + "thiserror 2.0.17", +] + [[package]] name = "quote" version = "1.0.38" @@ -4694,6 +5216,15 @@ dependencies = [ "getrandom 0.3.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "range-alloc" version = "0.1.4" @@ -4718,7 +5249,7 @@ dependencies = [ "av1-grain", "bitstream-io", "built", - "cfg-if", + "cfg-if 1.0.0", "interpolate_name", "itertools 0.12.1", "libc", @@ -4955,7 +5486,7 @@ dependencies = [ "ashpd", "block2", "core-foundation 0.10.0", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "js-sys", "log", "objc2 0.5.2", @@ -4986,7 +5517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.0", "getrandom 0.2.15", "libc", "spin", @@ -5020,7 +5551,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "ordered-multimap", ] @@ -5167,6 +5698,22 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "rxing" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6609a7ccb6435312dd3a2bff9924cdbb1c96050510fff30676c3d7b8b0045739" +dependencies = [ + "chrono", + "codepage-437", + "encoding_rs", + "num", + "once_cell", + "regex", + "thiserror 2.0.17", + "unicode-segmentation", +] + [[package]] name = "ryu" version = "1.0.19" @@ -5373,9 +5920,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.0", "core-foundation 0.10.0", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "io-kit-sys", "libudev", "mach2", @@ -5391,7 +5938,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cpufeatures", "digest", ] @@ -5402,7 +5949,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cpufeatures", "digest", ] @@ -5628,6 +6175,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spirv" @@ -5767,7 +6317,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -5806,7 +6356,7 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "fastrand", "getrandom 0.3.1", "once_cell", @@ -5869,7 +6419,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "once_cell", ] @@ -5902,7 +6452,7 @@ dependencies = [ "arrayref", "arrayvec", "bytemuck", - "cfg-if", + "cfg-if 1.0.0", "log", "png", "tiny-skia-path", @@ -6001,7 +6551,7 @@ version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "futures", "log", "mio-serial", @@ -6282,9 +6832,9 @@ checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-vo" @@ -6389,6 +6939,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -6464,7 +7034,7 @@ version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -6490,7 +7060,7 @@ version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "js-sys", "once_cell", "wasm-bindgen", @@ -6723,7 +7293,7 @@ checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", "bitflags 2.11.0", - "cfg-if", + "cfg-if 1.0.0", "cfg_aliases", "document-features", "hashbrown 0.16.0", @@ -6816,7 +7386,7 @@ dependencies = [ "bitflags 2.11.0", "block", "bytemuck", - "cfg-if", + "cfg-if 1.0.0", "cfg_aliases", "core-graphics-types 0.2.0", "glow", @@ -6830,7 +7400,7 @@ dependencies = [ "libc", "libloading", "log", - "metal", + "metal 0.32.0", "naga", "ndk-sys", "objc", @@ -6848,7 +7418,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-types", - "windows", + "windows 0.58.0", "windows-core 0.58.0", ] @@ -6933,6 +7503,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -6948,13 +7539,37 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-strings", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -6966,6 +7581,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "windows-interface" version = "0.58.0" @@ -6977,12 +7603,33 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -6992,16 +7639,34 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7093,6 +7758,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7241,7 +7915,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", @@ -7292,7 +7966,7 @@ version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "windows-sys 0.48.0", ] diff --git a/Cargo.toml b/Cargo.toml index f5c2596c85..d829fdb028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,16 @@ flate2 = { version = "1.0", default-features = false } winresource = "0.1.24" unicode-segmentation = "1.0" bitcoin = "0.32" +foundation-ur = "=0.4.0" +minicbor = { version = "0.24", features = ["alloc", "std"] } +nokhwa = { version = "=0.10.11", default-features = false, features = ["input-native"] } +nokhwa-bindings-macos = "=0.2.4" +flume = "0.11" +quircs = "=0.10.3" +qrcode = { version = "0.14", default-features = false } +rxing = { version = "=0.9.2", default-features = false, features = ["decoders", "encoding_rs", "qrcode"] } +zeroize = "1.8" +objc = "0.2" # Routed to our fork for native dashed/dotted border styles. [patch.crates-io] diff --git a/contrib/release/macos/README.md b/contrib/release/macos/README.md index 82074e6cb9..2a4415585e 100644 --- a/contrib/release/macos/README.md +++ b/contrib/release/macos/README.md @@ -49,7 +49,7 @@ tar -xzf apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz Sign the packaged application using the `sign` command (mind `--code-signature-flags for the necessary hardened runtime): ``` -./apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign sign --code-signature-flags runtime --pem-source wizardsardine_liana.key --der-source antoine_devid_liana_codesigning.cer Liana.app +./apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign sign --code-signature-flags runtime --entitlements-xml-file entitlements.plist --pem-source wizardsardine_liana.key --der-source antoine_devid_liana_codesigning.cer Liana.app ``` You can see the chain of certificates was applied using the `diff-signatures` command against another bundle. The best way to verify the signature is by using the `codesign` command on a Mac. diff --git a/contrib/release/macos/entitlements.plist b/contrib/release/macos/entitlements.plist new file mode 100644 index 0000000000..7a81164ad6 --- /dev/null +++ b/contrib/release/macos/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.camera + + + diff --git a/contrib/release/release.sh b/contrib/release/release.sh index e18c7e7ac4..fe4da0f61d 100755 --- a/contrib/release/release.sh +++ b/contrib/release/release.sh @@ -128,6 +128,7 @@ if [ "$TARGET" = "liana" ]; then unzip ../contrib/release/macos/Liana.app.zip sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" ./Liana.app/Contents/Info.plist + sed -i '/<\/dict>/i\ NSCameraUsageDescription\n Liana uses the camera only to scan QR codes from air-gapped signing devices.\n' ./Liana.app/Contents/Info.plist cp "$NIX_BUILD_DIR/universal2-apple-darwin/liana-gui" ./Liana.app/Contents/MacOS/Liana zip_archive "$LIANA_PREFIX-macos-noncodesigned.zip" Liana.app mv "$LIANA_PREFIX-macos-noncodesigned.zip" "$RELEASE_DIR/" diff --git a/contrib/release/sign.sh b/contrib/release/sign.sh index f09b07beef..70777a37c0 100755 --- a/contrib/release/sign.sh +++ b/contrib/release/sign.sh @@ -129,12 +129,22 @@ sign_with_rcodesign() { chmod u+w "./$APP_BUNDLE/Contents/MacOS/LianaBusiness" fi - rcodesign sign \ - --digest sha256 \ - --code-signature-flags runtime \ - --pem-source "$CODESIGN_KEY" \ - --der-source "$CODESIGN_CERT" \ - "$APP_BUNDLE/" + if [ "$TARGET" = "liana" ]; then + rcodesign sign \ + --digest sha256 \ + --code-signature-flags runtime \ + --entitlements-xml-file ../contrib/release/macos/entitlements.plist \ + --pem-source "$CODESIGN_KEY" \ + --der-source "$CODESIGN_CERT" \ + "$APP_BUNDLE/" + else + rcodesign sign \ + --digest sha256 \ + --code-signature-flags runtime \ + --pem-source "$CODESIGN_KEY" \ + --der-source "$CODESIGN_CERT" \ + "$APP_BUNDLE/" + fi rcodesign notary-submit \ --max-wait-seconds 600 \ diff --git a/doc/passport-airgap-protocol.md b/doc/passport-airgap-protocol.md new file mode 100644 index 0000000000..121790f49c --- /dev/null +++ b/doc/passport-airgap-protocol.md @@ -0,0 +1,230 @@ +# Passport air-gapped protocol v1 + +This document defines the wire contract between Liana and Passport. It is +implemented independently of installer, wallet, camera, and signing screens in +`liana-gui/src/airgap`. + +The protocol preserves Liana's existing connected hardware-wallet behavior. +Passport is an asynchronous air-gapped signing method and is not represented as +an `async-hwi` USB device. + +## Transport matrix + +| Operation | QR | microSD | +| --- | --- | --- | +| Passport account import | `ur:crypto-account` | UTF-8 descriptor key | +| Wallet-policy registration | `ur:bytes` containing UTF-8 JSON | UTF-8 JSON | +| Address-verification request/response | `ur:bytes` containing UTF-8 JSON | UTF-8 JSON | +| PSBT request/response | `ur:crypto-psbt` | binary PSBT | + +BC-UR uses bytewords and fountain encoding. Single-part and multipart values +carry the same registry CBOR. `bytes` and `crypto-psbt` wrap their value in a +CBOR byte string. `crypto-account` uses the BCR-2020-015 account map and the +following deliberately narrow profile: + +- one top-level master fingerprint; +- a `crypto-output` matching `wsh(cosigner(crypto-hdkey))`; +- public key material and a 32-byte chain code; +- origin `m/48'/coin_type'/account'/2'`; +- matching Bitcoin network in `crypto-coin-info` and the origin coin type; +- no child derivation expression and no private key material. + +The current Passport microSD fallback is one line: + +```text +[fingerprint/48'/coin_type'/account'/2']xpub-or-tpub +``` + +Liana validates the complete origin and extended public key. Fingerprint-only +matching is not sufficient. + +## Wallet-policy registration + +The authoritative registration format is the Passport envelope, not +`crypto-output`: + +```json +{ + "format": "passport-wallet-policy", + "version": 1, + "name": "Wallet name", + "network": "BTC", + "template": "wsh(or_d(pk(@0/<0;1>/*),and_v(v:pkh(@1/<0;1>/*),older(52560))))", + "keys": ["[abcdef01]xpub...", "[abcdef02]xpub..."], + "policy_id": "64 lowercase hexadecimal characters" +} +``` + +`network` is `BTC` for mainnet and `TBTC` for non-mainnet Bitcoin networks. +The descriptor template and key expressions are canonical ASCII. Key aliases +and the wallet name do not participate in policy identity. Liana maps a wallet +alias to Passport's printable 20-character display limit (falling back to +`Liana` when necessary); this display-only mapping cannot alter the policy ID. + +Policy identity is: + +```text +SHA256( + "Passport Wallet Policy\0" || + 0x01 || + compact_size(network.len) || network || + compact_size(template.len) || template || + compact_size(keys.len) || + for each key: compact_size(key.len) || key +) +``` + +Liana reconstructs and reparses the full descriptor before export. Its existing +canonical eight-character descriptor checksum is the user-facing policy +checksum. No second descriptor hash is introduced. + +## Address verification + +Request: + +```json +{ + "format": "passport-address-verification", + "version": 1, + "network": "TBTC", + "policy_id": "...", + "descriptor_checksum": "abcdefgh", + "branch": 0, + "index": 7 +} +``` + +Response: + +```json +{ + "format": "passport-address-verification-response", + "version": 1, + "network": "TBTC", + "policy_id": "...", + "descriptor_checksum": "abcdefgh", + "branch": 0, + "index": 7, + "address": "tb1...", + "fingerprint": "1234abcd" +} +``` + +The request intentionally does not contain Liana's expected address. Passport +derives from its registered policy. Liana accepts the response only when the +network, policy identity, checksum, branch, index, independently derived +address, and full fingerprint all match the active request. + +## PSBT invariant + +Both QR directions use `crypto-psbt`; microSD uses binary BIP174 PSBT. Returned +data is never a replacement transaction record. Before signature merge, Liana +must require the same unsigned transaction and input/output counts, retain all +canonical unknown/proprietary fields and existing signatures, and admit only +new signatures for keys expected by the wallet. + +## Decoder resource limits + +The default limits intentionally match Passport Core's own UR decoder where +possible: + +| Resource | Limit | +| --- | ---: | +| Decoded registry CBOR | 24 KiB | +| Encoded registry CBOR sent to Passport | 24 KiB | +| Declared fountain fragments | 128 | +| QR fragment characters | 1,408 | +| Decoded fragment CBOR | 700 bytes | +| JSON envelope | 4,096 bytes | +| JSON nesting | 16 | +| Descriptor | 4,096 ASCII bytes | +| Policy template | 2,048 ASCII bytes | +| Policy keys | 20 | +| Scan session | 120 seconds | +| Imported binary PSBT file | 8 MiB | + +The decoder checks declared message length, padded allocation size, fragment +count, fragment size, and expected UR type before handing data to the fountain +decoder. Duplicate fragments are tolerated. Inconsistent type, message length, +fragment geometry, checksum, or fountain session is rejected. Cancellation +clears decoder state; restart begins a new deadline and session. + +Camera implementations must not persist frames, and must release the camera on +success, cancellation, timeout, and error. They are downstream consumers of +this module and may apply smaller limits, never larger ones without a protocol +review. + +The QR ceiling is the shared Passport decoder limit, not a PSBT-format limit. +When a PSBT cannot fit, Liana rejects QR presentation before generating an +unscannable sequence and keeps the bounded binary microSD workflow available. + +## Versioning + +Unknown envelope fields are rejected in v1. A change that adds fields or alters +identity, checksum, network, descriptor, or response-binding semantics requires +a new envelope version. Local explanatory metadata such as signer aliases must +not change policy identity. + +## Persisted signer and exchange states + +Wallet settings add a backwards-compatible `airgapped_signers` array. Each +record contains only the signer kind, complete master fingerprint, optional +alias, public BIP48 account key, and per-wallet registration state. Existing +settings without this field deserialize to an empty array. No seed, private +extended key, signature, PSBT, or camera frame is persisted there. + +Registration moves from `NotRegistered` to `Exported` only after the user +confirms completion on the signer. The exported state stores the active +descriptor checksum. Loading a wallet invalidates a state whose checksum +differs from the canonical descriptor. A QR/file exchange itself is transient: +reopening the operation recreates the same bound request from the persisted +wallet and canonical PSBT, which makes cancellation and an application restart +safe. + +Wallets created before this metadata existed remain usable. The registration +picker reconstructs candidate QR signers from eligible public BIP48 account +keys already committed to the descriptor, excluding known hot, USB, and +provider-managed keys. It persists a reconstructed record only after explicit +registration confirmation. + +## Camera and packaging + +The scanner uses Nokhwa's Media Foundation and V4L2 backends on Windows and +Linux. On macOS it uses Nokhwa's AVFoundation bindings directly so AVFoundation +can negotiate a 720p session without taking the unsupported device-format lock +used by Nokhwa's generic camera wrapper. Quirc performs the fast-path QR decode; +RXing supplies the inverted/low-quality fallback. All RGB frames stay in memory. +A bounded worker owns the native stream and is joined on success, cancellation, +timeout, failure, modal close, or drop. Preview buffers and UR state are then +released; no frame is written to disk. macOS release bundles include +`NSCameraUsageDescription`. Linux release builders need the V4L2/libclang +development inputs required by Nokhwa's native backend. + +### Direct dependency rationale + +All added direct dependencies use permissive licenses. Exact resolved versions +and the transitive dependency graph remain locked in `Cargo.lock`. + +| Dependency | License | Scope | Reason | +| --- | --- | --- | --- | +| `foundation-ur` | MIT | all desktop targets | BC-UR bytewords and fountain encoding/decoding | +| `minicbor` | BlueOak-1.0.0 | all desktop targets | bounded registry-CBOR parsing and encoding | +| `nokhwa` | Apache-2.0 | all desktop targets | camera enumeration, permission handling, and native Windows/Linux capture | +| `quircs` | MIT | all desktop targets | fast in-memory QR detection and decoding | +| `rxing` | Apache-2.0 | all desktop targets | robust inverted and difficult-image QR fallback | +| `zeroize` | Apache-2.0 OR MIT | all desktop targets | overwrite owned animated PSBT QR strings on release | +| `nokhwa-bindings-macos` | Apache-2.0 | macOS only | negotiated AVFoundation capture | +| `flume` | Apache-2.0 OR MIT | macOS only | AVFoundation callback transport | +| `objc` | MIT | macOS only | two typed AVFoundation session-preset messages | +| `qrcode` | MIT OR Apache-2.0 | tests only | deterministic synthetic camera frames | + +## Threat model + +Camera frames, QR strings, and imported files are untrusted. The transport +layer checks type, size, fountain geometry, JSON shape/depth, canonical policy +identity, network, descriptor checksum, and response binding before use. A +returned PSBT is not trusted as a replacement: Liana verifies every newly added +ECDSA or Taproot signature, rejects unexpected keys or leaves, preserves all +existing signatures and non-signature maps, and merges only verified signature +fields into its canonical PSBT. Passport independently derives addresses from +the registered policy; the coordinator never supplies the address as proof. diff --git a/doc/passport-user-guide.md b/doc/passport-user-guide.md new file mode 100644 index 0000000000..53cf55c830 --- /dev/null +++ b/doc/passport-user-guide.md @@ -0,0 +1,91 @@ +# Using Passport with Liana + +Liana supports Passport Core and Passport Prime as air-gapped signers. The +workflow uses animated QR codes or microSD and never requires USB, copying an +xpub, or editing a descriptor by hand. + +## Add a Passport key + +1. On Passport, export a Liana account for the correct Bitcoin network and + account number. Choose QR or microSD. +2. In Liana's wallet installer, choose **Passport** for the policy key slot. +3. Scan the `crypto-account` QR, or import the exported descriptor-key file. +4. Compare the complete master fingerprint, BIP48 origin, network, and account + number before confirming. + +Repeat this for each Passport used by the policy. The same account key may be +used in mutually exclusive immediate and recovery paths; Liana determines the +threshold and timelock from the completed wallet policy. + +## Register the wallet policy + +After the complete descriptor is built, Liana shows its eight-character +**Policy checksum**. This checksum—not the wallet name—is the identity users +must compare. + +1. In the installer's registration step, select each Passport and scan the + animated policy QR or export the policy JSON to microSD. Alternatively, + finish installation and open **Settings → Wallet → Air-gapped signers → + Register policy**. +2. Review the policy and exact checksum on Passport, then confirm it. +3. Return to Liana and select **Done**. Liana records the registration for the + current descriptor. + +A descriptor change makes the registration stale and requires registration +again. + +The registration states mean: + +- **Not registered** — Liana has no confirmation that the current policy was + registered on this signer. +- **Registration completed** — the current policy was registered on Passport. + +If the descriptor changes, Liana clears the completed state and requires the +policy to be registered again. + +## Verify a receive address + +1. Reveal a receive address in Liana and select **Verify**. +2. Select the configured Passport. +3. Show the request as an animated QR or save it to microSD. +4. Passport looks up the registered policy, independently derives the selected + branch and index, and displays the complete address. +5. Compare the address and return Passport's confirmation by QR or microSD. + +Liana accepts the confirmation only when the network, policy identity, +checksum, branch, index, address, and Passport fingerprint all match. + +## Sign a transaction + +1. Create and review the transaction normally in Liana. +2. Select **Sign**, then the Passport required by the active spending path. +3. Show the `crypto-psbt` animated QR or export the binary PSBT to microSD. +4. Review and sign on Passport. +5. Scan the returned `crypto-psbt` QR or import `signed.psbt`. + +If Liana reports that a PSBT exceeds Passport's QR limit, choose microSD on the +same exchange screen. This is a transport-size fallback; it does not change the +transaction or wallet policy. + +Liana rejects a returned PSBT if the unsigned transaction changed, an existing +signature disappeared, a new signature is invalid or belongs to an unexpected +key, or the selected Passport did not add a signature. Signer-side metadata +normalization is discarded: Liana keeps its original non-signature PSBT fields +and merges only verified signatures. For multisig wallets, repeat the same flow +with additional signers until the chosen path is complete, then finalize and +broadcast normally. + +## Camera and privacy + +Liana asks for camera access only while a scanner is open, never writes frames +to disk, and releases the camera on success, cancellation, timeout, or error. +Animated QR codes reveal the public wallet policy or transaction details to +anyone who can see them. Use microSD in environments where displaying those +details is inappropriate. + +If camera access is denied or unavailable, use the microSD actions on the same +screen. If a Passport is replaced or restored with a different seed or +passphrase, import its account again and verify the full master fingerprint +and BIP48 xpub before registering the policy. A restored Passport with the same +seed and passphrase can reuse the public account key, but the wallet policy must +still be present on that device; re-register it if Passport reports it missing. diff --git a/flake.nix b/flake.nix index 1d7525a912..35550345d9 100644 --- a/flake.nix +++ b/flake.nix @@ -41,6 +41,8 @@ # Common build inputs for all shells commonBuildInputs = with pkgs; [ expat + clang + libclang fontconfig freetype freetype.dev diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index df81ed6b6c..7432a9f46b 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -33,7 +33,7 @@ iced_runtime = { workspace = true } # Used to verify RFC-compliance of an email email_address = { workspace = true } -tokio = { workspace = true, features = ["signal"] } +tokio = { workspace = true, features = ["signal", "sync", "time"] } async-fd-lock = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -55,6 +55,12 @@ chrono = { workspace = true } libc = { workspace = true } base64 = { workspace = true } bitcoin_hashes = { workspace = true } +foundation-ur = { workspace = true } +minicbor = { workspace = true } +nokhwa = { workspace = true } +quircs = { workspace = true } +rxing = { workspace = true } +zeroize = { workspace = true } reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls", "stream"] } rust-ini = { workspace = true } rfd = { workspace = true } @@ -68,6 +74,11 @@ encrypted_backup = { workspace = true } [target.'cfg(windows)'.dependencies] zip = { workspace = true, default-features = false, features = ["bzip2", "deflate"] } +[target.'cfg(target_os = "macos")'.dependencies] +flume = { workspace = true } +nokhwa-bindings-macos = { workspace = true } +objc = { workspace = true } + [target.'cfg(unix)'.dependencies] tar = { workspace = true, default-features = false } flate2 = { workspace = true, default-features = false } @@ -82,3 +93,4 @@ winresource = { workspace = true } [dev-dependencies] tokio = {workspace = true, features = ["rt", "macros"]} +qrcode = { workspace = true } diff --git a/liana-gui/src/airgap/animation.rs b/liana-gui/src/airgap/animation.rs new file mode 100644 index 0000000000..fb8e2b70e7 --- /dev/null +++ b/liana-gui/src/airgap/animation.rs @@ -0,0 +1,211 @@ +use std::time::{Duration, Instant}; + +use zeroize::Zeroize; + +use super::{EncodedUr, Error, UrType}; + +/// Snapshot used by presentation layers without exposing the full frame set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnimationState { + pub frame: usize, + pub total_frames: usize, + pub paused: bool, +} + +/// Owns one deterministic UR cycle and advances it without background work. +/// +/// UI layers drive this from their normal tick subscription. Keeping animation +/// state synchronous avoids a thread retaining PSBT fragments after a modal is +/// closed. `clear` and `Drop` overwrite every owned frame before releasing it. +pub struct AnimatedQr { + ur_type: UrType, + frames: Vec, + interval: Duration, + started_at: Instant, + paused_at: Option, + paused_duration: Duration, +} + +impl AnimatedQr { + pub fn new(encoded: EncodedUr, frames_per_second: u8) -> Result { + Self::new_at(encoded, frames_per_second, Instant::now()) + } + + fn new_at(encoded: EncodedUr, frames_per_second: u8, now: Instant) -> Result { + if encoded.frames.is_empty() { + return Err(Error::Empty); + } + if !(1..=20).contains(&frames_per_second) { + return Err(Error::InvalidUr( + "QR animation speed must be between 1 and 20 frames per second".to_owned(), + )); + } + Ok(Self { + ur_type: encoded.ur_type, + frames: encoded.frames, + interval: Duration::from_secs_f64(1.0 / f64::from(frames_per_second)), + started_at: now, + paused_at: None, + paused_duration: Duration::ZERO, + }) + } + + pub fn ur_type(&self) -> UrType { + self.ur_type + } + + pub fn frame(&self) -> Option<&str> { + self.frame_at(Instant::now()) + } + + pub fn frame_at(&self, now: Instant) -> Option<&str> { + let index = self.frame_index_at(now)?; + self.frames.get(index).map(String::as_str) + } + + pub fn state(&self) -> AnimationState { + self.state_at(Instant::now()) + } + + pub fn state_at(&self, now: Instant) -> AnimationState { + AnimationState { + frame: self.frame_index_at(now).unwrap_or(0), + total_frames: self.frames.len(), + paused: self.paused_at.is_some(), + } + } + + pub fn pause(&mut self) { + self.pause_at(Instant::now()); + } + + pub fn pause_at(&mut self, now: Instant) { + if self.paused_at.is_none() { + self.paused_at = Some(now); + } + } + + pub fn resume(&mut self) { + self.resume_at(Instant::now()); + } + + pub fn resume_at(&mut self, now: Instant) { + if let Some(paused_at) = self.paused_at.take() { + self.paused_duration = self + .paused_duration + .saturating_add(now.saturating_duration_since(paused_at)); + } + } + + pub fn restart(&mut self) { + self.restart_at(Instant::now()); + } + + pub fn restart_at(&mut self, now: Instant) { + self.started_at = now; + self.paused_at = None; + self.paused_duration = Duration::ZERO; + } + + pub fn clear(&mut self) { + self.frames.zeroize(); + self.frames.clear(); + self.paused_at = None; + self.paused_duration = Duration::ZERO; + } + + fn frame_index_at(&self, now: Instant) -> Option { + if self.frames.is_empty() { + return None; + } + let effective_now = self.paused_at.unwrap_or(now); + let elapsed = effective_now + .saturating_duration_since(self.started_at) + .saturating_sub(self.paused_duration); + let ticks = elapsed.as_nanos() / self.interval.as_nanos(); + Some((ticks % self.frames.len() as u128) as usize) + } +} + +impl Drop for AnimatedQr { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encoded(frames: &[&str]) -> EncodedUr { + EncodedUr { + ur_type: UrType::CryptoPsbt, + frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), + } + } + + #[test] + fn single_frame_is_stable() { + let now = Instant::now(); + let animation = AnimatedQr::new_at(encoded(&["one"]), 5, now).unwrap(); + assert_eq!( + animation.frame_at(now + Duration::from_secs(60)), + Some("one") + ); + } + + #[test] + fn multipart_cycles_deterministically() { + let now = Instant::now(); + let animation = AnimatedQr::new_at(encoded(&["one", "two", "three"]), 5, now).unwrap(); + assert_eq!(animation.frame_at(now), Some("one")); + assert_eq!( + animation.frame_at(now + Duration::from_millis(200)), + Some("two") + ); + assert_eq!( + animation.frame_at(now + Duration::from_millis(600)), + Some("one") + ); + } + + #[test] + fn pause_resume_and_restart_preserve_expected_frame() { + let now = Instant::now(); + let mut animation = AnimatedQr::new_at(encoded(&["one", "two", "three"]), 5, now).unwrap(); + animation.pause_at(now + Duration::from_millis(250)); + assert_eq!( + animation.frame_at(now + Duration::from_secs(5)), + Some("two") + ); + animation.resume_at(now + Duration::from_secs(5)); + assert_eq!( + animation.frame_at(now + Duration::from_millis(5_100)), + Some("two") + ); + assert_eq!( + animation.frame_at(now + Duration::from_millis(5_150)), + Some("three") + ); + animation.restart_at(now + Duration::from_secs(6)); + assert_eq!( + animation.frame_at(now + Duration::from_secs(6)), + Some("one") + ); + } + + #[test] + fn clear_removes_owned_sensitive_frames() { + let now = Instant::now(); + let mut animation = AnimatedQr::new_at(encoded(&["secret"]), 5, now).unwrap(); + animation.clear(); + assert_eq!(animation.frame_at(now), None); + assert_eq!(animation.state_at(now).total_frames, 0); + } + + #[test] + fn rejects_unsafe_animation_rates() { + assert!(AnimatedQr::new(encoded(&["one"]), 0).is_err()); + assert!(AnimatedQr::new(encoded(&["one"]), 21).is_err()); + } +} diff --git a/liana-gui/src/airgap/camera.rs b/liana-gui/src/airgap/camera.rs new file mode 100644 index 0000000000..c2231debc2 --- /dev/null +++ b/liana-gui/src/airgap/camera.rs @@ -0,0 +1,863 @@ +use std::{ + convert::TryFrom, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError}, + Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "macos")] +use nokhwa::utils::FrameFormat; +use nokhwa::utils::{ApiBackend, CameraFormat, CameraIndex}; +use rxing::{BarcodeFormat, DecodeHints}; + +#[cfg(not(target_os = "macos"))] +use nokhwa::{ + utils::{RequestedFormat, RequestedFormatType}, + Camera, +}; + +#[cfg(any(not(target_os = "macos"), test))] +use nokhwa::pixel_format::{FormatDecoder, RgbFormat}; + +#[cfg(target_os = "macos")] +use { + flume::{Receiver as FrameReceiver, Sender as FrameSender}, + nokhwa_bindings_macos::{ + AVCaptureDevice, AVCaptureDeviceInput, AVCaptureSession, AVCaptureVideoCallback, + AVCaptureVideoDataOutput, + }, + objc::{ + msg_send, + runtime::{Object, BOOL, YES}, + sel, sel_impl, + }, + std::ffi::CString, +}; + +use super::{DecodeProgress, ScanLimits, UrDecodeSession, UrPayload, UrType}; + +#[cfg(any(not(target_os = "macos"), test))] +const TARGET_CAPTURE_WIDTH: u32 = 1280; +#[cfg(any(not(target_os = "macos"), test))] +const TARGET_CAPTURE_HEIGHT: u32 = 720; +#[cfg(any(not(target_os = "macos"), test))] +const TARGET_CAPTURE_FPS: u32 = 30; +#[cfg(any(not(target_os = "macos"), test))] +const MIN_REALTIME_FPS: u32 = 24; +const PREVIEW_MAX_WIDTH: u32 = 640; +const PREVIEW_MAX_HEIGHT: u32 = 480; +const PREVIEW_INTERVAL: Duration = Duration::from_millis(33); +const DECODE_INTERVAL: Duration = Duration::from_millis(200); +const EVENT_QUEUE: usize = 3; + +#[cfg(target_os = "macos")] +#[link(name = "AVFoundation", kind = "framework")] +extern "C" { + static AVCaptureSessionPreset1280x720: *mut Object; +} + +#[cfg(target_os = "macos")] +type NativeFrame = (Vec, FrameFormat, Option); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CameraDescriptor { + pub index: CameraIndex, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CameraFailure { + PermissionDenied, + PermissionTimedOut, + Unavailable, + Busy, + Capture(String), + InvalidFrame, +} + +impl std::fmt::Display for CameraFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PermissionDenied => write!(formatter, "camera permission denied"), + Self::PermissionTimedOut => write!(formatter, "camera permission request timed out"), + Self::Unavailable => write!(formatter, "no camera is available"), + Self::Busy => write!(formatter, "camera is already in use"), + Self::Capture(error) => write!(formatter, "camera capture failed: {error}"), + Self::InvalidFrame => write!(formatter, "camera returned an invalid frame"), + } + } +} + +impl std::error::Error for CameraFailure {} + +#[derive(Debug, Clone, PartialEq)] +pub enum CameraEvent { + Preview { + width: u32, + height: u32, + rgba: Vec, + }, + Progress { + estimated: f32, + detected_frames: u32, + }, + Rejected(String), + Complete(UrPayload), + Failure(CameraFailure), +} + +/// Requests camera access. On non-macOS platforms the callback completes +/// immediately; macOS uses AVFoundation's permission callback. +fn initialize_camera(callback: impl Fn(bool) + Send + Sync + 'static) { + nokhwa::nokhwa_initialize(callback); +} + +/// Requests access without blocking Iced's update loop and returns the cameras +/// that can be offered to the user. The callback is bounded because some +/// platform backends can fail to answer when their permission service is +/// unavailable. +pub async fn request_camera_access() -> Result, CameraFailure> { + if camera_permission_granted() { + return list_cameras(); + } + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = Arc::new(Mutex::new(Some(sender))); + initialize_camera(move |granted| { + if let Some(sender) = sender + .lock() + .expect("camera permission lock poisoned") + .take() + { + let _ = sender.send(granted); + } + }); + let granted = tokio::time::timeout(Duration::from_secs(30), receiver) + .await + .map_err(|_| CameraFailure::PermissionTimedOut)? + .map_err(|_| CameraFailure::Unavailable)?; + if !granted { + return Err(CameraFailure::PermissionDenied); + } + list_cameras() +} + +fn camera_permission_granted() -> bool { + nokhwa::nokhwa_check() +} + +fn list_cameras() -> Result, CameraFailure> { + nokhwa::query(ApiBackend::Auto) + .map_err(map_camera_error) + .map(|cameras| { + cameras + .into_iter() + .map(|camera| CameraDescriptor { + index: camera.index().clone(), + name: camera.human_name(), + }) + .collect() + }) +} + +/// Owns a camera worker. Dropping or cancelling it stops the capture loop, +/// clears the UR session, and closes the native stream through `Camera::drop`. +pub struct CameraScanner { + stop: Arc, + events: Receiver, + worker: Option>, +} + +impl CameraScanner { + pub fn start( + index: CameraIndex, + expected: UrType, + limits: ScanLimits, + ) -> Result { + if !camera_permission_granted() { + return Err(CameraFailure::PermissionDenied); + } + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let (sender, events) = mpsc::sync_channel(EVENT_QUEUE); + let worker = thread::Builder::new() + .name("liana-qr-camera".to_owned()) + .spawn(move || run_camera(index, expected, limits, worker_stop, sender)) + .map_err(|error| CameraFailure::Capture(error.to_string()))?; + Ok(Self { + stop, + events, + worker: Some(worker), + }) + } + + pub fn try_recv(&self) -> Result { + self.events.try_recv() + } + + pub fn cancel(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +impl Drop for CameraScanner { + fn drop(&mut self) { + self.cancel(); + } +} + +fn run_camera( + index: CameraIndex, + expected: UrType, + limits: ScanLimits, + stop: Arc, + sender: SyncSender, +) { + let mut camera = match open_camera(index) { + Ok(camera) => camera, + Err(failure) => { + send_terminal_event(&sender, &stop, CameraEvent::Failure(failure)); + return; + } + }; + if let Err(failure) = start_camera_stream(&mut camera) { + send_terminal_event(&sender, &stop, CameraEvent::Failure(failure)); + return; + } + + let mut ur = UrDecodeSession::new(expected, limits); + let session_started = Instant::now(); + let mut qr = quircs::Quirc::default(); + let mut last_preview = Instant::now() - PREVIEW_INTERVAL; + let mut last_decode = Instant::now() - DECODE_INTERVAL; + let mut detected_frames = 0u32; + while !stop.load(Ordering::Acquire) { + if session_started.elapsed() > limits.timeout { + send_terminal_event( + &sender, + &stop, + CameraEvent::Failure(CameraFailure::Capture(super::Error::TimedOut.to_string())), + ); + break; + } + let (width, height, raw) = match read_camera_frame_rgb(&mut camera, &stop) { + Ok(frame) => frame, + Err(failure) => { + send_terminal_event(&sender, &stop, CameraEvent::Failure(failure)); + break; + } + }; + let now = Instant::now(); + let decode_due = now.duration_since(last_decode) >= DECODE_INTERVAL; + let preview_due = now.duration_since(last_preview) >= PREVIEW_INTERVAL; + if !decode_due && !preview_due { + continue; + } + if decode_due { + last_decode = now; + let Some(luma) = rgb_to_luma(width, height, &raw) else { + send_terminal_event( + &sender, + &stop, + CameraEvent::Failure(CameraFailure::InvalidFrame), + ); + break; + }; + + for value in decode_qr_frame(&mut qr, width as usize, height as usize, &luma) { + let Ok(value) = value else { + continue; + }; + match ur.receive(&value) { + Ok(DecodeProgress::Incomplete { estimated }) => { + detected_frames = detected_frames.saturating_add(1); + let _ = sender.try_send(CameraEvent::Progress { + estimated, + detected_frames, + }); + } + Ok(DecodeProgress::Complete(payload)) => { + send_terminal_event(&sender, &stop, CameraEvent::Complete(payload)); + stop.store(true, Ordering::Release); + break; + } + Err(super::Error::Empty | super::Error::InvalidUr(_)) => { + // A camera may see unrelated text or ordinary QR codes. + // They are not part of this bounded UR session. + } + Err(error) => { + if matches!(error, super::Error::MixedSession) { + ur.restart(); + } + let _ = sender.try_send(CameraEvent::Rejected(error.to_string())); + } + } + } + } + + if preview_due && !stop.load(Ordering::Acquire) { + last_preview = now; + let Some((preview_width, preview_height, rgba)) = + rgb_to_preview_rgba(width, height, &raw) + else { + send_terminal_event( + &sender, + &stop, + CameraEvent::Failure(CameraFailure::InvalidFrame), + ); + break; + }; + let _ = sender.try_send(CameraEvent::Preview { + width: preview_width, + height: preview_height, + rgba, + }); + } + } + ur.cancel(); + stop_camera_stream(&mut camera); +} + +/// Deliver completion and failure events without making cancellation wait for +/// a full UI queue. Preview/progress events are deliberately lossy; terminal +/// events retry until consumed, disconnected, or the scanner is cancelled. +fn send_terminal_event( + sender: &SyncSender, + stop: &AtomicBool, + mut event: CameraEvent, +) { + loop { + match sender.try_send(event) { + Ok(()) | Err(TrySendError::Disconnected(_)) => return, + Err(TrySendError::Full(returned)) => { + if stop.load(Ordering::Acquire) { + return; + } + event = returned; + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +#[cfg(not(target_os = "macos"))] +type PlatformCamera = Camera; + +#[cfg(not(target_os = "macos"))] +fn open_camera(index: CameraIndex) -> Result { + // Start with a backend-supported RGB-decodable format, then select an + // advertised real-time mode close to 720p. Requesting the absolute highest + // frame rate can also select a multi-megapixel stream whose conversion and + // QR detection make the preview substantially less responsive. + let requested = RequestedFormat::new::(RequestedFormatType::None); + let mut camera = Camera::new(index, requested).map_err(map_camera_error)?; + if let Ok(formats) = camera.compatible_camera_formats() { + if let Some(format) = preferred_camera_format(&formats) { + camera + .set_camera_requset(RequestedFormat::new::( + RequestedFormatType::Exact(format), + )) + .map_err(map_camera_error)?; + } + } + Ok(camera) +} + +#[cfg(not(target_os = "macos"))] +fn start_camera_stream(camera: &mut PlatformCamera) -> Result<(), CameraFailure> { + camera.open_stream().map_err(map_camera_error) +} + +#[cfg(not(target_os = "macos"))] +fn read_camera_frame_rgb( + camera: &mut PlatformCamera, + _stop: &AtomicBool, +) -> Result<(u32, u32, Vec), CameraFailure> { + let image = camera + .frame() + .map_err(map_camera_error)? + .decode_image::() + .map_err(map_camera_error)?; + let (width, height) = image.dimensions(); + Ok((width, height, image.into_raw())) +} + +#[cfg(not(target_os = "macos"))] +fn stop_camera_stream(camera: &mut PlatformCamera) { + let _ = camera.stop_stream(); +} + +/// AVFoundation capture path for macOS. +/// +/// Nokhwa configures the device format both while constructing and opening a +/// camera. Some built-in Mac cameras reject that exclusive configuration lock +/// even though they are available for capture. A 720p session preset lets +/// AVFoundation negotiate a processed capture mode without locking the device +/// directly, while still giving the QR decoder enough spatial detail. +#[cfg(target_os = "macos")] +struct PlatformCamera { + device: AVCaptureDevice, + format: CameraFormat, + buffer_name: CString, + receiver: Arc>, + sender: Arc>, + input: Option, + session: Option, + output: Option, + callback: Option, +} + +#[cfg(target_os = "macos")] +fn open_camera(index: CameraIndex) -> Result { + let device = AVCaptureDevice::new(&index).map_err(map_camera_error)?; + let active = device.active_format().map_err(map_camera_error)?; + let format = CameraFormat::new( + active.resolution(), + FrameFormat::RAWRGB, + active.frame_rate(), + ); + let buffer_name = CString::new(format!("liana-qr-camera-{index}")) + .map_err(|error| CameraFailure::Capture(error.to_string()))?; + let (sender, receiver) = flume::unbounded(); + Ok(PlatformCamera { + device, + format, + buffer_name, + receiver: Arc::new(receiver), + sender: Arc::new(sender), + input: None, + session: None, + output: None, + callback: None, + }) +} + +#[cfg(target_os = "macos")] +fn start_camera_stream(camera: &mut PlatformCamera) -> Result<(), CameraFailure> { + let input = AVCaptureDeviceInput::new(&camera.device).map_err(map_camera_error)?; + let session = AVCaptureSession::new(); + session.begin_configuration(); + session.add_input(&input).map_err(map_camera_error)?; + set_720p_session_preset(&session)?; + let callback = AVCaptureVideoCallback::new(&camera.buffer_name, &camera.sender) + .map_err(map_camera_error)?; + let output = AVCaptureVideoDataOutput::new(); + output.add_delegate(&callback).map_err(map_camera_error)?; + output + .set_frame_format(FrameFormat::RAWRGB) + .map_err(map_camera_error)?; + session.add_output(&output).map_err(map_camera_error)?; + session.commit_configuration(); + session.start().map_err(map_camera_error)?; + let active = camera.device.active_format().map_err(map_camera_error)?; + camera.format = CameraFormat::new( + active.resolution(), + FrameFormat::RAWRGB, + active.frame_rate(), + ); + camera.input = Some(input); + camera.session = Some(session); + camera.output = Some(output); + camera.callback = Some(callback); + Ok(()) +} + +#[cfg(target_os = "macos")] +#[allow(unexpected_cfgs)] +fn set_720p_session_preset(session: &AVCaptureSession) -> Result<(), CameraFailure> { + // SAFETY: AVFoundation exports this process-lifetime NSString constant. + let preset = unsafe { AVCaptureSessionPreset1280x720 }; + // SAFETY: `session.inner()` and `preset` are valid Objective-C objects for + // the duration of these synchronous messages, and both selectors return + // the declared Objective-C types. + let supported: BOOL = unsafe { msg_send![session.inner(), canSetSessionPreset: preset] }; + if supported != YES { + return Err(CameraFailure::Capture( + "camera does not support a 720p capture session".to_owned(), + )); + } + // SAFETY: The preceding query confirmed this session accepts the preset. + let _: () = unsafe { msg_send![session.inner(), setSessionPreset: preset] }; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn read_camera_frame_rgb( + camera: &mut PlatformCamera, + stop: &AtomicBool, +) -> Result<(u32, u32, Vec), CameraFailure> { + let (bytes, _, _) = loop { + match camera.receiver.recv_timeout(Duration::from_millis(100)) { + Ok(frame) => break frame, + Err(flume::RecvTimeoutError::Timeout) if !stop.load(Ordering::Acquire) => continue, + Err(flume::RecvTimeoutError::Timeout) => { + return Err(CameraFailure::Capture( + "camera capture cancelled".to_owned(), + )) + } + Err(flume::RecvTimeoutError::Disconnected) => { + return Err(CameraFailure::Capture( + "camera capture channel disconnected".to_owned(), + )) + } + } + }; + let width = camera.format.width(); + let height = camera.format.height(); + let row_bytes = (width as usize) + .checked_mul(3) + .ok_or(CameraFailure::InvalidFrame)?; + let expected = row_bytes + .checked_mul(height as usize) + .ok_or(CameraFailure::InvalidFrame)?; + let bytes = if bytes.len() == expected { + bytes + } else if height != 0 && bytes.len() % height as usize == 0 { + let source_stride = bytes.len() / height as usize; + if source_stride < row_bytes { + return Err(CameraFailure::InvalidFrame); + } + let mut packed = Vec::with_capacity(expected); + for row in bytes.chunks_exact(source_stride) { + packed.extend_from_slice(&row[..row_bytes]); + } + packed + } else { + return Err(CameraFailure::InvalidFrame); + }; + let _ = camera.receiver.drain(); + Ok((width, height, bytes)) +} + +#[cfg(target_os = "macos")] +fn stop_camera_stream(camera: &mut PlatformCamera) { + if let Some(session) = camera.session.take() { + if let Some(output) = camera.output.take() { + session.remove_output(&output); + } + if let Some(input) = camera.input.take() { + session.remove_input(&input); + } + session.stop(); + } + camera.callback = None; + let _ = camera.receiver.drain(); +} + +#[cfg(target_os = "macos")] +impl Drop for PlatformCamera { + fn drop(&mut self) { + stop_camera_stream(self); + } +} + +#[cfg(any(not(target_os = "macos"), test))] +fn preferred_camera_format(formats: &[CameraFormat]) -> Option { + let has_realtime_format = formats.iter().any(|format| { + RgbFormat::FORMATS.contains(&format.format()) && format.frame_rate() >= MIN_REALTIME_FPS + }); + formats + .iter() + .copied() + .filter(|format| RgbFormat::FORMATS.contains(&format.format())) + .filter(|format| !has_realtime_format || format.frame_rate() >= MIN_REALTIME_FPS) + .min_by_key(|format| { + let resolution_distance = format.width().abs_diff(TARGET_CAPTURE_WIDTH) + + format.height().abs_diff(TARGET_CAPTURE_HEIGHT); + let frame_rate_distance = format.frame_rate().abs_diff(TARGET_CAPTURE_FPS); + (resolution_distance, frame_rate_distance) + }) +} + +fn rgb_to_luma(width: u32, height: u32, rgb: &[u8]) -> Option> { + let pixels = (width as usize).checked_mul(height as usize)?; + if rgb.len() != pixels.checked_mul(3)? { + return None; + } + Some( + rgb.chunks_exact(3) + .map(|pixel| { + ((u16::from(pixel[0]) * 77 + u16::from(pixel[1]) * 150 + u16::from(pixel[2]) * 29) + >> 8) as u8 + }) + .collect(), + ) +} + +fn rgb_to_preview_rgba(width: u32, height: u32, rgb: &[u8]) -> Option<(u32, u32, Vec)> { + let pixels = (width as usize).checked_mul(height as usize)?; + if width == 0 || height == 0 || rgb.len() != pixels.checked_mul(3)? { + return None; + } + let step = width + .div_ceil(PREVIEW_MAX_WIDTH) + .max(height.div_ceil(PREVIEW_MAX_HEIGHT)) + .max(1); + let preview_width = width.div_ceil(step); + let preview_height = height.div_ceil(step); + let mut rgba = Vec::with_capacity( + (preview_width as usize) + .checked_mul(preview_height as usize)? + .checked_mul(4)?, + ); + for preview_y in 0..preview_height { + let source_y = (preview_y * step).min(height - 1); + for preview_x in 0..preview_width { + // Mirror only the preview so it behaves like a conventional + // front-facing camera. QR decoding continues to use the original, + // unmodified frame above. + let source_x = width - 1 - (preview_x * step).min(width - 1); + let source = ((source_y as usize) * (width as usize) + source_x as usize) * 3; + rgba.extend_from_slice(&[rgb[source], rgb[source + 1], rgb[source + 2], 255]); + } + } + draw_scan_guide(preview_width, preview_height, &mut rgba)?; + Some((preview_width, preview_height, rgba)) +} + +fn draw_scan_guide(width: u32, height: u32, rgba: &mut [u8]) -> Option<()> { + if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 { + return None; + } + let side = width.min(height) * 3 / 5; + if side < 4 { + return Some(()); + } + let left = (width - side) / 2; + let top = (height - side) / 2; + let right = left + side - 1; + let bottom = top + side - 1; + let corner = (side / 5).max(12); + let thickness = 3u32.min(side); + let mut paint = |x: u32, y: u32| { + let offset = ((y as usize * width as usize) + x as usize) * 4; + rgba[offset..offset + 4].copy_from_slice(&[0, 255, 102, 255]); + }; + for line in 0..thickness { + for distance in 0..corner { + paint(left + distance, top + line); + paint(left + line, top + distance); + paint(right - distance, top + line); + paint(right - line, top + distance); + paint(left + distance, bottom - line); + paint(left + line, bottom - distance); + paint(right - distance, bottom - line); + paint(right - line, bottom - distance); + } + } + Some(()) +} + +fn decode_qr_frame( + decoder: &mut quircs::Quirc, + width: usize, + height: usize, + luma: &[u8], +) -> Vec> { + if width.checked_mul(height) != Some(luma.len()) { + return vec![Err(CameraFailure::InvalidFrame)]; + } + let mut decoded = decoder + .identify(width, height, luma) + .filter_map(|code| code.ok()) + .map(|code| { + code.decode() + .map_err(|error| CameraFailure::Capture(error.to_string())) + .and_then(|data| { + String::from_utf8(data.payload) + .map_err(|error| CameraFailure::Capture(error.to_string())) + }) + }) + .collect::>(); + if decoded.iter().any(Result::is_ok) { + return decoded; + } + + if let Some(value) = decode_qr_with_zxing(width, height, luma) { + decoded.push(Ok(value)); + return decoded; + } + if let Some((crop_width, crop_height, crop)) = centered_square_luma(width, height, luma) { + if let Some(value) = decode_qr_with_zxing(crop_width, crop_height, &crop) { + decoded.push(Ok(value)); + } + } + decoded +} + +fn decode_qr_with_zxing(width: usize, height: usize, luma: &[u8]) -> Option { + let mut hints = DecodeHints { + TryHarder: Some(true), + AlsoInverted: Some(true), + ..DecodeHints::default() + }; + rxing::helpers::detect_in_luma_with_hints( + luma.to_vec(), + u32::try_from(width).ok()?, + u32::try_from(height).ok()?, + Some(BarcodeFormat::QR_CODE), + &mut hints, + ) + .ok() + .map(|result| result.getText().to_owned()) +} + +fn centered_square_luma( + width: usize, + height: usize, + luma: &[u8], +) -> Option<(usize, usize, Vec)> { + if width.checked_mul(height) != Some(luma.len()) { + return None; + } + let side = width.min(height); + let left = (width - side) / 2; + let top = (height - side) / 2; + let mut crop = Vec::with_capacity(side.checked_mul(side)?); + for row in top..top + side { + let start = row.checked_mul(width)?.checked_add(left)?; + crop.extend_from_slice(&luma[start..start + side]); + } + Some((side, side, crop)) +} + +fn map_camera_error(error: nokhwa::NokhwaError) -> CameraFailure { + let text = error.to_string(); + let lowercase = text.to_ascii_lowercase(); + if lowercase.contains("permission") || lowercase.contains("denied") { + CameraFailure::PermissionDenied + } else if lowercase.contains("busy") || lowercase.contains("in use") { + CameraFailure::Busy + } else if lowercase.contains("not found") || lowercase.contains("no camera") { + CameraFailure::Unavailable + } else { + CameraFailure::Capture(text) + } +} + +#[cfg(test)] +mod tests { + use qrcode::{types::Color, QrCode}; + + use super::*; + + fn qr_luma(value: &str, inverted: bool) -> (usize, Vec) { + let code = QrCode::new(value).unwrap(); + let modules = code.width(); + let quiet = 4usize; + let scale = 8usize; + let side = (modules + quiet * 2) * scale; + let colors = code.to_colors(); + let (light, dark) = if inverted { (0, 255) } else { (255, 0) }; + let mut pixels = vec![light; side * side]; + for y in 0..modules { + for x in 0..modules { + if colors[y * modules + x] == Color::Dark { + let left = (x + quiet) * scale; + let top = (y + quiet) * scale; + for row in top..top + scale { + pixels[row * side + left..row * side + left + scale].fill(dark); + } + } + } + } + (side, pixels) + } + + #[test] + fn synthetic_qr_frame_decodes_without_persistence() { + let value = "ur:bytes/hdcxmybgmnkp"; + let (side, pixels) = qr_luma(value, false); + let mut decoder = quircs::Quirc::default(); + assert_eq!( + decode_qr_frame(&mut decoder, side, side, &pixels), + vec![Ok(value.to_owned())] + ); + } + + #[test] + fn inverted_qr_frame_decodes_with_robust_fallback() { + let value = "ur:bytes/hdcxmybgmnkp"; + let (side, pixels) = qr_luma(value, true); + let mut decoder = quircs::Quirc::default(); + assert!(decode_qr_frame(&mut decoder, side, side, &pixels) + .into_iter() + .any(|result| result == Ok(value.to_owned()))); + } + + #[test] + fn invalid_frame_dimensions_are_rejected() { + let mut decoder = quircs::Quirc::default(); + assert_eq!( + decode_qr_frame(&mut decoder, 10, 10, &[0; 99]), + vec![Err(CameraFailure::InvalidFrame)] + ); + } + + #[test] + fn cancelled_scanner_does_not_block_on_a_full_event_queue() { + let (sender, receiver) = mpsc::sync_channel(1); + sender + .try_send(CameraEvent::Rejected("queued".to_owned())) + .unwrap(); + let stop = AtomicBool::new(true); + send_terminal_event( + &sender, + &stop, + CameraEvent::Failure(CameraFailure::Unavailable), + ); + assert_eq!( + receiver.try_recv(), + Ok(CameraEvent::Rejected("queued".to_owned())) + ); + } + + #[test] + fn camera_format_prefers_realtime_720p_without_selecting_4k() { + use nokhwa::utils::{FrameFormat, Resolution}; + + let formats = [ + CameraFormat::new(Resolution::new(3840, 2160), FrameFormat::MJPEG, 60), + CameraFormat::new(Resolution::new(1280, 720), FrameFormat::MJPEG, 30), + CameraFormat::new(Resolution::new(640, 480), FrameFormat::MJPEG, 30), + ]; + assert_eq!(preferred_camera_format(&formats), Some(formats[1])); + } + + #[test] + fn camera_format_avoids_slow_modes_when_realtime_is_available() { + use nokhwa::utils::{FrameFormat, Resolution}; + + let formats = [ + CameraFormat::new(Resolution::new(1280, 720), FrameFormat::MJPEG, 5), + CameraFormat::new(Resolution::new(640, 480), FrameFormat::MJPEG, 30), + ]; + assert_eq!(preferred_camera_format(&formats), Some(formats[1])); + } + + #[test] + fn preview_is_bounded_and_mirrors_sampled_pixels() { + let width = 1280; + let height = 720; + let mut rgb = vec![0; width as usize * height as usize * 3]; + rgb[..3].copy_from_slice(&[10, 20, 30]); + rgb[(width as usize - 1) * 3..width as usize * 3].copy_from_slice(&[40, 50, 60]); + let (preview_width, preview_height, rgba) = + rgb_to_preview_rgba(width, height, &rgb).unwrap(); + assert_eq!((preview_width, preview_height), (640, 360)); + assert_eq!(&rgba[..4], &[40, 50, 60, 255]); + assert_eq!( + rgba.len(), + preview_width as usize * preview_height as usize * 4 + ); + assert!(rgba + .chunks_exact(4) + .any(|pixel| pixel == [0, 255, 102, 255])); + } +} diff --git a/liana-gui/src/airgap/device.rs b/liana-gui/src/airgap/device.rs new file mode 100644 index 0000000000..d329d979bf --- /dev/null +++ b/liana-gui/src/airgap/device.rs @@ -0,0 +1,132 @@ +use liana::miniscript::{bitcoin::bip32::Fingerprint, descriptor::DescriptorPublicKey}; +use serde::{Deserialize, Serialize}; + +use super::{Error, PassportAccount}; + +/// A persisted air-gapped signer. Only public account material is stored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AirgappedSignerConfig { + pub kind: AirgappedSignerKind, + pub fingerprint: Fingerprint, + pub alias: Option, + pub account: DescriptorPublicKey, + #[serde(default)] + pub registration: RegistrationState, +} + +impl AirgappedSignerConfig { + pub fn qr(account: PassportAccount, alias: Option) -> Result { + let origin_fingerprint = match &account.account { + DescriptorPublicKey::XPub(xpub) => { + xpub.origin.as_ref().map(|(fingerprint, _)| *fingerprint) + } + _ => None, + } + .ok_or_else(|| Error::InvalidAccount("extended key origin is required".to_owned()))?; + if origin_fingerprint != account.fingerprint { + return Err(Error::InvalidFingerprint); + } + Ok(Self { + kind: AirgappedSignerKind::Qr, + fingerprint: account.fingerprint, + alias, + account: account.account, + registration: RegistrationState::NotRegistered, + }) + } + + pub fn invalidate_registration(&mut self, descriptor_checksum: &str) { + if !self.registration.is_current(descriptor_checksum) { + self.registration = RegistrationState::NotRegistered; + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AirgappedSignerKind { + #[serde(alias = "passport")] + Qr, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum RegistrationState { + #[default] + NotRegistered, + Exported { + descriptor_checksum: String, + }, +} + +impl RegistrationState { + pub fn is_current(&self, descriptor_checksum: &str) -> bool { + match self { + Self::NotRegistered => false, + Self::Exported { + descriptor_checksum: registered, + } => registered == descriptor_checksum, + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use liana::miniscript::bitcoin::Network; + + use super::*; + + const ACCOUNT: &str = "[9f141cf0/48'/1'/0'/2']tpubDFnReAwXvYd6RA46X55HuFpmvZsLanDrwHAUsdYEGEpNGTRnCdbDRXJGLTwDeqKURCPZUDgdkuuu9dYkuBNQHmSNBUu7V2CdLKwpJjx2JuC"; + + #[test] + fn signer_config_roundtrips_without_secret_material() { + let account = PassportAccount::from_descriptor_key(ACCOUNT, Network::Testnet4).unwrap(); + let mut signer = AirgappedSignerConfig::qr(account, Some("Recovery".to_owned())).unwrap(); + signer.registration = RegistrationState::Exported { + descriptor_checksum: "u768v50p".to_owned(), + }; + + let json = serde_json::to_string(&signer).unwrap(); + assert!(!json.contains("xprv")); + assert!(!json.contains("tprv")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + signer + ); + } + + #[test] + fn stale_registration_is_invalidated() { + let account = PassportAccount::from_descriptor_key(ACCOUNT, Network::Testnet4).unwrap(); + let mut signer = AirgappedSignerConfig::qr(account, None).unwrap(); + signer.registration = RegistrationState::Exported { + descriptor_checksum: "u768v50p".to_owned(), + }; + signer.invalidate_registration("aaaaaaaa"); + assert_eq!(signer.registration, RegistrationState::NotRegistered); + } + + #[test] + fn persisted_account_origin_remains_parseable() { + let account = DescriptorPublicKey::from_str(ACCOUNT).unwrap(); + let encoded = serde_json::to_string(&account).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + account + ); + } + + #[test] + fn legacy_passport_kind_migrates_to_generic_qr_kind() { + let legacy = format!( + r#"{{"kind":"passport","fingerprint":"9f141cf0","alias":"Cold signer","account":"{ACCOUNT}","registration":{{"state":"not_registered"}}}}"# + ); + let signer: AirgappedSignerConfig = serde_json::from_str(&legacy).unwrap(); + assert_eq!(signer.kind, AirgappedSignerKind::Qr); + assert!(serde_json::to_string(&signer) + .unwrap() + .contains(r#""kind":"qr""#)); + } +} diff --git a/liana-gui/src/airgap/error.rs b/liana-gui/src/airgap/error.rs new file mode 100644 index 0000000000..d2ab265b6c --- /dev/null +++ b/liana-gui/src/airgap/error.rs @@ -0,0 +1,81 @@ +use std::fmt; + +/// A protocol or bounded-decoder failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + Cancelled, + TimedOut, + Empty, + FragmentTooLarge { + actual: usize, + maximum: usize, + }, + TooManyFragments { + actual: u32, + maximum: u32, + }, + PayloadTooLarge { + actual: usize, + maximum: usize, + }, + WrongUrType { + expected: &'static str, + actual: String, + }, + MixedSession, + Incomplete, + InvalidUr(String), + InvalidCbor(String), + InvalidJson(String), + JsonTooDeep { + maximum: usize, + }, + InvalidNetwork, + InvalidPolicy(String), + InvalidChecksum, + InvalidFingerprint, + InvalidAccount(String), + InvalidPsbt(String), + WrongResponseType, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled => write!(f, "operation cancelled"), + Self::TimedOut => write!(f, "QR scan session timed out"), + Self::Empty => write!(f, "payload is empty"), + Self::FragmentTooLarge { actual, maximum } => { + write!(f, "QR fragment is {actual} bytes; the maximum is {maximum}") + } + Self::TooManyFragments { actual, maximum } => write!( + f, + "QR declares {actual} fragments; the maximum is {maximum}" + ), + Self::PayloadTooLarge { actual, maximum } => write!( + f, + "decoded payload is {actual} bytes; the maximum is {maximum}" + ), + Self::WrongUrType { expected, actual } => { + write!(f, "expected UR type {expected}, received {actual}") + } + Self::MixedSession => write!(f, "QR fragment belongs to another scan session"), + Self::Incomplete => write!(f, "QR sequence is incomplete"), + Self::InvalidUr(e) => write!(f, "invalid UR: {e}"), + Self::InvalidCbor(e) => write!(f, "invalid UR CBOR: {e}"), + Self::InvalidJson(e) => write!(f, "invalid protocol JSON: {e}"), + Self::JsonTooDeep { maximum } => { + write!(f, "protocol JSON exceeds the nesting limit of {maximum}") + } + Self::InvalidNetwork => write!(f, "unsupported Bitcoin network"), + Self::InvalidPolicy(e) => write!(f, "invalid wallet policy: {e}"), + Self::InvalidChecksum => write!(f, "invalid descriptor checksum"), + Self::InvalidFingerprint => write!(f, "invalid master fingerprint"), + Self::InvalidAccount(e) => write!(f, "invalid air-gapped signer account: {e}"), + Self::InvalidPsbt(e) => write!(f, "invalid PSBT: {e}"), + Self::WrongResponseType => write!(f, "response is not valid for the active operation"), + } + } +} + +impl std::error::Error for Error {} diff --git a/liana-gui/src/airgap/mod.rs b/liana-gui/src/airgap/mod.rs new file mode 100644 index 0000000000..94634cffbc --- /dev/null +++ b/liana-gui/src/airgap/mod.rs @@ -0,0 +1,30 @@ +//! Typed, bounded transport primitives for air-gapped signing methods. +//! +//! Protocol parsing is independent from installer and wallet screens. +//! Untrusted QR/file input is bounded and decoded here before a UI flow sees a +//! protocol value; the camera module feeds that same decoder without persisting +//! frames. + +mod animation; +mod camera; +mod device; +mod error; +mod passport; +mod payload; +mod session; +mod ur; + +pub use animation::{AnimatedQr, AnimationState}; +pub use camera::{ + request_camera_access, CameraDescriptor, CameraEvent, CameraFailure, CameraScanner, +}; +pub use device::{AirgappedSignerConfig, AirgappedSignerKind, RegistrationState}; +pub use error::Error; +pub use passport::{ + AddressVerificationRequest, PolicyNetwork, PolicyRegistration, VerifiedAddress, +}; +pub use payload::{ + validate_and_merge_psbt, AirgappedRequest, AirgappedResponse, ExpectedResponse, PassportAccount, +}; +pub use session::{DecodeProgress, ScanLimits, UrDecodeSession}; +pub use ur::{encode_ur, EncodedUr, QrDensity, UrPayload, UrType}; diff --git a/liana-gui/src/airgap/passport.rs b/liana-gui/src/airgap/passport.rs new file mode 100644 index 0000000000..89ae847b3a --- /dev/null +++ b/liana-gui/src/airgap/passport.rs @@ -0,0 +1,599 @@ +use std::{ + collections::HashSet, + convert::{TryFrom, TryInto}, + str::FromStr, +}; + +use liana::{ + descriptors::LianaDescriptor, + miniscript::bitcoin::{hashes::Hash, Network}, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use super::Error; + +pub const POLICY_FORMAT: &str = "passport-wallet-policy"; +pub const ADDRESS_REQUEST_FORMAT: &str = "passport-address-verification"; +pub const ADDRESS_RESPONSE_FORMAT: &str = "passport-address-verification-response"; +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_JSON_BYTES: usize = 4_096; +pub const MAX_JSON_DEPTH: usize = 16; +pub const MAX_DESCRIPTOR_LENGTH: usize = 4_096; +pub const MAX_TEMPLATE_LENGTH: usize = 2_048; +pub const MAX_KEYS: usize = 20; +pub const MAX_NAME_LENGTH: usize = 20; + +const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicyNetwork { + BTC, + TBTC, +} + +impl TryFrom for PolicyNetwork { + type Error = Error; + + fn try_from(value: Network) -> Result { + match value { + Network::Bitcoin => Ok(Self::BTC), + Network::Testnet | Network::Testnet4 | Network::Signet | Network::Regtest => { + Ok(Self::TBTC) + } + _ => Err(Error::InvalidNetwork), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyRegistration { + pub format: String, + pub version: u8, + pub name: String, + pub network: PolicyNetwork, + pub template: String, + pub keys: Vec, + pub policy_id: String, +} + +impl PolicyRegistration { + pub fn new( + name: impl Into, + network: PolicyNetwork, + template: impl Into, + keys: Vec, + ) -> Result { + let registration = Self { + format: POLICY_FORMAT.to_owned(), + version: PROTOCOL_VERSION, + name: name.into(), + network, + template: template.into(), + keys, + policy_id: String::new(), + }; + registration.validate_without_id()?; + Ok(Self { + policy_id: registration.calculate_policy_id(), + ..registration + }) + } + + /// Convert Liana's canonical multipath descriptor to Passport's v1 + /// BIP388-style template and canonical key vector. + pub fn from_descriptor( + name: impl Into, + network: Network, + descriptor: &LianaDescriptor, + ) -> Result { + let supplied_name = name.into(); + let printable_name: String = supplied_name + .chars() + .filter(|character| character.is_ascii() && !character.is_ascii_control()) + .collect(); + let mut transport_name: String = printable_name + .trim() + .chars() + .take(MAX_NAME_LENGTH) + .collect::() + .trim_end() + .to_owned(); + if transport_name.is_empty() { + transport_name = "Liana".to_owned(); + } + let descriptor = descriptor.to_string(); + let body = descriptor + .rsplit_once('#') + .map(|(body, _)| body) + .ok_or(Error::InvalidChecksum)?; + let (template, keys) = descriptor_to_template(body)?; + Self::new(transport_name, network.try_into()?, template, keys) + } + + pub fn descriptor_checksum(&self) -> Result { + let descriptor = self.full_descriptor(); + let parsed = LianaDescriptor::from_str(&descriptor) + .map_err(|e| Error::InvalidPolicy(e.to_string()))?; + parsed + .to_string() + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .ok_or(Error::InvalidChecksum) + } + + pub fn full_descriptor(&self) -> String { + let mut descriptor = self.template.clone(); + for index in (0..self.keys.len()).rev() { + descriptor = descriptor.replace(&format!("@{index}"), &self.keys[index]); + } + descriptor + } + + pub fn calculate_policy_id(&self) -> String { + let mut payload = b"Passport Wallet Policy\0".to_vec(); + payload.push(PROTOCOL_VERSION); + encode_field( + &mut payload, + match self.network { + PolicyNetwork::BTC => "BTC", + PolicyNetwork::TBTC => "TBTC", + }, + ); + encode_field(&mut payload, &self.template); + compact_size(&mut payload, self.keys.len()); + for key in &self.keys { + encode_field(&mut payload, key); + } + liana::miniscript::bitcoin::hashes::sha256::Hash::hash(&payload).to_string() + } + + pub fn to_json(&self) -> Result, Error> { + self.validate()?; + encode_json(self) + } + + pub fn from_json(data: &[u8]) -> Result { + let value: Self = decode_json(data)?; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), Error> { + self.validate_without_id()?; + if self.policy_id != self.calculate_policy_id() { + return Err(Error::InvalidPolicy( + "policy identity does not match its canonical contents".to_owned(), + )); + } + // Reparse the reconstructed descriptor using Liana's own parser. This + // preserves branch order, key order, threshold and timelock semantics. + LianaDescriptor::from_str(&self.full_descriptor()) + .map_err(|e| Error::InvalidPolicy(e.to_string()))?; + Ok(()) + } + + fn validate_without_id(&self) -> Result<(), Error> { + if self.format != POLICY_FORMAT || self.version != PROTOCOL_VERSION { + return Err(Error::InvalidPolicy( + "unsupported wallet-policy envelope".to_owned(), + )); + } + validate_ascii(&self.name, 1, MAX_NAME_LENGTH, "wallet name")?; + validate_ascii(&self.template, 1, MAX_TEMPLATE_LENGTH, "policy template")?; + if !(self.template.starts_with("wsh(") || self.template.starts_with("tr(")) + || !self.template.ends_with(')') + { + return Err(Error::InvalidPolicy( + "policy template must be a top-level wsh() or tr() descriptor".to_owned(), + )); + } + if self.keys.is_empty() || self.keys.len() > MAX_KEYS { + return Err(Error::InvalidPolicy(format!( + "policy must contain between 1 and {MAX_KEYS} keys" + ))); + } + let mut unique = HashSet::new(); + for key in &self.keys { + let canonical = canonical_key(key)?; + if canonical != *key { + return Err(Error::InvalidPolicy("key is not canonical".to_owned())); + } + if !unique.insert(key) { + return Err(Error::InvalidPolicy( + "policy key vector contains a duplicate".to_owned(), + )); + } + } + if self.full_descriptor().len() > MAX_DESCRIPTOR_LENGTH { + return Err(Error::InvalidPolicy("descriptor is too large".to_owned())); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AddressVerificationRequest { + pub format: String, + pub version: u8, + pub network: PolicyNetwork, + pub policy_id: String, + pub descriptor_checksum: String, + pub branch: u32, + pub index: u32, +} + +impl AddressVerificationRequest { + pub fn new(registration: &PolicyRegistration, branch: u32, index: u32) -> Result { + if branch > 1 { + return Err(Error::InvalidPolicy("branch must be 0 or 1".to_owned())); + } + Ok(Self { + format: ADDRESS_REQUEST_FORMAT.to_owned(), + version: PROTOCOL_VERSION, + network: registration.network, + policy_id: registration.policy_id.clone(), + descriptor_checksum: registration.descriptor_checksum()?, + branch, + index, + }) + } + + pub fn to_json(&self) -> Result, Error> { + encode_json(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VerifiedAddress { + pub format: String, + pub version: u8, + pub network: PolicyNetwork, + pub policy_id: String, + pub descriptor_checksum: String, + pub branch: u32, + pub index: u32, + pub address: String, + pub fingerprint: String, +} + +impl VerifiedAddress { + pub fn from_json(data: &[u8]) -> Result { + decode_json(data) + } + + pub fn validate_for( + &self, + request: &AddressVerificationRequest, + expected_address: &str, + fingerprint: &str, + ) -> Result<(), Error> { + validate_fingerprint(&self.fingerprint)?; + if self.format != ADDRESS_RESPONSE_FORMAT + || self.version != PROTOCOL_VERSION + || self.network != request.network + || self.policy_id != request.policy_id + || self.descriptor_checksum != request.descriptor_checksum + || self.branch != request.branch + || self.index != request.index + || self.address != expected_address + || !self.fingerprint.eq_ignore_ascii_case(fingerprint) + { + return Err(Error::WrongResponseType); + } + Ok(()) + } +} + +pub(crate) fn encode_json(value: &T) -> Result, Error> { + let encoded = serde_json::to_vec(value).map_err(|e| Error::InvalidJson(e.to_string()))?; + if encoded.len() > MAX_JSON_BYTES { + return Err(Error::PayloadTooLarge { + actual: encoded.len(), + maximum: MAX_JSON_BYTES, + }); + } + Ok(encoded) +} + +pub(crate) fn decode_json(data: &[u8]) -> Result { + if data.is_empty() { + return Err(Error::Empty); + } + if data.len() > MAX_JSON_BYTES { + return Err(Error::PayloadTooLarge { + actual: data.len(), + maximum: MAX_JSON_BYTES, + }); + } + validate_json_depth(data, MAX_JSON_DEPTH)?; + serde_json::from_slice(data).map_err(|e| Error::InvalidJson(e.to_string())) +} + +fn validate_json_depth(data: &[u8], maximum: usize) -> Result<(), Error> { + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for &byte in data { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + match byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth = depth.saturating_add(1); + if depth > maximum { + return Err(Error::JsonTooDeep { maximum }); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + Ok(()) +} + +fn descriptor_to_template(body: &str) -> Result<(String, Vec), Error> { + if body.len() > MAX_DESCRIPTOR_LENGTH || !body.is_ascii() { + return Err(Error::InvalidPolicy( + "descriptor is non-ASCII or too large".to_owned(), + )); + } + let bytes = body.as_bytes(); + let mut output = String::with_capacity(body.len()); + let mut keys = Vec::new(); + let mut position = 0usize; + while position < bytes.len() { + if bytes[position] != b'[' { + output.push(char::from(bytes[position])); + position += 1; + continue; + } + let close = body[position + 1..] + .find(']') + .map(|offset| position + 1 + offset) + .ok_or_else(|| Error::InvalidPolicy("key origin is incomplete".to_owned()))?; + let mut xpub_end = close + 1; + while xpub_end < bytes.len() && BASE58.as_bytes().contains(&bytes[xpub_end]) { + xpub_end += 1; + } + if xpub_end == close + 1 { + return Err(Error::InvalidPolicy( + "key origin is not followed by an extended public key".to_owned(), + )); + } + let key = canonical_key(&body[position..xpub_end])?; + let (suffix, next) = if body[xpub_end..].starts_with("/**") { + ("/**".to_owned(), xpub_end + 3) + } else if body[xpub_end..].starts_with("/<") { + let relative_end = body[xpub_end + 2..] + .find(">/*") + .ok_or_else(|| Error::InvalidPolicy("multipath suffix is incomplete".to_owned()))?; + let suffix_end = xpub_end + 2 + relative_end; + let branches = &body[xpub_end + 2..suffix_end]; + let mut parts = branches.split(';'); + let first = canonical_number(parts.next())?; + let second = canonical_number(parts.next())?; + if parts.next().is_some() || first == second { + return Err(Error::InvalidPolicy( + "exactly two distinct multipath branches are required".to_owned(), + )); + } + (format!("/<{first};{second}>/*"), suffix_end + 3) + } else { + return Err(Error::InvalidPolicy( + "extended keys must end in /** or //*".to_owned(), + )); + }; + let key_index = match keys.iter().position(|existing| existing == &key) { + Some(index) => index, + None => { + if keys.len() == MAX_KEYS { + return Err(Error::InvalidPolicy("too many keys".to_owned())); + } + keys.push(key); + keys.len() - 1 + } + }; + output.push_str(&format!("@{key_index}{suffix}")); + position = next; + } + Ok((output, keys)) +} + +fn canonical_key(key: &str) -> Result { + if !key.is_ascii() || !key.starts_with('[') { + return Err(Error::InvalidPolicy("key origin is required".to_owned())); + } + let close = key + .find(']') + .ok_or_else(|| Error::InvalidPolicy("key origin is incomplete".to_owned()))?; + let origin = &key[1..close]; + let xpub = &key[close + 1..]; + if !(100..=120).contains(&xpub.len()) || !xpub.bytes().all(|b| BASE58.as_bytes().contains(&b)) { + return Err(Error::InvalidPolicy( + "extended public key encoding is invalid".to_owned(), + )); + } + let mut components = origin.split('/'); + let fingerprint = components + .next() + .ok_or(Error::InvalidFingerprint)? + .to_ascii_lowercase(); + validate_fingerprint(&fingerprint)?; + let mut canonical = format!("[{fingerprint}"); + for component in components { + if component.is_empty() { + return Err(Error::InvalidPolicy("empty origin component".to_owned())); + } + let hardened = component.ends_with(['\'', 'h', 'H']); + let number = if hardened { + &component[..component.len() - 1] + } else { + component + }; + let value = canonical_number(Some(number))?; + canonical.push('/'); + canonical.push_str(&value.to_string()); + if hardened { + canonical.push('\''); + } + } + canonical.push(']'); + canonical.push_str(xpub); + Ok(canonical) +} + +fn canonical_number(number: Option<&str>) -> Result { + let number = number.ok_or_else(|| Error::InvalidPolicy("missing number".to_owned()))?; + if number.is_empty() + || !number.bytes().all(|b| b.is_ascii_digit()) + || (number.len() > 1 && number.starts_with('0')) + { + return Err(Error::InvalidPolicy("number is not canonical".to_owned())); + } + let value = number + .parse::() + .map_err(|_| Error::InvalidPolicy("number is too large".to_owned()))?; + if value >= (1 << 31) { + return Err(Error::InvalidPolicy("number is too large".to_owned())); + } + Ok(value) +} + +fn validate_ascii(value: &str, minimum: usize, maximum: usize, field: &str) -> Result<(), Error> { + if !(minimum..=maximum).contains(&value.len()) + || !value.is_ascii() + || value.trim() != value + || value.bytes().any(|b| !(32..=126).contains(&b)) + { + return Err(Error::InvalidPolicy(format!("invalid {field}"))); + } + Ok(()) +} + +fn validate_fingerprint(value: &str) -> Result<(), Error> { + if value.len() == 8 && value.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(Error::InvalidFingerprint) + } +} + +fn compact_size(output: &mut Vec, value: usize) { + if value < 253 { + output.push(value as u8); + } else if value <= u16::MAX as usize { + output.push(253); + output.extend_from_slice(&(value as u16).to_le_bytes()); + } else { + output.push(254); + output.extend_from_slice(&(value as u32).to_le_bytes()); + } +} + +fn encode_field(output: &mut Vec, value: &str) { + compact_size(output, value.len()); + output.extend_from_slice(value.as_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + const LIANA_XPUB_1: &str = "xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW"; + const LIANA_XPUB_2: &str = "xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe"; + + fn registration() -> PolicyRegistration { + PolicyRegistration::new( + "Recovery", + PolicyNetwork::BTC, + "wsh(or_d(pk(@0/<0;1>/*),and_v(v:pkh(@1/<0;1>/*),older(52560))))", + vec![ + format!("[abcdef01]{LIANA_XPUB_1}"), + format!("[abcdef02]{LIANA_XPUB_2}"), + ], + ) + .unwrap() + } + + #[test] + fn passport_policy_id_matches_reference_algorithm() { + let registration = registration(); + // Generated independently by Passport Core's MiniscriptPolicy v1. + assert_eq!( + registration.policy_id, + "506b3dd1ce28b757cde12e2977c483b0afb518de9ad8edbdfbc01e5d9763dd9f" + ); + assert_eq!(registration.descriptor_checksum().unwrap(), "y7qrgwup"); + } + + #[test] + fn wallet_alias_is_safely_mapped_to_passport_name_limits() { + let source = registration(); + let descriptor = LianaDescriptor::from_str(&source.full_descriptor()).unwrap(); + let mapped = PolicyRegistration::from_descriptor( + " Family 🔐 inheritance wallet with a long name ", + Network::Bitcoin, + &descriptor, + ) + .unwrap(); + assert_eq!(mapped.name, "Family inheritance"); + assert!(mapped.name.len() <= MAX_NAME_LENGTH); + + let fallback = + PolicyRegistration::from_descriptor("🔐🔐", Network::Bitcoin, &descriptor).unwrap(); + assert_eq!(fallback.name, "Liana"); + } + + #[test] + fn json_depth_is_bounded_before_deserialization() { + let deeply_nested = format!("{}0{}", "[".repeat(17), "]".repeat(17)); + assert_eq!( + decode_json::(deeply_nested.as_bytes()), + Err(Error::JsonTooDeep { maximum: 16 }) + ); + } + + #[test] + fn address_response_is_bound_to_request() { + let registration = registration(); + let descriptor = LianaDescriptor::from_str(®istration.full_descriptor()).unwrap(); + let address = descriptor + .receive_descriptor() + .derive( + 7.into(), + &liana::miniscript::bitcoin::secp256k1::Secp256k1::verification_only(), + ) + .address(Network::Bitcoin) + .to_string(); + let request = AddressVerificationRequest::new(®istration, 0, 7).unwrap(); + let response = VerifiedAddress { + format: ADDRESS_RESPONSE_FORMAT.to_owned(), + version: 1, + network: request.network, + policy_id: request.policy_id.clone(), + descriptor_checksum: request.descriptor_checksum.clone(), + branch: 0, + index: 7, + address: address.clone(), + fingerprint: "abcdef01".to_owned(), + }; + response + .validate_for(&request, &address, "abcdef01") + .unwrap(); + assert_eq!( + response.validate_for(&request, "bc1qother", "abcdef01"), + Err(Error::WrongResponseType) + ); + } +} diff --git a/liana-gui/src/airgap/payload.rs b/liana-gui/src/airgap/payload.rs new file mode 100644 index 0000000000..4d671a4bd9 --- /dev/null +++ b/liana-gui/src/airgap/payload.rs @@ -0,0 +1,969 @@ +use std::{collections::HashSet, convert::TryInto, str::FromStr}; + +use liana::miniscript::{ + bitcoin::{ + bip32::{ChainCode, ChildNumber, DerivationPath, Fingerprint, Xpub}, + ecdsa, + psbt::Psbt, + secp256k1::{self, PublicKey, Secp256k1}, + sighash::SighashCache, + taproot::{self, TapLeafHash}, + Network, NetworkKind, + }, + descriptor::DescriptorPublicKey, + psbt::PsbtExt, +}; + +use super::{ + passport::decode_json, AddressVerificationRequest, Error, PolicyNetwork, PolicyRegistration, + UrPayload, UrType, VerifiedAddress, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PassportAccount { + pub fingerprint: Fingerprint, + pub account: DescriptorPublicKey, + pub network: PolicyNetwork, +} + +impl PassportAccount { + pub fn account_number(&self) -> Result { + let origin = match &self.account { + DescriptorPublicKey::XPub(xpub) => xpub.origin.as_ref().map(|(_, path)| path), + _ => None, + } + .ok_or_else(|| Error::InvalidAccount("extended key origin is required".to_owned()))?; + origin + .into_iter() + .nth(2) + .copied() + .ok_or_else(|| Error::InvalidAccount("BIP48 account component is missing".to_owned())) + } + + /// Decode the deliberately narrow `crypto-account` profile used for a + /// Passport BIP48 native-SegWit cosigner export. + pub fn from_crypto_account_cbor(data: &[u8], expected_network: Network) -> Result { + // CBOR map ordering is not significant. Validate the envelope and read + // its fingerprint first, then decode output descriptors in a second + // bounded pass so standards-compliant encoders may emit either order. + let mut decoder = minicbor::Decoder::new(data); + let map_len = decoder + .map() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| { + Error::InvalidAccount("indefinite account maps are forbidden".to_owned()) + })?; + let mut seen = HashSet::new(); + let mut master_fingerprint = None; + let mut has_outputs = false; + for _ in 0..map_len { + let key = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if !seen.insert(key) { + return Err(Error::InvalidAccount( + "duplicate crypto-account map entry".to_owned(), + )); + } + match key { + 1 => { + master_fingerprint = Some( + decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?, + ) + } + 2 => { + has_outputs = true; + decoder + .skip() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + } + _ => { + return Err(Error::InvalidAccount( + "unknown crypto-account map entry".to_owned(), + )) + } + } + } + if decoder.position() != data.len() { + return Err(Error::InvalidAccount("trailing CBOR data".to_owned())); + } + let fingerprint = master_fingerprint + .ok_or_else(|| Error::InvalidAccount("master fingerprint is required".to_owned()))?; + if !has_outputs { + return Err(Error::InvalidAccount( + "output descriptors are required".to_owned(), + )); + } + + let mut decoder = minicbor::Decoder::new(data); + let map_len = decoder + .map() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| { + Error::InvalidAccount("indefinite account maps are forbidden".to_owned()) + })?; + let mut accounts = Vec::new(); + for _ in 0..map_len { + match decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + { + 1 => { + decoder + .skip() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + } + 2 => { + let len = decoder + .array() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| { + Error::InvalidAccount( + "indefinite output descriptor arrays are forbidden".to_owned(), + ) + })?; + if len == 0 || len > 16 { + return Err(Error::InvalidAccount( + "crypto-account must contain 1 to 16 outputs".to_owned(), + )); + } + for _ in 0..len { + let mut candidate = decoder.clone(); + if let Ok(account) = + decode_bip48_cosigner(&mut candidate, fingerprint, expected_network) + { + accounts.push(account); + } + decoder + .skip() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + } + } + _ => { + return Err(Error::InvalidAccount( + "unknown crypto-account map entry".to_owned(), + )); + } + } + } + if accounts.len() != 1 { + return Err(Error::InvalidAccount( + "expected exactly one BIP48 native-SegWit cosigner".to_owned(), + )); + } + Ok(accounts.remove(0)) + } + + /// Decode Passport's current microSD fallback: + /// `[fingerprint/48'/coin_type'/account'/2']xpub-or-tpub`. + pub fn from_descriptor_key(value: &str, expected_network: Network) -> Result { + let value = value.trim(); + if value.contains("xprv") || value.contains("tprv") { + return Err(Error::InvalidAccount( + "private key material is forbidden".to_owned(), + )); + } + let account = DescriptorPublicKey::from_str(value) + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + let (origin, xkey) = match &account { + DescriptorPublicKey::XPub(xpub) => (xpub.origin.as_ref(), &xpub.xkey), + _ => { + return Err(Error::InvalidAccount( + "expected one non-wildcard extended public key".to_owned(), + )) + } + }; + let (fingerprint, path) = origin.ok_or_else(|| { + Error::InvalidAccount("master fingerprint and origin are required".to_owned()) + })?; + let components: Vec<_> = path.into_iter().copied().collect(); + if components.len() != 4 + || components[0].to_string() != "48'" + || components[3].to_string() != "2'" + || components.iter().any(|child| !child.is_hardened()) + || xkey.depth as usize != components.len() + { + return Err(Error::InvalidAccount( + "expected BIP48 native-SegWit origin m/48'/coin_type'/account'/2'".to_owned(), + )); + } + let coin_type = match components[1] { + ChildNumber::Hardened { index } => index, + ChildNumber::Normal { .. } => { + return Err(Error::InvalidAccount( + "coin type must be hardened".to_owned(), + )) + } + }; + let network = if coin_type == 0 { + PolicyNetwork::BTC + } else if coin_type == 1 { + PolicyNetwork::TBTC + } else { + return Err(Error::InvalidNetwork); + }; + let expected: PolicyNetwork = expected_network.try_into()?; + if network != expected || xkey.network != expected_network.into() { + return Err(Error::InvalidNetwork); + } + Ok(Self { + fingerprint: *fingerprint, + account, + network, + }) + } +} + +#[derive(Debug, Clone)] +pub enum AirgappedRequest { + RegisterPolicy(PolicyRegistration), + VerifyAddress(AddressVerificationRequest), + SignPsbt(Psbt), +} + +impl AirgappedRequest { + pub fn encode(&self) -> Result { + match self { + Self::RegisterPolicy(policy) => Ok(UrPayload::bytes(policy.to_json()?)), + Self::VerifyAddress(request) => Ok(UrPayload::bytes(request.to_json()?)), + Self::SignPsbt(psbt) => Ok(UrPayload::psbt(psbt)), + } + } + + pub fn expected_response(&self) -> Option { + match self { + Self::RegisterPolicy(_) => None, + Self::VerifyAddress(_) => Some(ExpectedResponse::VerifiedAddress), + Self::SignPsbt(_) => Some(ExpectedResponse::SignedPsbt), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpectedResponse { + VerifiedAddress, + SignedPsbt, +} + +impl ExpectedResponse { + pub const fn ur_type(self) -> UrType { + match self { + Self::VerifiedAddress => UrType::Bytes, + Self::SignedPsbt => UrType::CryptoPsbt, + } + } + + pub fn decode(self, payload: UrPayload) -> Result { + if payload.ur_type != self.ur_type() { + return Err(Error::WrongUrType { + expected: self.ur_type().as_str(), + actual: payload.ur_type.as_str().to_owned(), + }); + } + match self { + Self::VerifiedAddress => Ok(AirgappedResponse::VerifiedAddress(decode_json( + &payload.data, + )?)), + Self::SignedPsbt => Psbt::deserialize(&payload.data) + .map(AirgappedResponse::SignedPsbt) + .map_err(|e| Error::InvalidPsbt(e.to_string())), + } + } +} + +fn decode_bip48_cosigner( + decoder: &mut minicbor::Decoder<'_>, + account_fingerprint: u32, + expected_network: Network, +) -> Result { + expect_tag(decoder, 308)?; // crypto-output + expect_tag(decoder, 401)?; // wsh() + expect_tag(decoder, 410)?; // cosigner() + expect_tag(decoder, 303)?; // crypto-hdkey + + let map_len = decoder + .map() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| Error::InvalidAccount("indefinite HD key maps are forbidden".to_owned()))?; + let mut is_private = false; + let mut key_data = None; + let mut chain_code = None; + let mut network = PolicyNetwork::BTC; + let mut origin = None; + let mut parent_fingerprint = None; + let mut seen = HashSet::new(); + for _ in 0..map_len { + let key = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if !seen.insert(key) { + return Err(Error::InvalidAccount( + "duplicate crypto-hdkey map entry".to_owned(), + )); + } + match key { + 2 => { + is_private = decoder + .bool() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + } + 3 => { + let bytes = decoder + .bytes() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if bytes.len() != 33 { + return Err(Error::InvalidAccount( + "HD public key data must contain 33 bytes".to_owned(), + )); + } + key_data = Some(bytes.to_vec()); + } + 4 => { + let bytes = decoder + .bytes() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if bytes.len() != 32 { + return Err(Error::InvalidAccount( + "HD chain code must contain 32 bytes".to_owned(), + )); + } + let mut code = [0u8; 32]; + code.copy_from_slice(bytes); + chain_code = Some(code); + } + 5 => { + expect_tag(decoder, 40305)?; + network = decode_coin_info(decoder)?; + } + 6 => { + expect_tag(decoder, 40304)?; + origin = Some(decode_keypath(decoder)?); + } + 7 => { + return Err(Error::InvalidAccount( + "account-level exports must not contain child derivations".to_owned(), + )) + } + 8 => { + parent_fingerprint = Some( + decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?, + ) + } + 9 | 10 => { + decoder + .str() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + } + _ => { + return Err(Error::InvalidAccount( + "unknown crypto-hdkey map entry".to_owned(), + )) + } + } + } + if is_private { + return Err(Error::InvalidAccount( + "private key material is forbidden".to_owned(), + )); + } + let expected: PolicyNetwork = expected_network.try_into()?; + if network != expected { + return Err(Error::InvalidNetwork); + } + let (path, source_fingerprint) = + origin.ok_or_else(|| Error::InvalidAccount("HD key origin is required".to_owned()))?; + if let Some(source) = source_fingerprint { + if source != account_fingerprint { + return Err(Error::InvalidFingerprint); + } + } + if path.len() != 4 + || !matches!(path[0], ChildNumber::Hardened { index: 48 }) + || !matches!(path[1], ChildNumber::Hardened { index: 0 | 1 }) + || !matches!(path[2], ChildNumber::Hardened { .. }) + || !matches!(path[3], ChildNumber::Hardened { index: 2 }) + { + return Err(Error::InvalidAccount( + "expected BIP48 native-SegWit origin m/48'/coin_type'/account'/2'".to_owned(), + )); + } + let path_network = match path[1] { + ChildNumber::Hardened { index: 0 } => PolicyNetwork::BTC, + ChildNumber::Hardened { index: 1 } => PolicyNetwork::TBTC, + _ => return Err(Error::InvalidNetwork), + }; + if path_network != network { + return Err(Error::InvalidNetwork); + } + + let public_key = PublicKey::from_slice( + &key_data.ok_or_else(|| Error::InvalidAccount("HD public key is required".to_owned()))?, + ) + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + let fingerprint_bytes = account_fingerprint.to_be_bytes(); + let fingerprint = Fingerprint::from(&fingerprint_bytes); + let xpub = Xpub { + network: match network { + PolicyNetwork::BTC => NetworkKind::Main, + PolicyNetwork::TBTC => NetworkKind::Test, + }, + depth: path.len() as u8, + parent_fingerprint: Fingerprint::from( + &parent_fingerprint + .ok_or_else(|| Error::InvalidAccount("parent fingerprint is required".to_owned()))? + .to_be_bytes(), + ), + child_number: *path.last().expect("BIP48 path has four elements"), + public_key, + chain_code: ChainCode::from( + &chain_code + .ok_or_else(|| Error::InvalidAccount("chain code is required".to_owned()))?, + ), + }; + let origin = DerivationPath::from(path); + let origin = origin.to_string(); + let origin = origin.strip_prefix("m/").unwrap_or(&origin); + let account = DescriptorPublicKey::from_str(&format!("[{fingerprint}/{origin}]{xpub}")) + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + Ok(PassportAccount { + fingerprint, + account, + network, + }) +} + +fn expect_tag(decoder: &mut minicbor::Decoder<'_>, expected: u64) -> Result<(), Error> { + let actual = decoder + .tag() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if actual.as_u64() == expected { + Ok(()) + } else { + Err(Error::InvalidAccount(format!( + "expected CBOR tag {expected}, received {}", + actual.as_u64() + ))) + } +} + +fn decode_coin_info(decoder: &mut minicbor::Decoder<'_>) -> Result { + let len = decoder + .map() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| { + Error::InvalidAccount("indefinite coin-info maps are forbidden".to_owned()) + })?; + let mut coin_type = 0u32; + let mut network = 0u64; + let mut seen = HashSet::new(); + for _ in 0..len { + let key = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if !seen.insert(key) { + return Err(Error::InvalidAccount( + "duplicate coin-info map entry".to_owned(), + )); + } + match key { + 1 => { + coin_type = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + } + 2 => { + network = decoder + .u64() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + } + _ => return Err(Error::InvalidAccount("unknown coin-info entry".to_owned())), + } + } + if coin_type != 0 { + return Err(Error::InvalidNetwork); + } + match network { + 0 => Ok(PolicyNetwork::BTC), + 1 => Ok(PolicyNetwork::TBTC), + _ => Err(Error::InvalidNetwork), + } +} + +fn decode_keypath( + decoder: &mut minicbor::Decoder<'_>, +) -> Result<(Vec, Option), Error> { + let len = decoder + .map() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| Error::InvalidAccount("indefinite keypath maps are forbidden".to_owned()))?; + let mut path = None; + let mut source = None; + let mut seen = HashSet::new(); + for _ in 0..len { + let key = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + if !seen.insert(key) { + return Err(Error::InvalidAccount( + "duplicate keypath map entry".to_owned(), + )); + } + match key { + 1 => { + let components = decoder + .array() + .map_err(|e| Error::InvalidAccount(e.to_string()))? + .ok_or_else(|| { + Error::InvalidAccount("indefinite keypath arrays are forbidden".to_owned()) + })?; + if components % 2 != 0 || components > 16 { + return Err(Error::InvalidAccount( + "invalid or oversized keypath".to_owned(), + )); + } + let mut decoded_path = Vec::with_capacity((components / 2) as usize); + for _ in 0..components / 2 { + let index = decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + let hardened = decoder + .bool() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + let child = if hardened { + ChildNumber::from_hardened_idx(index) + } else { + ChildNumber::from_normal_idx(index) + } + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + decoded_path.push(child); + } + path = Some(decoded_path); + } + 2 => { + source = Some( + decoder + .u32() + .map_err(|e| Error::InvalidAccount(e.to_string()))?, + ) + } + 3 => { + decoder + .u8() + .map_err(|e| Error::InvalidAccount(e.to_string()))?; + } + _ => return Err(Error::InvalidAccount("unknown keypath entry".to_owned())), + } + } + Ok(( + path.ok_or_else(|| Error::InvalidAccount("keypath components are required".to_owned()))?, + source, + )) +} + +#[derive(Debug, Clone)] +pub enum AirgappedResponse { + VerifiedAddress(VerifiedAddress), + SignedPsbt(Psbt), +} + +/// Validate an air-gapped signing response and merge only signature fields +/// into Liana's canonical PSBT. +/// +/// This intentionally does not replace global/input/output maps, so existing +/// unknown and proprietary fields cannot disappear or be rewritten by the +/// returned file/QR. Signers may normalize or omit metadata in their returned +/// PSBT; those differences are ignored because every accepted signature is +/// checked against the original transaction before it is merged. +pub fn validate_and_merge_psbt(original: &Psbt, returned: &Psbt) -> Result { + if original.unsigned_tx != returned.unsigned_tx + || original.inputs.len() != returned.inputs.len() + || original.outputs.len() != returned.outputs.len() + { + return Err(Error::InvalidPsbt( + "returned transaction does not match".to_owned(), + )); + } + + let mut merged = original.clone(); + let mut added = 0usize; + for (index, (canonical, signed)) in original + .inputs + .iter() + .zip(returned.inputs.iter()) + .enumerate() + { + for (public_key, signature) in &canonical.partial_sigs { + if signed.partial_sigs.get(public_key) != Some(signature) { + return Err(Error::InvalidPsbt(format!( + "existing signature disappeared or changed on input {index}" + ))); + } + } + for (public_key, signature) in &signed.partial_sigs { + if let Some(existing) = canonical.partial_sigs.get(public_key) { + if existing != signature { + return Err(Error::InvalidPsbt(format!( + "existing signature changed on input {index}" + ))); + } + continue; + } + if !canonical.bip32_derivation.contains_key(&public_key.inner) { + return Err(Error::InvalidPsbt(format!( + "signature uses an unexpected public key on input {index}" + ))); + } + verify_ecdsa_signature(original, index, public_key, signature)?; + merged.inputs[index] + .partial_sigs + .insert(*public_key, *signature); + added += 1; + } + + for (key, signature) in &canonical.tap_script_sigs { + if signed.tap_script_sigs.get(key) != Some(signature) { + return Err(Error::InvalidPsbt(format!( + "existing Taproot signature disappeared or changed on input {index}" + ))); + } + } + for (key, signature) in &signed.tap_script_sigs { + if let Some(existing) = canonical.tap_script_sigs.get(key) { + if existing != signature { + return Err(Error::InvalidPsbt(format!( + "existing Taproot signature changed on input {index}" + ))); + } + continue; + } + let Some((leaf_hashes, _)) = canonical.tap_key_origins.get(&key.0) else { + return Err(Error::InvalidPsbt(format!( + "Taproot signature uses an unexpected public key on input {index}" + ))); + }; + if !leaf_hashes.contains(&key.1) { + return Err(Error::InvalidPsbt(format!( + "Taproot signature uses an unexpected script leaf on input {index}" + ))); + } + verify_taproot_signature(original, index, key.0, key.1, signature)?; + merged.inputs[index] + .tap_script_sigs + .insert(*key, *signature); + added += 1; + } + + match (canonical.tap_key_sig, signed.tap_key_sig) { + (Some(existing), Some(returned)) if existing == returned => {} + (Some(_), _) => { + return Err(Error::InvalidPsbt(format!( + "existing Taproot key-path signature disappeared or changed on input {index}" + ))); + } + (None, Some(signature)) => { + let internal_key = canonical.tap_internal_key.ok_or_else(|| { + Error::InvalidPsbt(format!( + "Taproot key-path signature has no expected internal key on input {index}" + )) + })?; + if !canonical.tap_key_origins.contains_key(&internal_key) { + return Err(Error::InvalidPsbt(format!( + "Taproot key-path signature uses an unexpected key on input {index}" + ))); + } + verify_taproot_key_signature(original, index, &signature)?; + merged.inputs[index].tap_key_sig = Some(signature); + added += 1; + } + (None, None) => {} + } + } + + if added == 0 { + return Err(Error::InvalidPsbt("signature was not added".to_owned())); + } + Ok(merged) +} + +fn verify_ecdsa_signature( + psbt: &Psbt, + input_index: usize, + public_key: &liana::miniscript::bitcoin::PublicKey, + signature: &ecdsa::Signature, +) -> Result<(), Error> { + let mut verification_psbt = psbt.clone(); + if let Some(declared) = verification_psbt.inputs[input_index].sighash_type { + let declared = declared.ecdsa_hash_ty().map_err(|_| { + Error::InvalidPsbt(format!("invalid sighash type on input {input_index}")) + })?; + if declared != signature.sighash_type { + return Err(Error::InvalidPsbt(format!( + "signature sighash type does not match input {input_index}" + ))); + } + } else { + verification_psbt.inputs[input_index].sighash_type = Some(signature.sighash_type.into()); + } + let mut cache = SighashCache::new(&verification_psbt.unsigned_tx); + let message = verification_psbt + .sighash_msg(input_index, &mut cache, None) + .map_err(|error| { + Error::InvalidPsbt(format!( + "could not calculate signature hash for input {input_index}: {error}" + )) + })? + .to_secp_msg(); + Secp256k1::verification_only() + .verify_ecdsa(&message, &signature.signature, &public_key.inner) + .map_err(|_| Error::InvalidPsbt(format!("invalid signature on input {input_index}"))) +} + +fn verify_taproot_signature( + psbt: &Psbt, + input_index: usize, + public_key: secp256k1::XOnlyPublicKey, + leaf_hash: TapLeafHash, + signature: &taproot::Signature, +) -> Result<(), Error> { + let mut verification_psbt = psbt.clone(); + if let Some(declared) = verification_psbt.inputs[input_index].sighash_type { + let declared = declared.taproot_hash_ty().map_err(|_| { + Error::InvalidPsbt(format!("invalid sighash type on input {input_index}")) + })?; + if declared != signature.sighash_type { + return Err(Error::InvalidPsbt(format!( + "signature sighash type does not match input {input_index}" + ))); + } + } else { + verification_psbt.inputs[input_index].sighash_type = Some(signature.sighash_type.into()); + } + let mut cache = SighashCache::new(&verification_psbt.unsigned_tx); + let message = verification_psbt + .sighash_msg(input_index, &mut cache, Some(leaf_hash)) + .map_err(|error| { + Error::InvalidPsbt(format!( + "could not calculate Taproot signature hash for input {input_index}: {error}" + )) + })? + .to_secp_msg(); + Secp256k1::verification_only() + .verify_schnorr(&signature.signature, &message, &public_key) + .map_err(|_| { + Error::InvalidPsbt(format!("invalid Taproot signature on input {input_index}")) + }) +} + +fn verify_taproot_key_signature( + psbt: &Psbt, + input_index: usize, + signature: &taproot::Signature, +) -> Result<(), Error> { + let mut verification_psbt = psbt.clone(); + if let Some(declared) = verification_psbt.inputs[input_index].sighash_type { + let declared = declared.taproot_hash_ty().map_err(|_| { + Error::InvalidPsbt(format!("invalid sighash type on input {input_index}")) + })?; + if declared != signature.sighash_type { + return Err(Error::InvalidPsbt(format!( + "signature sighash type does not match input {input_index}" + ))); + } + } else { + verification_psbt.inputs[input_index].sighash_type = Some(signature.sighash_type.into()); + } + let spent = verification_psbt.spend_utxo(input_index).map_err(|error| { + Error::InvalidPsbt(format!( + "missing Taproot input value at input {input_index}: {error}" + )) + })?; + let script = spent.script_pubkey.as_bytes(); + if !spent.script_pubkey.is_p2tr() || script.len() != 34 { + return Err(Error::InvalidPsbt(format!( + "Taproot key-path signature is not for a Taproot output on input {input_index}" + ))); + } + let output_key = secp256k1::XOnlyPublicKey::from_slice(&script[2..]).map_err(|_| { + Error::InvalidPsbt(format!("invalid Taproot output key on input {input_index}")) + })?; + let mut cache = SighashCache::new(&verification_psbt.unsigned_tx); + let message = verification_psbt + .sighash_msg(input_index, &mut cache, None) + .map_err(|error| { + Error::InvalidPsbt(format!( + "could not calculate Taproot key-path signature hash for input {input_index}: {error}" + )) + })? + .to_secp_msg(); + Secp256k1::verification_only() + .verify_schnorr(&signature.signature, &message, &output_key) + .map_err(|_| { + Error::InvalidPsbt(format!( + "invalid Taproot key-path signature on input {input_index}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use minicbor::data::Tag; + + const TESTNET_ACCOUNT: &str = "[9f141cf0/48'/1'/0'/2']tpubDFnReAwXvYd6RA46X55HuFpmvZsLanDrwHAUsdYEGEpNGTRnCdbDRXJGLTwDeqKURCPZUDgdkuuu9dYkuBNQHmSNBUu7V2CdLKwpJjx2JuC"; + + #[test] + fn micro_sd_account_is_network_and_path_checked() { + let parsed = + PassportAccount::from_descriptor_key(TESTNET_ACCOUNT, Network::Testnet4).unwrap(); + assert_eq!(parsed.fingerprint.to_string(), "9f141cf0"); + assert_eq!(parsed.network, PolicyNetwork::TBTC); + assert_eq!( + PassportAccount::from_descriptor_key(TESTNET_ACCOUNT, Network::Bitcoin), + Err(Error::InvalidNetwork) + ); + } + + #[test] + fn crypto_account_decodes_bip48_native_segwit_cosigner() { + let fallback = + PassportAccount::from_descriptor_key(TESTNET_ACCOUNT, Network::Testnet4).unwrap(); + let xpub = match &fallback.account { + DescriptorPublicKey::XPub(key) => key, + _ => unreachable!(), + }; + let mut encoder = minicbor::Encoder::new(Vec::new()); + encoder + .map(2) + .unwrap() + .u8(1) + .unwrap() + .u32(0x9f141cf0) + .unwrap() + .u8(2) + .unwrap() + .array(1) + .unwrap() + .tag(Tag::new(308)) + .unwrap() + .tag(Tag::new(401)) + .unwrap() + .tag(Tag::new(410)) + .unwrap() + .tag(Tag::new(303)) + .unwrap() + .map(5) + .unwrap() + .u8(3) + .unwrap() + .bytes(&xpub.xkey.public_key.serialize()) + .unwrap() + .u8(4) + .unwrap() + .bytes(&xpub.xkey.chain_code.to_bytes()) + .unwrap() + .u8(5) + .unwrap() + .tag(Tag::new(40305)) + .unwrap() + .map(1) + .unwrap() + .u8(2) + .unwrap() + .u8(1) + .unwrap() + .u8(6) + .unwrap() + .tag(Tag::new(40304)) + .unwrap() + .map(2) + .unwrap() + .u8(1) + .unwrap() + .array(8) + .unwrap() + .u8(48) + .unwrap() + .bool(true) + .unwrap() + .u8(1) + .unwrap() + .bool(true) + .unwrap() + .u8(0) + .unwrap() + .bool(true) + .unwrap() + .u8(2) + .unwrap() + .bool(true) + .unwrap() + .u8(2) + .unwrap() + .u32(0x9f141cf0) + .unwrap() + .u8(8) + .unwrap() + .u32(u32::from_be_bytes(xpub.xkey.parent_fingerprint.to_bytes())) + .unwrap(); + let cbor = encoder.into_writer(); + let mut direct = minicbor::Decoder::new(&cbor); + direct.map().unwrap(); + direct.u8().unwrap(); + direct.u32().unwrap(); + direct.u8().unwrap(); + direct.array().unwrap(); + decode_bip48_cosigner(&mut direct, 0x9f141cf0, Network::Testnet4).unwrap(); + let parsed = PassportAccount::from_crypto_account_cbor(&cbor, Network::Testnet4).unwrap(); + assert_eq!(parsed, fallback); + + // Reorder the two top-level map entries. CBOR maps are unordered, so + // the output array is valid even when it precedes the fingerprint. + assert_eq!( + &cbor[..8], + &[0xa2, 0x01, 0x1a, 0x9f, 0x14, 0x1c, 0xf0, 0x02] + ); + let mut reordered = vec![0xa2, 0x02]; + reordered.extend_from_slice(&cbor[8..]); + reordered.extend_from_slice(&cbor[1..7]); + assert_eq!( + PassportAccount::from_crypto_account_cbor(&reordered, Network::Testnet4).unwrap(), + fallback + ); + } + + #[test] + fn crypto_account_rejects_duplicate_map_entries() { + let mut encoder = minicbor::Encoder::new(Vec::new()); + encoder + .map(2) + .unwrap() + .u8(1) + .unwrap() + .u32(0x9f141cf0) + .unwrap() + .u8(1) + .unwrap() + .u32(0x9f141cf0) + .unwrap(); + assert!(matches!( + PassportAccount::from_crypto_account_cbor( + &encoder.into_writer(), + Network::Testnet4 + ), + Err(Error::InvalidAccount(error)) if error.contains("duplicate") + )); + } + + #[test] + fn active_operation_enforces_response_type() { + let payload = UrPayload::bytes(b"{}".to_vec()); + assert!(matches!( + ExpectedResponse::SignedPsbt.decode(payload), + Err(Error::WrongUrType { .. }) + )); + } +} diff --git a/liana-gui/src/airgap/session.rs b/liana-gui/src/airgap/session.rs new file mode 100644 index 0000000000..a96d5cee04 --- /dev/null +++ b/liana-gui/src/airgap/session.rs @@ -0,0 +1,251 @@ +use std::time::{Duration, Instant}; + +use foundation_ur::{ + bytewords::{self, Style}, + fountain::part::Part, + Decoder, UR, +}; + +use super::{ur::decode_registry_value, Error, UrPayload, UrType}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScanLimits { + pub maximum_decoded_bytes: usize, + pub maximum_fragment_count: u32, + pub maximum_fragment_chars: usize, + pub maximum_fragment_bytes: usize, + pub timeout: Duration, +} + +impl Default for ScanLimits { + fn default() -> Self { + Self { + // Match Passport Core's own bounded decoder contract. + maximum_decoded_bytes: 24 * 1024, + maximum_fragment_count: 128, + maximum_fragment_chars: 1_408, + maximum_fragment_bytes: 700, + timeout: Duration::from_secs(120), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DecodeProgress { + Incomplete { estimated: f32 }, + Complete(UrPayload), +} + +pub struct UrDecodeSession { + expected: UrType, + limits: ScanLimits, + decoder: Decoder, + started_at: Option, + cancelled: bool, +} + +impl UrDecodeSession { + pub fn new(expected: UrType, limits: ScanLimits) -> Self { + Self { + expected, + limits, + decoder: Decoder::default(), + started_at: None, + cancelled: false, + } + } + + pub fn receive(&mut self, fragment: &str) -> Result { + self.receive_at(fragment, Instant::now()) + } + + pub fn receive_at(&mut self, fragment: &str, now: Instant) -> Result { + if self.cancelled { + return Err(Error::Cancelled); + } + let started_at = *self.started_at.get_or_insert(now); + if now.saturating_duration_since(started_at) > self.limits.timeout { + return Err(Error::TimedOut); + } + if fragment.is_empty() { + return Err(Error::Empty); + } + if fragment.len() > self.limits.maximum_fragment_chars { + return Err(Error::FragmentTooLarge { + actual: fragment.len(), + maximum: self.limits.maximum_fragment_chars, + }); + } + let normalized = fragment.to_ascii_lowercase(); + let parsed = UR::parse(&normalized).map_err(|e| Error::InvalidUr(e.to_string()))?; + if parsed.as_type() != self.expected.as_str() + && !(self.expected == UrType::CryptoPsbt && parsed.as_type() == "psbt") + { + return Err(Error::WrongUrType { + expected: self.expected.as_str(), + actual: parsed.as_type().to_owned(), + }); + } + + if parsed.is_single_part() { + if !self.decoder.is_empty() { + return Err(Error::MixedSession); + } + let cbor = super::ur::decode_single_part( + parsed.as_bytewords().ok_or_else(|| { + Error::InvalidUr("single-part UR has no bytewords payload".to_owned()) + })?, + self.limits.maximum_decoded_bytes, + )?; + let data = decode_registry_value(self.expected, &cbor)?; + self.ensure_payload_limit(data.len())?; + return Ok(DecodeProgress::Complete(UrPayload { + ur_type: self.expected, + data, + })); + } + + let sequence_count = parsed.sequence_count().ok_or(Error::Incomplete)?; + if sequence_count > self.limits.maximum_fragment_count { + return Err(Error::TooManyFragments { + actual: sequence_count, + maximum: self.limits.maximum_fragment_count, + }); + } + let bytewords = parsed + .as_bytewords() + .ok_or_else(|| Error::InvalidUr("multipart UR has no bytewords fragment".to_owned()))?; + let decoded_size = bytewords::validate(bytewords, Style::Minimal) + .map_err(|e| Error::InvalidUr(e.to_string()))?; + if decoded_size > self.limits.maximum_fragment_bytes { + return Err(Error::FragmentTooLarge { + actual: decoded_size, + maximum: self.limits.maximum_fragment_bytes, + }); + } + let mut decoded = vec![0u8; decoded_size]; + let written = bytewords::decode_to_slice(bytewords, &mut decoded, Style::Minimal) + .map_err(|e| Error::InvalidUr(e.to_string()))?; + decoded.truncate(written); + let part: Part<'_> = + minicbor::decode(&decoded).map_err(|e| Error::InvalidCbor(e.to_string()))?; + if part.sequence_count > self.limits.maximum_fragment_count { + return Err(Error::TooManyFragments { + actual: part.sequence_count, + maximum: self.limits.maximum_fragment_count, + }); + } + self.ensure_payload_limit(part.message_length)?; + let padded = part + .data + .len() + .checked_mul(part.sequence_count as usize) + .ok_or(Error::PayloadTooLarge { + actual: usize::MAX, + maximum: self.limits.maximum_decoded_bytes, + })?; + self.ensure_payload_limit(padded)?; + + let safe_part = UR::MultiPartDeserialized { + ur_type: self.expected.as_str(), + fragment: part, + }; + self.decoder.receive(safe_part).map_err(|e| match e { + foundation_ur::decoder::Error::InconsistentType + | foundation_ur::decoder::Error::Fountain( + foundation_ur::fountain::decoder::Error::InconsistentPart { .. }, + ) => Error::MixedSession, + _ => Error::InvalidUr(e.to_string()), + })?; + + if self.decoder.is_complete() { + let cbor = self + .decoder + .message() + .map_err(|e| Error::InvalidUr(e.to_string()))? + .ok_or(Error::Incomplete)?; + self.ensure_payload_limit(cbor.len())?; + let data = decode_registry_value(self.expected, cbor)?; + self.ensure_payload_limit(data.len())?; + Ok(DecodeProgress::Complete(UrPayload { + ur_type: self.expected, + data, + })) + } else { + Ok(DecodeProgress::Incomplete { + estimated: self.decoder.estimated_percent_complete() as f32, + }) + } + } + + pub fn cancel(&mut self) { + self.cancelled = true; + self.decoder.clear(); + } + + pub fn restart(&mut self) { + self.cancelled = false; + self.started_at = None; + self.decoder.clear(); + } + + fn ensure_payload_limit(&self, actual: usize) -> Result<(), Error> { + if actual > self.limits.maximum_decoded_bytes { + Err(Error::PayloadTooLarge { + actual, + maximum: self.limits.maximum_decoded_bytes, + }) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::airgap::{encode_ur, UrPayload}; + + #[test] + fn multipart_accepts_reordering_and_duplicates() { + let encoded = encode_ur(&UrPayload::bytes(vec![42; 1_024]), 100).unwrap(); + assert!(encoded.is_multipart()); + let mut frames = encoded.frames.clone(); + frames.reverse(); + frames.insert(1, frames[0].clone()); + let mut session = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + let mut result = None; + for frame in frames { + if let DecodeProgress::Complete(payload) = session.receive(&frame).unwrap() { + result = Some(payload); + break; + } + } + assert_eq!(result.unwrap().data, vec![42; 1_024]); + } + + #[test] + fn cancellation_requires_explicit_restart() { + let encoded = encode_ur(&UrPayload::bytes(b"hello".to_vec()), 100).unwrap(); + let mut session = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + session.cancel(); + assert_eq!(session.receive(&encoded.frames[0]), Err(Error::Cancelled)); + session.restart(); + assert!(matches!( + session.receive(&encoded.frames[0]), + Ok(DecodeProgress::Complete(_)) + )); + } + + #[test] + fn timeout_is_bounded() { + let encoded = encode_ur(&UrPayload::bytes(vec![1; 500]), 100).unwrap(); + let mut session = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + let start = Instant::now(); + session.receive_at(&encoded.frames[0], start).unwrap(); + assert_eq!( + session.receive_at(&encoded.frames[1], start + Duration::from_secs(121)), + Err(Error::TimedOut) + ); + } +} diff --git a/liana-gui/src/airgap/ur.rs b/liana-gui/src/airgap/ur.rs new file mode 100644 index 0000000000..23e24cf2c3 --- /dev/null +++ b/liana-gui/src/airgap/ur.rs @@ -0,0 +1,242 @@ +use foundation_ur::{ + bytewords::{self, Style}, + Encoder, UR, +}; +use liana::miniscript::bitcoin::psbt::Psbt; + +use super::Error; + +// Passport Core and Passport Prime share Foundation's bounded BC-UR decoder. +// Keep the encoded registry message within the device's 24 KiB ceiling; larger +// binary PSBTs remain available through the bounded microSD path. +const PASSPORT_MAX_UR_MESSAGE_BYTES: usize = 24 * 1024; +const PASSPORT_MAX_UR_FRAGMENTS: u32 = 128; + +// Keep three deliberately separated presets so changing density has a visible +// effect. The lowest tier helps signers with less capable cameras by halving +// the data in each frame relative to Low. The encoder also enforces the +// signer's fragment ceiling, so large payloads must use a denser tier. +const QR_FRAGMENT_LENGTHS: [usize; 3] = [60, 120, 400]; + +/// User-adjustable amount of data carried by each animated QR frame. +/// Lower levels make simpler QR symbols at the cost of additional frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QrDensity(u8); + +impl QrDensity { + const DEFAULT_LEVEL: u8 = 1; + + pub fn fragment_length(self) -> usize { + QR_FRAGMENT_LENGTHS[usize::from(self.0)] + } + + pub fn label(self) -> &'static str { + match self.0 { + 0 => "Very low", + 1 => "Low", + _ => "High", + } + } + + pub fn less_dense(self) -> Option { + self.0.checked_sub(1).map(Self) + } + + pub fn more_dense(self) -> Option { + let next = self.0 + 1; + (usize::from(next) < QR_FRAGMENT_LENGTHS.len()).then_some(Self(next)) + } +} + +impl Default for QrDensity { + fn default() -> Self { + Self(Self::DEFAULT_LEVEL) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UrType { + Bytes, + CryptoPsbt, + CryptoAccount, +} + +impl UrType { + pub const fn as_str(self) -> &'static str { + match self { + Self::Bytes => "bytes", + Self::CryptoPsbt => "crypto-psbt", + Self::CryptoAccount => "crypto-account", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UrPayload { + pub ur_type: UrType, + /// Registry value data. `bytes` and `crypto-psbt` have their CBOR byte + /// string removed; `crypto-account` remains registry CBOR for typed account + /// decoding. + pub data: Vec, +} + +impl UrPayload { + pub fn bytes(data: impl Into>) -> Self { + Self { + ur_type: UrType::Bytes, + data: data.into(), + } + } + + pub fn psbt(psbt: &Psbt) -> Self { + Self { + ur_type: UrType::CryptoPsbt, + data: psbt.serialize(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncodedUr { + pub ur_type: UrType, + pub frames: Vec, +} + +impl EncodedUr { + pub fn is_multipart(&self) -> bool { + self.frames.len() > 1 + } +} + +/// Encode one complete deterministic cycle of a BC-UR v2 stream. +pub fn encode_ur(payload: &UrPayload, max_fragment_length: usize) -> Result { + if payload.data.is_empty() { + return Err(Error::Empty); + } + if max_fragment_length == 0 { + return Err(Error::InvalidUr( + "maximum fragment length must be positive".to_owned(), + )); + } + let cbor = match payload.ur_type { + UrType::Bytes | UrType::CryptoPsbt => encode_cbor_bytes(&payload.data)?, + UrType::CryptoAccount => payload.data.clone(), + }; + if cbor.len() > PASSPORT_MAX_UR_MESSAGE_BYTES { + return Err(Error::PayloadTooLarge { + actual: cbor.len(), + maximum: PASSPORT_MAX_UR_MESSAGE_BYTES, + }); + } + let frames = if cbor.len() <= max_fragment_length { + vec![UR::new(payload.ur_type.as_str(), &cbor).to_string()] + } else { + let mut encoder = Encoder::new(); + encoder.start(payload.ur_type.as_str(), &cbor, max_fragment_length); + let count = encoder.sequence_count(); + if count > PASSPORT_MAX_UR_FRAGMENTS { + return Err(Error::TooManyFragments { + actual: count, + maximum: PASSPORT_MAX_UR_FRAGMENTS, + }); + } + (0..count) + .map(|_| encoder.next_part().to_string()) + .collect() + }; + Ok(EncodedUr { + ur_type: payload.ur_type, + frames, + }) +} + +pub(crate) fn decode_registry_value(ur_type: UrType, cbor: &[u8]) -> Result, Error> { + if ur_type == UrType::CryptoAccount { + return Ok(cbor.to_vec()); + } + let mut decoder = minicbor::Decoder::new(cbor); + let bytes = decoder + .bytes() + .map_err(|e| Error::InvalidCbor(e.to_string()))?; + if decoder.position() != cbor.len() { + return Err(Error::InvalidCbor("trailing CBOR data".to_owned())); + } + Ok(bytes.to_vec()) +} + +pub(crate) fn decode_single_part(encoded: &str, maximum: usize) -> Result, Error> { + let size = bytewords::validate(encoded, Style::Minimal) + .map_err(|e| Error::InvalidUr(e.to_string()))?; + if size > maximum { + return Err(Error::PayloadTooLarge { + actual: size, + maximum, + }); + } + let mut decoded = vec![0u8; size]; + let written = bytewords::decode_to_slice(encoded, &mut decoded, Style::Minimal) + .map_err(|e| Error::InvalidUr(e.to_string()))?; + decoded.truncate(written); + Ok(decoded) +} + +fn encode_cbor_bytes(data: &[u8]) -> Result, Error> { + let mut encoder = minicbor::Encoder::new(Vec::with_capacity(data.len() + 5)); + encoder + .bytes(data) + .map_err(|e| Error::InvalidCbor(e.to_string()))?; + Ok(encoder.into_writer()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outgoing_ur_respects_passport_decoder_ceiling() { + assert!(encode_ur( + &UrPayload::bytes(vec![0; PASSPORT_MAX_UR_MESSAGE_BYTES]), + 250 + ) + .is_err()); + assert!(encode_ur(&UrPayload::bytes(vec![0; 23 * 1024]), 250).is_ok()); + } + + #[test] + fn outgoing_ur_respects_passport_fragment_ceiling() { + assert!(matches!( + encode_ur(&UrPayload::bytes(vec![0; 23 * 1024]), 120), + Err(Error::TooManyFragments { maximum: 128, .. }) + )); + } + + #[test] + fn qr_density_adjustments_are_bounded_and_monotonic() { + let low = QrDensity::default(); + let very_low = low.less_dense().unwrap(); + let high = low.more_dense().unwrap(); + + assert_eq!(very_low.label(), "Very low"); + assert_eq!(low.label(), "Low"); + assert_eq!(high.label(), "High"); + assert!(very_low.fragment_length() < low.fragment_length()); + assert!(low.fragment_length() < high.fragment_length()); + assert!(very_low.less_dense().is_none()); + assert!(high.more_dense().is_none()); + assert_eq!(low.less_dense(), Some(very_low)); + assert_eq!(very_low.more_dense(), Some(low)); + assert_eq!(high.less_dense(), Some(low)); + + let payload = UrPayload::bytes(vec![42; 1_000]); + let simplest = encode_ur(&payload, very_low.fragment_length()).unwrap(); + let simpler = encode_ur(&payload, low.fragment_length()).unwrap(); + let denser = encode_ur(&payload, high.fragment_length()).unwrap(); + assert!(simplest.frames.len() >= simpler.frames.len() * 2 - 1); + assert!(simpler.frames.len() >= denser.frames.len() * 2); + let simplest_frame_length = simplest.frames.iter().map(String::len).max().unwrap(); + let simpler_frame_length = simpler.frames.iter().map(String::len).max().unwrap(); + let denser_frame_length = denser.frames.iter().map(String::len).max().unwrap(); + assert!(simpler_frame_length >= simplest_frame_length * 3 / 2); + assert!(denser_frame_length >= simpler_frame_length * 2); + } +} diff --git a/liana-gui/src/app/settings/mod.rs b/liana-gui/src/app/settings/mod.rs index 09757e7892..25573bc5e6 100644 --- a/liana-gui/src/app/settings/mod.rs +++ b/liana-gui/src/app/settings/mod.rs @@ -24,6 +24,7 @@ use liana::miniscript::bitcoin; use lianad::commands::ListCoinsResult; use crate::{ + airgap::AirgappedSignerConfig, app::{self, state::State}, backup::{Key, KeyRole, KeyType}, dir::{LianaDirectory, NetworkDirectory}, @@ -102,6 +103,10 @@ pub trait WalletSettingsTrait: Clone + Serialize + DeserializeOwned + Send + 'st fn keys(&self) -> &[KeySetting]; /// Get the list of hardware wallet configurations registered with this wallet. fn hardware_wallets(&self) -> &[HardwareWalletConfig]; + /// Get persisted asynchronous air-gapped signers for this wallet. + fn airgapped_signers(&self) -> &[AirgappedSignerConfig] { + &[] + } /// Get the remote backend authentication config, if this wallet uses a remote backend. fn remote_backend_auth(&self) -> Option<&AuthConfig>; /// Get the fiat price conversion settings for this wallet. @@ -280,6 +285,9 @@ pub struct LianaWalletSettings { // wallet metadata #[serde(default)] pub hardware_wallets: Vec, + /// Public-only asynchronous signers. Kept separate from live USB devices. + #[serde(default)] + pub airgapped_signers: Vec, pub remote_backend_auth: Option, /// Start internal bitcoind executable. /// if None, the app must refer to the gui.toml start_internal_bitcoind field. @@ -377,6 +385,10 @@ impl WalletSettingsTrait for LianaWalletSettings { &self.hardware_wallets } + fn airgapped_signers(&self) -> &[AirgappedSignerConfig] { + &self.airgapped_signers + } + fn remote_backend_auth(&self) -> Option<&AuthConfig> { self.remote_backend_auth.as_ref() } @@ -755,6 +767,7 @@ pub mod global { #[cfg(test)] mod test { use super::global::{GlobalSettings, WindowConfig}; + use super::LianaSettings; use std::env; const RAW_GLOBAL_SETTINGS: &str = r#"{ @@ -843,6 +856,27 @@ mod test { let _ = serde_json::from_str::(RAW_GLOBAL_SETTINGS).unwrap(); } + #[test] + fn legacy_wallet_settings_default_to_no_airgapped_signers() { + let settings: LianaSettings = serde_json::from_str( + r#"{ + "wallets": [{ + "name": "Legacy", + "alias": null, + "descriptor_checksum": "u768v50p", + "pinned_at": null, + "keys": [], + "hardware_wallets": [], + "remote_backend_auth": null, + "start_internal_bitcoind": null, + "fiat_price": null + }] + }"#, + ) + .unwrap(); + assert!(settings.wallets[0].airgapped_signers.is_empty()); + } + #[test] fn test_update_global_config() { let path = env::current_dir() diff --git a/liana-gui/src/app/state/airgap.rs b/liana-gui/src/app/state/airgap.rs new file mode 100644 index 0000000000..f7a9a05731 --- /dev/null +++ b/liana-gui/src/app/state/airgap.rs @@ -0,0 +1,796 @@ +use std::{ + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, + time::Duration, +}; + +use iced::{ + alignment::Horizontal, + widget::{image, progress_bar, qr_code, row, Column, Space}, + Alignment, Length, Subscription, Task, +}; +use liana_ui::{ + component::{ + button, card, + text::{p1_bold, p1_regular}, + }, + theme, + widget::{Container, Element, SpaceExt}, +}; + +use crate::{ + airgap::{ + encode_ur, request_camera_access, AirgappedRequest, AirgappedResponse, AnimatedQr, + CameraDescriptor, CameraEvent, CameraFailure, CameraScanner, ExpectedResponse, QrDensity, + ScanLimits, UrPayload, + }, + app::{message::Message, view}, + export::get_path, +}; + +const QR_FRAMES_PER_SECOND: u8 = 5; +const QR_DISPLAY_SIZE: f32 = 440.0; +const QR_MODAL_WIDTH: f32 = 860.0; +const DEFAULT_MODAL_WIDTH: f32 = 560.0; +const MAX_JSON_RESPONSE_FILE_BYTES: usize = 24 * 1024; +const MAX_PSBT_RESPONSE_FILE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub enum AirgapAction { + ShowQr, + Tick, + Pause, + Resume, + Restart, + LessDense, + MoreDense, + ExportFile, + FileExported(Result, String>), + ImportResponse, + FileImported(Result>, String>), + ScanResponse, + Cameras(Result, CameraFailure>), + SelectCamera(usize), + PollCamera, + Finish, + Retry, + Cancel, +} + +#[derive(Debug, Clone)] +pub enum AirgapOutcome { + Response(AirgappedResponse), + Exported, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Choose, + DisplayQr, + Exported, + StartingCamera, + Scanning, + Done, +} + +/// Reusable QR/microSD exchange state for registration, address verification, +/// and PSBT signing. It owns and releases the camera and animated QR material. +pub struct AirgapModal { + title: String, + request: AirgappedRequest, + expected: Option, + filename: String, + phase: Phase, + animation: Option, + qr_data: Option, + qr_density: QrDensity, + cameras: Vec, + selected_camera: Option, + scanner: Option, + preview: Option, + progress: f32, + detected_frames: u32, + error: Option, + exported_path: Option, + outcome: Option, +} + +impl AirgapModal { + pub fn new( + title: impl Into, + request: AirgappedRequest, + filename: impl Into, + ) -> Self { + let expected = request.expected_response(); + Self { + title: title.into(), + request, + expected, + filename: filename.into(), + phase: Phase::Choose, + animation: None, + qr_data: None, + qr_density: QrDensity::default(), + cameras: Vec::new(), + selected_camera: None, + scanner: None, + preview: None, + progress: 0.0, + detected_frames: 0, + error: None, + exported_path: None, + outcome: None, + } + } + + pub fn take_outcome(&mut self) -> Option { + self.outcome.take() + } + + pub fn subscription(&self) -> Subscription { + let animation = if self.phase == Phase::DisplayQr && self.animation.is_some() { + iced::time::every(Duration::from_millis(50)) + .map(|_| Message::View(view::Message::Airgap(AirgapAction::Tick))) + } else { + Subscription::none() + }; + let camera = if self.scanner.is_some() { + iced::time::every(Duration::from_millis(33)) + .map(|_| Message::View(view::Message::Airgap(AirgapAction::PollCamera))) + } else { + Subscription::none() + }; + Subscription::batch(vec![animation, camera]) + } + + pub fn update(&mut self, action: AirgapAction) -> Task { + match action { + AirgapAction::ShowQr => { + self.rebuild_qr(); + } + AirgapAction::Tick => self.refresh_qr(), + AirgapAction::Pause => { + if let Some(animation) = self.animation.as_mut() { + animation.pause(); + } + } + AirgapAction::Resume => { + if let Some(animation) = self.animation.as_mut() { + animation.resume(); + } + } + AirgapAction::Restart => { + if let Some(animation) = self.animation.as_mut() { + animation.restart(); + } + self.error = None; + } + AirgapAction::LessDense => { + if let Some(density) = self.qr_density.less_dense() { + self.set_qr_density(density); + } + } + AirgapAction::MoreDense => { + if let Some(density) = self.qr_density.more_dense() { + self.set_qr_density(density); + } + } + AirgapAction::ExportFile => { + let filename = self.filename.clone(); + let bytes = match self.request_file_bytes() { + Ok(bytes) => bytes, + Err(error) => { + self.error = Some(error); + return Task::none(); + } + }; + return Task::perform( + async move { + let Some(path) = get_path(filename, true).await else { + return Ok(None); + }; + fs::write(&path, bytes) + .map(|_| Some(path)) + .map_err(|error| error.to_string()) + }, + |result| { + Message::View(view::Message::Airgap(AirgapAction::FileExported(result))) + }, + ); + } + AirgapAction::FileExported(result) => match result { + Ok(Some(path)) => { + self.exported_path = Some(path); + self.animation = None; + self.qr_data = None; + self.phase = Phase::Exported; + self.error = None; + } + Ok(None) => {} + Err(error) => self.error = Some(error), + }, + AirgapAction::ImportResponse => { + let Some(expected) = self.expected else { + return Task::none(); + }; + let filename = self.response_filename(); + let maximum_bytes = match expected { + ExpectedResponse::SignedPsbt => MAX_PSBT_RESPONSE_FILE_BYTES, + _ => MAX_JSON_RESPONSE_FILE_BYTES, + }; + return Task::perform( + async move { + let Some(path) = get_path(filename, false).await else { + return Ok(None); + }; + read_bounded_file(&path, maximum_bytes).map(Some) + }, + |result| { + Message::View(view::Message::Airgap(AirgapAction::FileImported(result))) + }, + ); + } + AirgapAction::FileImported(result) => match result { + Ok(Some(bytes)) => self.accept_file_response(bytes), + Ok(None) => {} + Err(error) => self.error = Some(error), + }, + AirgapAction::ScanResponse => { + if self.expected.is_none() { + return Task::none(); + } + self.stop_camera(); + self.phase = Phase::StartingCamera; + self.error = None; + return Task::perform(request_camera_access(), |result| { + Message::View(view::Message::Airgap(AirgapAction::Cameras(result))) + }); + } + AirgapAction::Cameras(result) => { + // Ignore a permission callback that arrived after this exchange + // was cancelled or returned to the transport chooser. + if self.phase != Phase::StartingCamera { + return Task::none(); + } + match result { + Ok(cameras) if !cameras.is_empty() => { + self.cameras = cameras; + self.start_camera(0); + } + Ok(_) => { + self.phase = Phase::Choose; + self.error = Some("No camera is available".to_owned()); + } + Err(error) => { + self.phase = Phase::Choose; + self.error = Some(error.to_string()); + } + } + } + AirgapAction::SelectCamera(index) => self.start_camera(index), + AirgapAction::PollCamera => self.poll_camera(), + AirgapAction::Finish => { + if self.expected.is_none() { + self.finish(AirgapOutcome::Exported); + } + } + AirgapAction::Retry => { + self.stop_camera(); + self.animation = None; + self.qr_data = None; + self.error = None; + self.phase = Phase::Choose; + } + AirgapAction::Cancel => self.finish(AirgapOutcome::Cancelled), + } + Task::none() + } + + pub fn view(&self) -> Element<'_, view::Message> { + let msg = |action| view::Message::Airgap(action); + let header = row![ + p1_bold(&self.title), + Space::with_width(Length::Fill), + button::btn_modal_close(Some(msg(AirgapAction::Cancel))) + ] + .align_y(Alignment::Center); + let mut body = Column::new() + .push(header) + .spacing(12) + .align_x(Horizontal::Center); + if let Some(error) = &self.error { + body = body.push(card::error("Air-gapped signer", error.clone())); + } + body = match self.phase { + Phase::Choose => body + .push(p1_regular( + "Choose how to exchange this request with your air-gapped signer.", + )) + .push( + button::primary(None, "Show animated QR code") + .width(Length::Fill) + .on_press(msg(AirgapAction::ShowQr)), + ) + .push( + button::secondary(None, "Export to microSD") + .width(Length::Fill) + .on_press(msg(AirgapAction::ExportFile)), + ), + Phase::DisplayQr => { + let mut controls = Column::new().spacing(12).align_x(Horizontal::Center); + if let Some(data) = &self.qr_data { + body = body.push( + row![ + qr_code::QRCode::::new(data).total_size(QR_DISPLAY_SIZE), + self.qr_controls(controls, &msg) + ] + .spacing(20) + .align_y(Alignment::Start), + ); + body + } else { + controls = controls.push(p1_regular("Preparing QR code…")); + body.push(controls) + } + } + Phase::Exported => { + let body = body.push(p1_regular(match &self.exported_path { + Some(path) => format!("Saved request to {}", path.display()), + None => "Request exported".to_owned(), + })); + self.response_buttons(body, &msg) + } + Phase::StartingCamera => body.push(p1_regular("Requesting camera access…")), + Phase::Scanning => { + let mut content = body.push(p1_regular("Scan the response shown by your signer.")); + if self.cameras.len() > 1 { + for (index, camera) in self.cameras.iter().enumerate() { + let label = if self.selected_camera == Some(index) { + format!("{} (active)", camera.name) + } else { + camera.name.clone() + }; + content = content.push( + button::secondary(None, label) + .width(Length::Fill) + .on_press(msg(AirgapAction::SelectCamera(index))), + ); + } + } + if let Some(preview) = &self.preview { + content = content.push( + image(preview.clone()) + .width(Length::Fill) + .height(Length::Fixed(300.0)), + ); + } + let status = if self.detected_frames == 0 { + "Looking for animated QR frames…".to_owned() + } else { + format!( + "Scanning: {:.0}% · {} QR frame{} detected", + self.progress * 100.0, + self.detected_frames, + if self.detected_frames == 1 { "" } else { "s" } + ) + }; + content = content + .push(progress_bar(0.0..=1.0, self.progress.clamp(0.0, 1.0))) + .push(p1_regular(status)); + content.push( + button::secondary(None, "Back") + .width(Length::Fill) + .on_press(msg(AirgapAction::Retry)), + ) + } + Phase::Done => body, + }; + let modal_width = if self.phase == Phase::DisplayQr { + QR_MODAL_WIDTH + } else { + DEFAULT_MODAL_WIDTH + }; + Container::new(body.width(Length::Fixed(modal_width))) + .padding(20) + .style(theme::card::modal) + .into() + } + + fn qr_controls<'a>( + &'a self, + mut controls: Column<'a, view::Message, theme::Theme>, + msg: &impl Fn(AirgapAction) -> view::Message, + ) -> Column<'a, view::Message, theme::Theme> { + if let Some(animation) = &self.animation { + let state = animation.state(); + if state.total_frames > 1 { + controls = controls + .push(p1_regular(format!( + "Frame {} of {}", + state.frame + 1, + state.total_frames + ))) + .push(if state.paused { + button::secondary(None, "Resume").on_press(msg(AirgapAction::Resume)) + } else { + button::secondary(None, "Pause").on_press(msg(AirgapAction::Pause)) + }) + .push( + button::secondary(None, "Restart QR sequence") + .on_press(msg(AirgapAction::Restart)), + ); + } + } + controls = controls + .push(p1_regular(format!( + "QR density: {}", + self.qr_density.label() + ))) + .push( + row![ + button::secondary(None, "Less dense").on_press_maybe( + self.qr_density + .less_dense() + .map(|_| msg(AirgapAction::LessDense)) + ), + button::secondary(None, "More dense").on_press_maybe( + self.qr_density + .more_dense() + .map(|_| msg(AirgapAction::MoreDense)) + ), + ] + .spacing(10), + ) + .push( + button::secondary(None, "Use microSD instead") + .width(Length::Fill) + .on_press(msg(AirgapAction::ExportFile)), + ); + self.response_buttons(controls, msg) + } + + fn response_buttons<'a>( + &'a self, + body: Column<'a, view::Message, theme::Theme>, + msg: &impl Fn(AirgapAction) -> view::Message, + ) -> Column<'a, view::Message, theme::Theme> { + if self.expected.is_none() { + return body.push( + button::primary(None, "Done") + .width(Length::Fill) + .on_press(msg(AirgapAction::Finish)), + ); + } + body.push( + button::primary(None, "Scan signer response") + .width(Length::Fill) + .on_press(msg(AirgapAction::ScanResponse)), + ) + .push( + button::secondary(None, "Import response from microSD") + .width(Length::Fill) + .on_press(msg(AirgapAction::ImportResponse)), + ) + } + + fn refresh_qr(&mut self) { + if let Some(frame) = self.animation.as_ref().and_then(AnimatedQr::frame) { + match qr_code::Data::new(frame) { + Ok(data) => self.qr_data = Some(data), + Err(error) => self.error = Some(format!("Could not render QR code: {error}")), + } + } + } + + fn rebuild_qr(&mut self) { + let mut density = self.qr_density; + loop { + match self.build_qr(density) { + Ok((animation, qr_data)) => { + self.qr_density = density; + self.animation = Some(animation); + self.qr_data = Some(qr_data); + self.phase = Phase::DisplayQr; + self.error = None; + return; + } + Err(crate::airgap::Error::TooManyFragments { .. }) => { + // Preserve QR availability for larger requests by choosing + // the least-dense preset that stays within the signer cap. + if let Some(denser) = density.more_dense() { + density = denser; + continue; + } + self.error = Some( + "This request needs too many animated QR frames. Export it to microSD instead." + .to_owned(), + ); + return; + } + Err(crate::airgap::Error::PayloadTooLarge { .. }) => { + self.error = Some( + "This request is too large for animated QR. Export it to microSD instead." + .to_owned(), + ); + return; + } + Err(error) => { + self.error = Some(error.to_string()); + return; + } + } + } + } + + fn set_qr_density(&mut self, density: QrDensity) { + match self.build_qr(density) { + Ok((animation, qr_data)) => { + self.qr_density = density; + self.animation = Some(animation); + self.qr_data = Some(qr_data); + self.error = None; + } + Err(crate::airgap::Error::TooManyFragments { .. }) => { + self.error = Some( + "This request needs too many frames at that density. Choose a denser setting or use microSD." + .to_owned(), + ); + } + Err(error) => self.error = Some(error.to_string()), + } + } + + fn build_qr( + &self, + density: QrDensity, + ) -> Result<(AnimatedQr, qr_code::Data), crate::airgap::Error> { + let payload = self.request.encode()?; + let encoded = encode_ur(&payload, density.fragment_length())?; + let animation = AnimatedQr::new(encoded, QR_FRAMES_PER_SECOND)?; + let frame = animation.frame().ok_or(crate::airgap::Error::Empty)?; + let qr_data = qr_code::Data::new(frame).map_err(|error| { + crate::airgap::Error::InvalidUr(format!("could not render QR code: {error}")) + })?; + Ok((animation, qr_data)) + } + + fn request_file_bytes(&self) -> Result, String> { + self.request + .encode() + .map(|payload| payload.data) + .map_err(|error| error.to_string()) + } + + fn response_filename(&self) -> String { + match self.expected { + Some(ExpectedResponse::SignedPsbt) => "signed.psbt".to_owned(), + _ => "signer-response.json".to_owned(), + } + } + + fn accept_file_response(&mut self, bytes: Vec) { + let Some(expected) = self.expected else { + return; + }; + let payload = UrPayload { + ur_type: expected.ur_type(), + data: bytes, + }; + match expected.decode(payload) { + Ok(response) => self.finish(AirgapOutcome::Response(response)), + Err(error) => self.error = Some(error.to_string()), + } + } + + fn start_camera(&mut self, index: usize) { + let fallback_phase = if self.phase == Phase::StartingCamera { + Phase::Choose + } else { + Phase::Scanning + }; + self.stop_camera(); + self.error = None; + let Some(camera) = self.cameras.get(index) else { + self.phase = fallback_phase; + self.error = Some("Selected camera is no longer available".to_owned()); + return; + }; + let Some(expected) = self.expected else { + self.phase = fallback_phase; + self.error = Some("This request does not expect a response".to_owned()); + return; + }; + match CameraScanner::start( + camera.index.clone(), + expected.ur_type(), + ScanLimits::default(), + ) { + Ok(scanner) => { + self.selected_camera = Some(index); + self.scanner = Some(scanner); + self.phase = Phase::Scanning; + } + Err(error) => { + self.phase = fallback_phase; + self.error = Some(error.to_string()); + } + } + } + + fn poll_camera(&mut self) { + let mut complete = None; + let mut failed = false; + if let Some(scanner) = self.scanner.as_ref() { + while let Ok(event) = scanner.try_recv() { + match event { + CameraEvent::Preview { + width, + height, + rgba, + } => { + self.preview = Some(image::Handle::from_rgba(width, height, rgba)); + } + CameraEvent::Progress { + estimated, + detected_frames, + } => { + self.progress = estimated; + self.detected_frames = detected_frames; + } + CameraEvent::Rejected(error) => self.error = Some(error), + CameraEvent::Complete(payload) => complete = Some(payload), + CameraEvent::Failure(error) => { + self.error = Some(error.to_string()); + failed = true; + } + } + } + } + if failed { + self.stop_camera(); + } + if let Some(payload) = complete { + let Some(expected) = self.expected else { + return; + }; + match expected.decode(payload) { + Ok(response) => self.finish(AirgapOutcome::Response(response)), + Err(error) => self.error = Some(error.to_string()), + } + } + } + + fn stop_camera(&mut self) { + self.scanner = None; + self.preview = None; + self.progress = 0.0; + self.detected_frames = 0; + self.selected_camera = None; + } + + fn finish(&mut self, outcome: AirgapOutcome) { + self.stop_camera(); + self.animation = None; + self.qr_data = None; + self.phase = Phase::Done; + self.outcome = Some(outcome); + } +} + +fn read_bounded_file(path: &Path, maximum_bytes: usize) -> Result, String> { + let file = File::open(path).map_err(|error| error.to_string())?; + let length = file.metadata().map_err(|error| error.to_string())?.len(); + if length > maximum_bytes as u64 { + return Err(format!( + "Signer response is too large ({length} bytes; maximum {maximum_bytes})" + )); + } + let mut bytes = Vec::with_capacity(length as usize); + file.take(maximum_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() > maximum_bytes { + return Err(format!( + "Signer response is too large (maximum {maximum_bytes} bytes)" + )); + } + Ok(bytes) +} + +impl Drop for AirgapModal { + fn drop(&mut self) { + self.stop_camera(); + self.animation = None; + self.qr_data = None; + } +} + +#[cfg(test)] +mod tests { + use std::{fs, time::SystemTime}; + + use crate::airgap::PolicyRegistration; + + use super::*; + + #[test] + fn registration_export_requires_explicit_user_confirmation() { + let registration = PolicyRegistration::from_json(include_bytes!( + "../../../test_assets/passport/policy-registration-mainnet.json" + )) + .unwrap(); + let mut modal = AirgapModal::new( + "Register policy", + AirgappedRequest::RegisterPolicy(registration), + "liana-policy.json", + ); + + let _ = modal.update(AirgapAction::FileExported(Ok(Some(PathBuf::from( + "liana-policy.json", + ))))); + assert!(modal.take_outcome().is_none()); + + let _ = modal.update(AirgapAction::Finish); + assert!(matches!( + modal.take_outcome(), + Some(AirgapOutcome::Exported) + )); + } + + #[test] + fn response_file_reads_are_bounded_before_protocol_decoding() { + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "liana-airgap-response-{}-{unique}", + std::process::id() + )); + fs::write(&path, [1, 2, 3, 4]).unwrap(); + assert_eq!(read_bounded_file(&path, 4).unwrap(), [1, 2, 3, 4]); + assert!(read_bounded_file(&path, 3) + .unwrap_err() + .contains("too large")); + fs::remove_file(path).unwrap(); + } + + #[test] + fn camera_failure_returns_to_the_transport_chooser() { + let registration = PolicyRegistration::from_json(include_bytes!( + "../../../test_assets/passport/policy-registration-mainnet.json" + )) + .unwrap(); + let mut modal = AirgapModal::new( + "Register policy", + AirgappedRequest::RegisterPolicy(registration), + "liana-policy.json", + ); + modal.phase = Phase::StartingCamera; + + let _ = modal.update(AirgapAction::Cameras(Err(CameraFailure::PermissionDenied))); + + assert_eq!(modal.phase, Phase::Choose); + assert_eq!(modal.error.as_deref(), Some("camera permission denied")); + } + + #[test] + fn stale_camera_permission_callback_is_ignored() { + let registration = PolicyRegistration::from_json(include_bytes!( + "../../../test_assets/passport/policy-registration-mainnet.json" + )) + .unwrap(); + let mut modal = AirgapModal::new( + "Register policy", + AirgappedRequest::RegisterPolicy(registration), + "liana-policy.json", + ); + + let _ = modal.update(AirgapAction::Cameras(Err(CameraFailure::PermissionDenied))); + + assert_eq!(modal.phase, Phase::Choose); + assert!(modal.error.is_none()); + } +} diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index f8f42d301d..26a4ed871c 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -1,3 +1,4 @@ +pub mod airgap; mod coins; pub mod export; mod label; diff --git a/liana-gui/src/app/state/psbt.rs b/liana-gui/src/app/state/psbt.rs index a6353f5123..e47ecaf0fa 100644 --- a/liana-gui/src/app/state/psbt.rs +++ b/liana-gui/src/app/state/psbt.rs @@ -16,11 +16,15 @@ use liana_ui::{widget::modal, widget::Element}; use crate::daemon::model::LabelsLoader; use crate::export::{ImportExportMessage, ImportExportType, Progress}; use crate::{ + airgap::{validate_and_merge_psbt, AirgappedRequest, AirgappedResponse, AirgappedSignerConfig}, app::{ cache::Cache, error::Error, message::Message, - state::label::{label_item_from_str, LabelsEdited}, + state::{ + airgap::{AirgapModal, AirgapOutcome}, + label::{label_item_from_str, LabelsEdited}, + }, view, wallet::{Wallet, WalletError}, }, @@ -454,6 +458,7 @@ impl Modal for DeleteModal { pub struct SignModal { wallet: Arc, + airgapped_signers: Vec, hws: HardwareWallets, error: Option, signing: HashSet, @@ -461,6 +466,7 @@ pub struct SignModal { is_saved: bool, display_modal: bool, recovery_timelock: Option, + airgap_exchange: Option, } impl SignModal { @@ -472,15 +478,18 @@ impl SignModal { is_saved: bool, recovery_timelock: Option, ) -> Self { + let airgapped_signers = wallet.airgapped_signer_candidates(network); Self { signing: HashSet::new(), hws: HardwareWallets::new(datadir_path, network).with_wallet(wallet.clone()), wallet, + airgapped_signers, error: None, signed, is_saved, display_modal: true, recovery_timelock, + airgap_exchange: None, } } @@ -491,7 +500,11 @@ impl SignModal { impl Modal for SignModal { fn subscription(&self) -> Subscription { - self.hws.refresh().map(Message::HardwareWallets) + if let Some(exchange) = &self.airgap_exchange { + exchange.exchange.subscription() + } else { + self.hws.refresh().map(Message::HardwareWallets) + } } fn update( @@ -501,6 +514,93 @@ impl Modal for SignModal { tx: &mut SpendTx, ) -> Task { match message { + Message::View(view::Message::Spend(view::SpendTxMessage::SignWithAirgappedSigner( + fingerprint, + ))) => { + let Some(signer) = self + .airgapped_signers + .iter() + .find(|signer| signer.fingerprint == fingerprint) + else { + self.error = Some(Error::Unexpected( + "Air-gapped signer is not configured".to_owned(), + )); + return Task::none(); + }; + if !signer + .registration + .is_current(&self.wallet.descriptor_checksum) + { + self.error = Some(Error::Unexpected( + "Register this wallet policy on the signer first".to_owned(), + )); + return Task::none(); + } + if !self + .wallet + .main_descriptor + .contains_fingerprint_in_path(fingerprint, self.recovery_timelock) + { + self.error = Some(Error::Unexpected( + "The signer is not part of the selected spending path".to_owned(), + )); + return Task::none(); + } + let original = tx.psbt.clone(); + let txid = original.unsigned_tx.compute_txid(); + self.error = None; + self.airgap_exchange = Some(AirgappedSignExchange { + exchange: AirgapModal::new( + "Sign transaction with air-gapped signer", + AirgappedRequest::SignPsbt(original.clone()), + format!("liana-{txid}.psbt"), + ), + fingerprint, + original, + }); + return Task::none(); + } + Message::View(view::Message::Airgap(action)) => { + let Some(exchange) = &mut self.airgap_exchange else { + return Task::none(); + }; + let command = exchange.exchange.update(action); + let Some(outcome) = exchange.exchange.take_outcome() else { + return command; + }; + let fingerprint = exchange.fingerprint; + let result = match outcome { + AirgapOutcome::Response(AirgappedResponse::SignedPsbt(returned)) => { + validate_and_merge_psbt(&exchange.original, &returned) + .map_err(|error| Error::Unexpected(error.to_string())) + .and_then(|merged| { + if response_added_signature_for( + &exchange.original, + &merged, + fingerprint, + ) { + Ok(merged) + } else { + Err(Error::Unexpected(format!( + "Signer {fingerprint} did not add a signature" + ))) + } + }) + } + AirgapOutcome::Cancelled => { + self.airgap_exchange = None; + return Task::none(); + } + _ => Err(Error::Unexpected( + "Signer returned the wrong signing response".to_owned(), + )), + }; + self.airgap_exchange = None; + if result.is_ok() { + self.display_modal = false; + } + return Task::done(Message::Signed(fingerprint, result)); + } Message::View(view::Message::SelectHardwareWallet(i)) => { if let Some(HardwareWallet::Supported { fingerprint, @@ -589,6 +689,9 @@ impl Modal for SignModal { view::psbt::sign_action_toasts(self.error.as_ref(), &self.hws.list, &self.signing), ) .into(); + if let Some(exchange) = &self.airgap_exchange { + return modal::Modal::new(content, exchange.exchange.view()).into(); + } if self.display_modal { modal::Modal::new( content, @@ -603,6 +706,8 @@ impl Modal for SignModal { .and_then(|signer| self.wallet.keys_aliases.get(&signer.fingerprint)), &self.signed, &self.signing, + &self.airgapped_signers, + &self.wallet.descriptor_checksum, self.recovery_timelock, ), ) @@ -614,6 +719,39 @@ impl Modal for SignModal { } } +struct AirgappedSignExchange { + exchange: AirgapModal, + fingerprint: Fingerprint, + original: Psbt, +} + +fn response_added_signature_for(original: &Psbt, merged: &Psbt, fingerprint: Fingerprint) -> bool { + original + .inputs + .iter() + .zip(&merged.inputs) + .any(|(before, after)| { + after.partial_sigs.keys().any(|public_key| { + !before.partial_sigs.contains_key(public_key) + && before + .bip32_derivation + .get(&public_key.inner) + .is_some_and(|source| source.0 == fingerprint) + }) || after.tap_script_sigs.keys().any(|key @ (public_key, _)| { + !before.tap_script_sigs.contains_key(key) + && before + .tap_key_origins + .get(public_key) + .is_some_and(|(_, source)| source.0 == fingerprint) + }) || (before.tap_key_sig.is_none() + && after.tap_key_sig.is_some() + && before + .tap_internal_key + .and_then(|key| before.tap_key_origins.get(&key)) + .is_some_and(|(_, source)| source.0 == fingerprint)) + }) +} + fn merge_signatures(psbt: &mut Psbt, signed_psbt: &Psbt) { for i in 0..signed_psbt.inputs.len() { let psbtin = match psbt.inputs.get_mut(i) { @@ -707,10 +845,26 @@ mod tests { use liana::descriptors::LianaDescriptor; use serde_json::json; - use std::str::FromStr; + use std::{path::PathBuf, str::FromStr}; const DESC: &str = "wsh(or_d(multi(2,[f714c228/48'/1'/0'/2']tpubDEwJnTwfKoMvu8AXXBPydBVWDpzNP5tatjjZ56q4TQioGL7iL9xzTbMoCCQ3tfGihtff7vtR4xsjcRuhZ7HWARVAkGZ1HZcpBhVdou76k7j/<0;1>/*,[2522f23c/48'/1'/0'/2']tpubDEoTU4bDW1EXN1rnLXnRfue1a7DeqjJcs39PkEeLcVXhVKzCnFo9yQX2EeeXJ6kh4hgbz5o9v7YAc1EE97AEJpJbKNmDxE3ZQo4msGPSp2J/<0;1>/*),and_v(v:thresh(1,pkh([f714c228/48'/1'/0'/2']tpubDEwJnTwfKoMvu8AXXBPydBVWDpzNP5tatjjZ56q4TQioGL7iL9xzTbMoCCQ3tfGihtff7vtR4xsjcRuhZ7HWARVAkGZ1HZcpBhVdou76k7j/<2;3>/*),a:pkh([2522f23c/48'/1'/0'/2']tpubDEoTU4bDW1EXN1rnLXnRfue1a7DeqjJcs39PkEeLcVXhVKzCnFo9yQX2EeeXJ6kh4hgbz5o9v7YAc1EE97AEJpJbKNmDxE3ZQo4msGPSp2J/<2;3>/*)),older(65535))))#9s8ekrce"; + #[test] + fn legacy_qr_signers_are_available_in_sign_modal() { + let wallet = Arc::new(Wallet::new(LianaDescriptor::from_str(DESC).unwrap())); + + let modal = SignModal::new( + HashSet::new(), + wallet, + LianaDirectory::new(PathBuf::new()), + Network::Testnet4, + true, + None, + ); + + assert_eq!(modal.airgapped_signers.len(), 2); + } + #[tokio::test] async fn test_update_psbt() { let daemon = Daemon::new(vec![ diff --git a/liana-gui/src/app/state/receive.rs b/liana-gui/src/app/state/receive.rs index 75f7502ac5..43c4ea5287 100644 --- a/liana-gui/src/app/state/receive.rs +++ b/liana-gui/src/app/state/receive.rs @@ -11,12 +11,20 @@ use liana_ui::{component::form, widget::modal, widget::*}; use crate::daemon::model::LabelsLoader; use crate::dir::LianaDirectory; use crate::{ + airgap::{ + AddressVerificationRequest, AirgappedRequest, AirgappedResponse, AirgappedSignerConfig, + PolicyRegistration, + }, app::{ cache::Cache, error::Error, menu::Menu, message::Message, - state::{label::LabelsEdited, State}, + state::{ + airgap::{AirgapModal, AirgapOutcome}, + label::LabelsEdited, + State, + }, view, wallet::Wallet, }, @@ -472,6 +480,11 @@ pub struct VerifyAddressModal { hws: HardwareWallets, address: Address, derivation_index: ChildNumber, + wallet: Arc, + airgapped_signers: Vec, + network: Network, + verified_airgapped_signers: HashSet, + airgap_exchange: Option, /// Whether the "Other options" (specter DIY QR code) section is open. qr_section_open: bool, } @@ -484,12 +497,18 @@ impl VerifyAddressModal { address: Address, derivation_index: ChildNumber, ) -> Self { + let airgapped_signers = wallet.airgapped_signer_candidates(network); Self { warning: None, chosen_hws: HashSet::new(), - hws: HardwareWallets::new(data_dir, network).with_wallet(wallet), + hws: HardwareWallets::new(data_dir, network).with_wallet(wallet.clone()), + wallet: wallet.clone(), + airgapped_signers, + network, address, derivation_index, + verified_airgapped_signers: HashSet::new(), + airgap_exchange: None, qr_section_open: false, } } @@ -497,10 +516,18 @@ impl VerifyAddressModal { impl VerifyAddressModal { fn view(&self) -> Element<'_, view::Message> { + if let Some(exchange) = &self.airgap_exchange { + return exchange.exchange.view(); + } view::receive::verify_address_modal( self.warning.as_ref(), &self.hws.list, &self.chosen_hws, + view::receive::AirgappedVerification { + signers: &self.airgapped_signers, + verified: &self.verified_airgapped_signers, + descriptor_checksum: &self.wallet.descriptor_checksum, + }, &self.address, self.derivation_index, self.qr_section_open, @@ -508,7 +535,11 @@ impl VerifyAddressModal { } fn subscription(&self) -> Subscription { - self.hws.refresh().map(Message::HardwareWallets) + if let Some(exchange) = &self.airgap_exchange { + exchange.exchange.subscription() + } else { + self.hws.refresh().map(Message::HardwareWallets) + } } fn update( @@ -518,6 +549,54 @@ impl VerifyAddressModal { message: Message, ) -> Task { match message { + Message::View(view::Message::VerifyAirgappedSigner(fingerprint)) => { + match AirgappedAddressExchange::new( + self.wallet.clone(), + self.network, + fingerprint, + self.address.clone(), + self.derivation_index, + ) { + Ok(exchange) => { + self.warning = None; + self.airgap_exchange = Some(exchange); + } + Err(error) => self.warning = Some(Error::Unexpected(error)), + } + Task::none() + } + Message::View(view::Message::Airgap(action)) => { + let Some(exchange) = &mut self.airgap_exchange else { + return Task::none(); + }; + let command = exchange.exchange.update(action); + let Some(outcome) = exchange.exchange.take_outcome() else { + return command; + }; + match outcome { + AirgapOutcome::Response(AirgappedResponse::VerifiedAddress(response)) => { + match response.validate_for( + &exchange.request, + &exchange.address, + &exchange.fingerprint.to_string(), + ) { + Ok(()) => { + self.verified_airgapped_signers.insert(exchange.fingerprint); + self.warning = None; + } + Err(error) => self.warning = Some(Error::Unexpected(error.to_string())), + } + } + AirgapOutcome::Cancelled => {} + _ => { + self.warning = Some(Error::Unexpected( + "Signer returned the wrong address response".to_owned(), + )); + } + } + self.airgap_exchange = None; + Task::none() + } Message::HardwareWallets(msg) => match self.hws.update(msg) { Ok(cmd) => cmd.map(Message::HardwareWallets), Err(e) => { @@ -555,6 +634,52 @@ impl VerifyAddressModal { } } +struct AirgappedAddressExchange { + exchange: AirgapModal, + request: AddressVerificationRequest, + address: String, + fingerprint: Fingerprint, +} + +impl AirgappedAddressExchange { + fn new( + wallet: Arc, + network: Network, + fingerprint: Fingerprint, + address: Address, + index: ChildNumber, + ) -> Result { + let signer = wallet + .airgapped_signer_candidates(network) + .into_iter() + .find(|signer| signer.fingerprint == fingerprint) + .ok_or_else(|| "Air-gapped signer is not configured for this wallet".to_owned())?; + if !signer.registration.is_current(&wallet.descriptor_checksum) { + return Err("Register this wallet policy on the signer first".to_owned()); + } + let registration = PolicyRegistration::from_descriptor( + wallet.name.clone(), + network, + &wallet.main_descriptor, + ) + .map_err(|error| error.to_string())?; + let index: u32 = index.into(); + let request = AddressVerificationRequest::new(®istration, 0, index) + .map_err(|error| error.to_string())?; + let filename = format!("liana-address-{index}.json"); + Ok(Self { + exchange: AirgapModal::new( + "Verify receive address with air-gapped signer", + AirgappedRequest::VerifyAddress(request.clone()), + filename, + ), + request, + address: address.to_string(), + fingerprint, + }) + } +} + pub struct ShowQrCodeModal { qr_code: qr_code::Data, address: String, @@ -771,6 +896,28 @@ mod tests { const DESC: &str = "wsh(or_d(multi(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[de6eb005/48'/1'/0'/2']tpubDFGuYfS2JwiUSEXiQuNGdT3R7WTDhbaE6jbUhgYSSdhmfQcSx7ZntMPPv7nrkvAqjpj3jX9wbhSGMeKVao4qAzhbNyBi7iQmv5xxQk6H6jz/<0;1>/*),and_v(v:pkh([ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*),older(3))))#p9ax3xxp"; + #[test] + fn legacy_qr_signers_are_available_for_address_verification() { + let wallet = Arc::new(Wallet::new(LianaDescriptor::from_str(DESC).unwrap())); + let secp = secp256k1::Secp256k1::verification_only(); + let index = ChildNumber::from_normal_idx(0).unwrap(); + let address = wallet + .main_descriptor + .receive_descriptor() + .derive(index, &secp) + .address(Network::Testnet4); + + let modal = VerifyAddressModal::new( + LianaDirectory::new(PathBuf::new()), + wallet, + Network::Testnet4, + address, + index, + ); + + assert_eq!(modal.airgapped_signers.len(), 2); + } + #[tokio::test] async fn test_receive_panel() { let wallet = Arc::new(Wallet::new(LianaDescriptor::from_str(DESC).unwrap())); diff --git a/liana-gui/src/app/state/settings/wallet.rs b/liana-gui/src/app/state/settings/wallet.rs index 1df8d0717c..d6bea18c1a 100644 --- a/liana-gui/src/app/state/settings/wallet.rs +++ b/liana-gui/src/app/state/settings/wallet.rs @@ -15,12 +15,17 @@ use liana_ui::{ }; use crate::{ + airgap::{AirgappedRequest, AirgappedSignerConfig, PolicyRegistration, RegistrationState}, app::{ cache::Cache, error::Error, message::Message, settings::{self, update_settings_file, LianaSettings}, - state::{export::ExportModal, State}, + state::{ + airgap::{AirgapModal, AirgapOutcome}, + export::ExportModal, + State, + }, view, wallet::Wallet, Config, @@ -36,6 +41,7 @@ use crate::{ enum Modal { None, RegisterWallet(RegisterWalletModal), + RegisterAirgappedSigner(AirgappedRegistrationModal), ImportExport(ExportModal), } @@ -124,6 +130,9 @@ impl State for WalletSettingsState { Modal::RegisterWallet(m) => modal::Modal::new(content, m.view()) .on_blur(Some(view::Message::Close)) .into(), + Modal::RegisterAirgappedSigner(m) => { + modal::Modal::new(content, m.exchange.view()).into() + } Modal::ImportExport(m) => m.view(content), } } @@ -132,6 +141,7 @@ impl State for WalletSettingsState { match &self.modal { Modal::None => Subscription::none(), Modal::RegisterWallet(modal) => modal.subscription(), + Modal::RegisterAirgappedSigner(modal) => modal.exchange.subscription(), Modal::ImportExport(modal) => { if let Some(sub) = modal.subscription() { sub.map(|m| { @@ -234,6 +244,60 @@ impl State for WalletSettingsState { )); Task::none() } + Message::View(view::Message::Settings( + view::SettingsMessage::RegisterAirgappedSigner(fingerprint), + )) => { + match AirgappedRegistrationModal::new( + self.wallet.clone(), + cache.network, + fingerprint, + ) { + Ok(modal) => self.modal = Modal::RegisterAirgappedSigner(modal), + Err(error) => self.warning = Some(Error::Unexpected(error)), + } + Task::none() + } + Message::View(view::Message::Airgap(action)) => { + let Modal::RegisterAirgappedSigner(modal) = &mut self.modal else { + return Task::none(); + }; + let command = modal.exchange.update(action); + let Some(outcome) = modal.exchange.take_outcome() else { + return command; + }; + let registration = modal.registration.clone(); + let signer = modal.signer.clone(); + let state = match outcome { + AirgapOutcome::Exported => RegistrationState::Exported { + descriptor_checksum: registration + .descriptor_checksum() + .expect("validated registration has a checksum"), + }, + AirgapOutcome::Cancelled => { + self.modal = Modal::None; + return Task::none(); + } + _ => { + self.warning = Some(Error::Unexpected( + "Signer returned the wrong response".to_owned(), + )); + self.modal = Modal::None; + return Task::none(); + } + }; + self.processing = true; + self.modal = Modal::None; + Task::perform( + update_airgapped_registration( + self.data_dir.clone(), + cache.network, + self.wallet.clone(), + signer, + state, + ), + Message::WalletUpdated, + ) + } Message::View(view::Message::ImportExport(ImportExportMessage::UpdateAliases( aliases, @@ -289,6 +353,7 @@ impl State for WalletSettingsState { } _ => match &mut self.modal { Modal::RegisterWallet(m) => m.update(daemon, cache, message), + Modal::RegisterAirgappedSigner(_) => Task::none(), _ => Task::none(), }, } @@ -309,6 +374,87 @@ impl State for WalletSettingsState { } } +struct AirgappedRegistrationModal { + exchange: AirgapModal, + registration: PolicyRegistration, + signer: AirgappedSignerConfig, +} + +impl AirgappedRegistrationModal { + fn new( + wallet: Arc, + network: Network, + fingerprint: Fingerprint, + ) -> Result { + let signer = wallet + .airgapped_signer_candidates(network) + .into_iter() + .find(|signer| signer.fingerprint == fingerprint) + .ok_or_else(|| "Air-gapped signer is not configured for this wallet".to_owned())?; + let registration = PolicyRegistration::from_descriptor( + wallet.name.clone(), + network, + &wallet.main_descriptor, + ) + .map_err(|error| error.to_string())?; + let filename = format!("liana-{}-policy.json", wallet.descriptor_checksum); + Ok(Self { + exchange: AirgapModal::new( + "Register wallet policy on air-gapped signer", + AirgappedRequest::RegisterPolicy(registration.clone()), + filename, + ), + registration, + signer, + }) + } +} + +async fn update_airgapped_registration( + data_dir: LianaDirectory, + network: Network, + wallet: Arc, + signer: AirgappedSignerConfig, + registration: RegistrationState, +) -> Result, Error> { + let mut wallet = wallet.as_ref().clone(); + apply_airgapped_registration(&mut wallet, signer, registration); + let signers: Vec = wallet.airgapped_signers.clone(); + let wallet_id = wallet.id(); + let network_dir = data_dir.network_directory(network); + update_settings_file(&network_dir, |mut settings: LianaSettings| { + if let Some(wallet_setting) = settings + .wallets + .iter_mut() + .find(|candidate| candidate.wallet_id() == wallet_id) + { + wallet_setting.airgapped_signers = signers.clone(); + } + settings + }) + .await?; + Ok(Arc::new(wallet)) +} + +fn apply_airgapped_registration( + wallet: &mut Wallet, + mut signer: AirgappedSignerConfig, + registration: RegistrationState, +) { + if let Some(existing) = wallet + .airgapped_signers + .iter_mut() + .find(|existing| existing.fingerprint == signer.fingerprint) + { + existing.registration = registration; + } else { + // Legacy wallets are migrated only after the user confirms that the + // policy was registered, keeping cancellation side-effect free. + signer.registration = registration; + wallet.airgapped_signers.push(signer); + } +} + impl From for Box { fn from(s: WalletSettingsState) -> Box { Box::new(s) @@ -321,6 +467,7 @@ pub struct RegisterWalletModal { warning: Option, chosen_hw: Option, hws: HardwareWallets, + airgapped_signers: Vec, registered: HashSet, processing: bool, } @@ -331,11 +478,13 @@ impl RegisterWalletModal { for hw in &wallet.hardware_wallets { registered.insert(hw.fingerprint); } + let airgapped_signers = wallet.airgapped_signer_candidates(network); Self { data_dir: data_dir.clone(), warning: None, chosen_hw: None, hws: HardwareWallets::new(data_dir, network).with_wallet(wallet.clone()), + airgapped_signers, wallet, processing: false, registered, @@ -348,6 +497,8 @@ impl RegisterWalletModal { view::settings::register_wallet_modal( self.warning.as_ref(), &self.hws.list, + &self.airgapped_signers, + &self.wallet.main_descriptor, self.processing, self.chosen_hw, &self.registered, @@ -386,6 +537,7 @@ impl RegisterWalletModal { for hw in &wallet.hardware_wallets { self.registered.insert(hw.fingerprint); } + self.airgapped_signers = wallet.airgapped_signer_candidates(cache.network); self.wallet = wallet; } Err(e) => { @@ -550,3 +702,73 @@ pub async fn update_aliases( Ok(Arc::new(wallet)) } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + const LEGACY_DESCRIPTOR: &str = "wsh(or_d(multi(2,[f714c228/48'/1'/0'/2']tpubDEwJnTwfKoMvu8AXXBPydBVWDpzNP5tatjjZ56q4TQioGL7iL9xzTbMoCCQ3tfGihtff7vtR4xsjcRuhZ7HWARVAkGZ1HZcpBhVdou76k7j/<0;1>/*,[2522f23c/48'/1'/0'/2']tpubDEoTU4bDW1EXN1rnLXnRfue1a7DeqjJcs39PkEeLcVXhVKzCnFo9yQX2EeeXJ6kh4hgbz5o9v7YAc1EE97AEJpJbKNmDxE3ZQo4msGPSp2J/<0;1>/*),and_v(v:thresh(1,pkh([f714c228/48'/1'/0'/2']tpubDEwJnTwfKoMvu8AXXBPydBVWDpzNP5tatjjZ56q4TQioGL7iL9xzTbMoCCQ3tfGihtff7vtR4xsjcRuhZ7HWARVAkGZ1HZcpBhVdou76k7j/<2;3>/*),a:pkh([2522f23c/48'/1'/0'/2']tpubDEoTU4bDW1EXN1rnLXnRfue1a7DeqjJcs39PkEeLcVXhVKzCnFo9yQX2EeeXJ6kh4hgbz5o9v7YAc1EE97AEJpJbKNmDxE3ZQo4msGPSp2J/<2;3>/*)),older(65535))))#9s8ekrce"; + + fn legacy_wallet() -> Wallet { + Wallet::new(LianaDescriptor::from_str(LEGACY_DESCRIPTOR).unwrap()) + } + + #[test] + fn legacy_bip48_keys_are_offered_as_qr_signer_candidates() { + let mut wallet = legacy_wallet(); + let fingerprint = Fingerprint::from_str("f714c228").unwrap(); + wallet + .keys_aliases + .insert(fingerprint, "Legacy QR signer".to_owned()); + + let signers = wallet.airgapped_signer_candidates(Network::Testnet4); + + assert_eq!(signers.len(), 2); + assert_eq!( + signers + .iter() + .find(|signer| signer.fingerprint == fingerprint) + .and_then(|signer| signer.alias.as_deref()), + Some("Legacy QR signer") + ); + } + + #[test] + fn known_usb_keys_are_not_migrated_to_qr_signers() { + let mut wallet = legacy_wallet(); + let fingerprint = Fingerprint::from_str("f714c228").unwrap(); + wallet.hardware_wallets.push(HardwareWalletConfig { + kind: "ledger".to_owned(), + fingerprint, + token: String::new(), + }); + + let signers = wallet.airgapped_signer_candidates(Network::Testnet4); + + assert_eq!(signers.len(), 1); + assert_ne!(signers[0].fingerprint, fingerprint); + } + + #[test] + fn confirmed_legacy_candidate_is_added_without_duplicates() { + let mut wallet = legacy_wallet(); + let signer = wallet + .airgapped_signer_candidates(Network::Testnet4) + .into_iter() + .next() + .unwrap(); + let fingerprint = signer.fingerprint; + let registration = RegistrationState::Exported { + descriptor_checksum: wallet.descriptor_checksum.clone(), + }; + + apply_airgapped_registration(&mut wallet, signer.clone(), registration.clone()); + apply_airgapped_registration(&mut wallet, signer, registration.clone()); + + assert_eq!(wallet.airgapped_signers.len(), 1); + assert_eq!(wallet.airgapped_signers[0].fingerprint, fingerprint); + assert_eq!(wallet.airgapped_signers[0].registration, registration); + } +} diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index 1117b4c7d5..537a35a7c0 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -1,5 +1,6 @@ use liana_ui::component::panels::spend::FeeLevel; +use crate::app::state::airgap::AirgapAction; use crate::{ app::menu::Menu, app::view::FiatAmountConverter, @@ -36,6 +37,7 @@ pub enum Message { Next, Previous, SelectHardwareWallet(usize), + VerifyAirgappedSigner(Fingerprint), CreateRbf(CreateRbfMessage), ShowAddressQrCode(AddressQrSource), ShowQrOptSection(bool), @@ -43,6 +45,7 @@ pub enum Message { HideRescanWarning, ExportPsbt, ImportPsbt, + Airgap(AirgapAction), OpenUrl(String), } @@ -110,6 +113,7 @@ pub enum SpendTxMessage { Confirm, Cancel, SelectHotSigner, + SignWithAirgappedSigner(Fingerprint), EditPsbt, PsbtEdited(String), Next, @@ -135,6 +139,7 @@ pub enum SettingsMessage { ImportWallet, AboutSection, RegisterWallet, + RegisterAirgappedSigner(Fingerprint), FingerprintAliasEdited(Fingerprint, String), WalletAliasEdited(String), Save, diff --git a/liana-gui/src/app/view/psbt.rs b/liana-gui/src/app/view/psbt.rs index 51617c7f71..4ba7d09032 100644 --- a/liana-gui/src/app/view/psbt.rs +++ b/liana-gui/src/app/view/psbt.rs @@ -32,6 +32,7 @@ use liana_ui::{ }; use crate::{ + airgap::AirgappedSignerConfig, app::{ cache::Cache, error::Error, @@ -906,6 +907,8 @@ pub fn sign_action<'a>( signer_alias: Option<&'a String>, signed: &HashSet, signing: &HashSet, + airgapped_signers: &'a [AirgappedSignerConfig], + descriptor_checksum: &str, recovery_timelock: Option, ) -> Element<'a, Message> { let title = "Select signing device to sign with:".to_string(); @@ -950,6 +953,27 @@ pub fn sign_action<'a>( signers.push(hot_signer); } + for signer in airgapped_signers { + let fingerprint = signer.fingerprint; + let can_sign = descriptor.contains_fingerprint_in_path(fingerprint, recovery_timelock); + let registered = signer.registration.is_current(descriptor_checksum); + let alias = signer.alias.as_deref().unwrap_or("Air-gapped signer"); + let label = if !registered { + format!("{alias} ({fingerprint}) — register policy first") + } else { + format!("{alias} ({fingerprint})") + }; + let action = (registered && can_sign && !signed.contains(&fingerprint)).then_some( + Message::Spend(SpendTxMessage::SignWithAirgappedSigner(fingerprint)), + ); + signers.push( + button::secondary(None, label) + .width(Length::Fill) + .on_press_maybe(action) + .into(), + ); + } + let modal_content = Column::from_vec(signers) .align_x(Alignment::Center) .spacing(10) diff --git a/liana-gui/src/app/view/receive/mod.rs b/liana-gui/src/app/view/receive/mod.rs index bda5f68ede..1eb9bee1aa 100644 --- a/liana-gui/src/app/view/receive/mod.rs +++ b/liana-gui/src/app/view/receive/mod.rs @@ -1,7 +1,7 @@ mod modals; pub use modals::{ edit_label_modal, new_address_label_modal, new_address_processing_modal, - new_address_show_modal, qr_modal, verify_address_modal, + new_address_show_modal, qr_modal, verify_address_modal, AirgappedVerification, }; use std::collections::HashMap; diff --git a/liana-gui/src/app/view/receive/modals.rs b/liana-gui/src/app/view/receive/modals.rs index 42c285f92f..7bd412e271 100644 --- a/liana-gui/src/app/view/receive/modals.rs +++ b/liana-gui/src/app/view/receive/modals.rs @@ -22,6 +22,7 @@ use liana_ui::{ }; use crate::{ + airgap::AirgappedSignerConfig, app::{ error::Error, view::{hw, warning::warn}, @@ -31,10 +32,17 @@ use crate::{ use crate::app::view::message::{AddressQrSource, LabelMessage, Message, NewAddressMessage}; +pub struct AirgappedVerification<'a> { + pub signers: &'a [AirgappedSignerConfig], + pub verified: &'a HashSet, + pub descriptor_checksum: &'a str, +} + pub fn verify_address_modal<'a>( warning: Option<&Error>, hws: &'a [HardwareWallet], chosen_hws: &HashSet, + airgapped: AirgappedVerification<'a>, address: &Address, derivation_index: ChildNumber, qr_section_open: bool, @@ -59,6 +67,28 @@ pub fn verify_address_modal<'a>( )); } } + for signer in airgapped.signers { + let fingerprint = signer.fingerprint; + let registered = signer + .registration + .is_current(airgapped.descriptor_checksum); + let verified = airgapped.verified.contains(&fingerprint); + let alias = signer.alias.as_deref().unwrap_or("Air-gapped signer"); + let label = if verified { + format!("{alias} ({fingerprint}) — verified") + } else if registered { + format!("Verify on {alias} ({fingerprint})") + } else { + format!("{alias} ({fingerprint}) — register policy first") + }; + let action = + (registered && !verified).then_some(Message::VerifyAirgappedSigner(fingerprint)); + devices = devices.push( + liana_ui::component::button::secondary(None, label) + .width(Length::Fill) + .on_press_maybe(action), + ); + } devices = devices.push(optional_section( qr_section_open, "Other options".to_string(), diff --git a/liana-gui/src/app/view/settings/mod.rs b/liana-gui/src/app/view/settings/mod.rs index 1fec519c5e..173c2cd12f 100644 --- a/liana-gui/src/app/view/settings/mod.rs +++ b/liana-gui/src/app/view/settings/mod.rs @@ -37,6 +37,7 @@ use lianad::config::BitcoindRpcAuth; use super::{dashboard, message::*}; use crate::{ + airgap::AirgappedSignerConfig, app::{cache::Cache, error::Error, menu::Menu, settings::ProviderKey, view::warning::warn}, help, hw::HardwareWallet, @@ -928,6 +929,11 @@ pub fn wallet_settings<'a>( // ------------------------- Descriptor card ------------------------- let title = text("Wallet descriptor:").bold(); + let policy_checksum = descriptor + .to_string() + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .unwrap_or_default(); let descriptor_s = scrollable::horizontal_thin(Column::new().push(text(descriptor.to_string()).small())) .width(Length::Fill); @@ -947,7 +953,19 @@ pub fn wallet_settings<'a>( .width(Length::Fill) .wrap(); let descriptor_card = card::simple( - column![title, descriptor_row, btn_row] + column![ + title, + descriptor_row, + row![ + text("Policy checksum:").bold(), + text(policy_checksum.clone()), + button::btn_copy(Some(Message::Clipboard(policy_checksum))) + ] + .spacing(10) + .align_y(Alignment::Center), + text("Compare this exact checksum with your signer; matching wallet names are not sufficient.").small(), + btn_row + ] .spacing(10) .width(Length::Fill), ) @@ -1222,11 +1240,18 @@ fn expire_message_units(sequence: u32) -> Vec { pub fn register_wallet_modal<'a>( warning: Option<&Error>, hws: &'a [HardwareWallet], + airgapped_signers: &'a [AirgappedSignerConfig], + descriptor: &LianaDescriptor, processing: bool, chosen_hw: Option, registered: &HashSet, ) -> Element<'a, Message> { - let signers = hws + let current_checksum = descriptor + .to_string() + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .unwrap_or_default(); + let mut signers = hws .iter() .enumerate() .fold(Column::new().spacing(10), |col, (i, hw)| { @@ -1250,6 +1275,27 @@ pub fn register_wallet_modal<'a>( move || Message::SelectHardwareWallet(i), )) }); + for signer in airgapped_signers { + let status = if signer.registration.is_current(¤t_checksum) { + component::list::DeviceStatus::Registered + } else { + component::list::DeviceStatus::None + }; + let title = signer + .alias + .clone() + .unwrap_or_else(|| "Air-gapped signer".to_owned()); + let fingerprint = signer.fingerprint; + signers = signers.push(component::list::entry_device_list( + title, + Some(format!("QR signer #{fingerprint}")), + status, + button::EntryWidth::Fill, + (!processing).then_some(Message::Settings(SettingsMessage::RegisterAirgappedSigner( + fingerprint, + ))), + )); + } let card_content = Column::new() .push( diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index ee714a3047..49a24f1408 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -4,7 +4,12 @@ use std::sync::Arc; use crate::app::cache::FiatPrice; use crate::dir::LianaDirectory; use crate::{ - app::settings, daemon::DaemonBackend, hw::HardwareWalletConfig, node::NodeType, signer::Signer, + airgap::{AirgappedSignerConfig, PassportAccount}, + app::settings, + daemon::DaemonBackend, + hw::HardwareWalletConfig, + node::NodeType, + signer::Signer, }; use liana::{miniscript::bitcoin, signer::HotSigner}; @@ -41,6 +46,7 @@ pub struct Wallet { pub keys_aliases: HashMap, pub provider_keys: HashMap, pub hardware_wallets: Vec, + pub airgapped_signers: Vec, pub signer: Option>, pub fiat_price_setting: Option, pub remote_backend_auth: Option, @@ -62,6 +68,7 @@ impl Wallet { keys_aliases: HashMap::new(), provider_keys: HashMap::new(), hardware_wallets: Vec::new(), + airgapped_signers: Vec::new(), signer: None, fiat_price_setting: None, remote_backend_auth: None, @@ -106,6 +113,17 @@ impl Wallet { self } + pub fn with_airgapped_signers(mut self, airgapped_signers: Vec) -> Self { + self.airgapped_signers = airgapped_signers + .into_iter() + .map(|mut signer| { + signer.invalidate_registration(&self.descriptor_checksum); + signer + }) + .collect(); + self + } + pub fn with_signer(mut self, signer: Signer) -> Self { self.signer = Some(Arc::new(signer)); self @@ -138,6 +156,44 @@ impl Wallet { descriptor_keys } + /// Return persisted QR signers plus safe migration candidates for wallets + /// made before signer transport metadata was stored. Candidate records are + /// derived only from public BIP48 keys already committed to the descriptor + /// and are not persisted until the user confirms policy registration. + pub fn airgapped_signer_candidates( + &self, + network: bitcoin::Network, + ) -> Vec { + let mut signers = self.airgapped_signers.clone(); + let mut known: HashSet<_> = signers.iter().map(|signer| signer.fingerprint).collect(); + let hot_signer = self.signer.as_ref().map(|signer| signer.fingerprint()); + + for key in self.main_descriptor.spendable_keys() { + let Ok(account) = PassportAccount::from_descriptor_key(&key.to_string(), network) + else { + continue; + }; + let fingerprint = account.fingerprint; + if known.contains(&fingerprint) + || hot_signer == Some(fingerprint) + || self + .hardware_wallets + .iter() + .any(|device| device.fingerprint == fingerprint) + || self.provider_keys.contains_key(&fingerprint) + { + continue; + } + let alias = self.keys_aliases.get(&fingerprint).cloned(); + if let Ok(signer) = AirgappedSignerConfig::qr(account, alias) { + known.insert(fingerprint); + signers.push(signer); + } + } + signers.sort_by_key(|signer| signer.fingerprint); + signers + } + pub fn load_from_settings(self, wallet_settings: WalletSettings) -> Result { if wallet_settings.descriptor_checksum != self.descriptor_checksum { Err(WalletError::WrongWalletLoaded) @@ -149,6 +205,7 @@ impl Wallet { .with_name(wallet_settings.name) .with_pinned_at(wallet_settings.pinned_at) .with_hardware_wallets(wallet_settings.hardware_wallets) + .with_airgapped_signers(wallet_settings.airgapped_signers) .with_fiat_price_setting(wallet_settings.fiat_price)) } } diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 409f84c54e..b3afd7e104 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -730,10 +730,15 @@ pub async fn import_xpub( path: PathBuf, network: Network, ) -> Result<(), Error> { - let mut file = File::open(path)?; + const MAX_XPUB_FILE_BYTES: u64 = 16 * 1024; + let file = File::open(path)?; let mut xpub_str = String::new(); - file.read_to_string(&mut xpub_str)?; + file.take(MAX_XPUB_FILE_BYTES + 1) + .read_to_string(&mut xpub_str)?; + if xpub_str.len() as u64 > MAX_XPUB_FILE_BYTES { + return Err(Error::ParseXpub); + } let xpub_str = xpub_str.trim().to_string(); let (descriptor_pubkey, key) = diff --git a/liana-gui/src/gui/tab.rs b/liana-gui/src/gui/tab.rs index 91ac730223..be52f52a5c 100644 --- a/liana-gui/src/gui/tab.rs +++ b/liana-gui/src/gui/tab.rs @@ -642,6 +642,7 @@ pub fn create_app_with_remote_backend( .into_iter() .map(|pk| (pk.fingerprint, pk.into())) .collect(); + let airgapped_signers = wallet_settings.airgapped_signers.clone(); Ok(app::App::new( Cache { @@ -671,6 +672,7 @@ pub fn create_app_with_remote_backend( .with_key_aliases(aliases) .with_provider_keys(provider_keys) .with_hardware_wallets(hws) + .with_airgapped_signers(airgapped_signers) .with_remote_backend_auth( wallet_settings .remote_backend_auth diff --git a/liana-gui/src/installer/context.rs b/liana-gui/src/installer/context.rs index 829d29422a..b527a2c050 100644 --- a/liana-gui/src/installer/context.rs +++ b/liana-gui/src/installer/context.rs @@ -5,6 +5,7 @@ use std::{ }; use crate::{ + airgap::AirgappedSignerConfig, app::settings::KeySetting, backup::Backup, dir::LianaDirectory, @@ -73,6 +74,7 @@ pub struct Context { pub descriptor: Option, pub keys: HashMap, pub hws: Vec<(DeviceKind, bitcoin::bip32::Fingerprint, Option<[u8; 32]>)>, + pub airgapped_signers: HashMap, pub liana_directory: LianaDirectory, pub network: bitcoin::Network, pub hw_is_used: bool, @@ -101,6 +103,7 @@ impl Context { poll_interval_secs: Duration::from_secs(30), }, hws: Vec::new(), + airgapped_signers: HashMap::new(), keys: HashMap::new(), bitcoin_backend: None, descriptor: None, diff --git a/liana-gui/src/installer/descriptor.rs b/liana-gui/src/installer/descriptor.rs index b29d3ba274..05d16e2cd5 100644 --- a/liana-gui/src/installer/descriptor.rs +++ b/liana-gui/src/installer/descriptor.rs @@ -4,7 +4,9 @@ use liana::miniscript::{ descriptor::DescriptorPublicKey, }; -use crate::{app::settings::ProviderKey, hw::is_compatible_with_tapminiscript}; +use crate::{ + airgap::AirgappedSignerKind, app::settings::ProviderKey, hw::is_compatible_with_tapminiscript, +}; use liana_connect::keys::api::KeyKind; /// Whether to enable cosigner keys on all paths (excluding safety net paths). @@ -17,6 +19,8 @@ pub enum KeySource { Device(DeviceKind, Option), /// A hot signer on the user's computer. HotSigner, + /// A persisted asynchronous signer that communicates without USB. + Airgapped(AirgappedSignerKind), /// A manually inserted xpub. Manual, /// A token for a key with the given kind. @@ -60,6 +64,7 @@ impl KeySource { match self { Self::Device(_, _) => KeySourceKind::Device, Self::HotSigner => KeySourceKind::HotSigner, + Self::Airgapped(_) => KeySourceKind::Airgapped, Self::Manual => KeySourceKind::Manual, Self::Token(kind, _) => KeySourceKind::Token(*kind), } @@ -97,6 +102,8 @@ pub enum KeySourceKind { Device, /// A hot signer. HotSigner, + /// An asynchronous QR/file signer. + Airgapped, /// A manually inserted xpub. Manual, /// A token for a key with the given kind. diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 81fed21ece..53092e53d8 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -8,7 +8,7 @@ use liana::{ DescriptorPublicKey, }, }; -use std::collections::HashMap; +use std::{collections::HashMap, path::PathBuf}; use super::{ context, @@ -59,6 +59,17 @@ pub enum Message { HardwareWallets(HardwareWalletMessage), HardwareWalletUpdate, WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), + RegisterPassport(Fingerprint), + PassportQrTick, + PausePassportQr, + ResumePassportQr, + RestartPassportQr, + LessDensePassportQr, + MoreDensePassportQr, + ExportPassportRegistration, + PassportRegistrationFileExported(Result, String>), + PassportRegistrationExported(Fingerprint), + CancelPassportRegistration, MnemonicWord(usize, String), ImportMnemonic(bool), RedeemNextKey, diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 6575850c15..932a0cf98f 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -587,6 +587,8 @@ pub async fn install_local_wallet( }) .collect(); + let airgapped_signers = ctx.airgapped_signers.values().cloned().collect(); + let wallet_settings = WalletSettings { name: wallet_name(descriptor), alias: Some(ctx.wallet_alias.clone()), @@ -594,6 +596,7 @@ pub async fn install_local_wallet( descriptor_checksum: wallet_id.descriptor_checksum.clone(), keys: ctx.keys.values().cloned().collect(), hardware_wallets, + airgapped_signers, remote_backend_auth: None, start_internal_bitcoind: Some(ctx.internal_bitcoind.is_some()), fiat_price: None, @@ -811,6 +814,7 @@ pub async fn create_remote_wallet( pinned_at: wallet_id.timestamp, keys: Vec::new(), hardware_wallets: Vec::new(), + airgapped_signers: Vec::new(), remote_backend_auth: Some(AuthConfig::new( remote_backend.user_id().to_string(), remote_backend.user_email().to_string(), @@ -893,6 +897,7 @@ pub async fn import_remote_wallet( pinned_at: wallet_id.timestamp, keys: Vec::new(), hardware_wallets: Vec::new(), + airgapped_signers: Vec::new(), remote_backend_auth: Some(AuthConfig::new( backend.user_id().to_string(), backend.user_email().to_string(), diff --git a/liana-gui/src/installer/step/descriptor/editor/key.rs b/liana-gui/src/installer/step/descriptor/editor/key.rs index 54d24b675d..325172310e 100644 --- a/liana-gui/src/installer/step/descriptor/editor/key.rs +++ b/liana-gui/src/installer/step/descriptor/editor/key.rs @@ -8,7 +8,7 @@ use async_hwi::{DeviceKind, Version}; use iced::{ alignment::{Horizontal, Vertical}, clipboard, - widget::{column, container, row, Column, Row, Space}, + widget::{column, container, image, row, Column, Row, Space}, Alignment, Length, Subscription, Task, }; use liana::miniscript::{ @@ -33,6 +33,10 @@ use liana_ui::{ }; use crate::{ + airgap::{ + request_camera_access, AirgappedSignerKind, CameraDescriptor, CameraEvent, CameraScanner, + PassportAccount, ScanLimits, UrType, + }, app::{settings::ProviderKey, state::export::ExportModal}, export::{ImportExportMessage, ImportExportType}, hw::{is_compatible_with_tapminiscript, HardwareWallet, HardwareWallets, UnsupportedReason}, @@ -97,6 +101,8 @@ enum Focus { Device(Fingerprint), EnterXpub, LoadXpubFromFile, + ImportPassport, + ScanPassport, GenerateHotKey, EnterSafetyNetToken, EnterCosignerToken, @@ -109,6 +115,14 @@ pub enum SelectKeySourceMessage { FetchFromDevice(Fingerprint, ChildNumber), SelectKey(Fingerprint), SelectLoadXpub, + SelectPassport, + SelectPassportQr, + SelectPassportFile, + PassportCameras(Result, crate::airgap::CameraFailure>), + SelectPassportCamera(usize), + PollPassportCamera, + CancelPassportScan, + PassportAccount(String), SelectEnterXpub, PasteXpub, Xpub(String), @@ -187,6 +201,12 @@ pub struct SelectKeySource { error: Option, details_error: Option, import_xpub_error: Option, + passport_cameras: Vec, + passport_camera_index: Option, + passport_scanner: Option, + passport_preview: Option, + passport_scan_progress: f32, + passport_scan_error: Option, // fields form_alias: form::Value, @@ -223,6 +243,12 @@ impl SelectKeySource { error: None, details_error: None, import_xpub_error: None, + passport_cameras: Vec::new(), + passport_camera_index: None, + passport_scanner: None, + passport_preview: None, + passport_scan_progress: 0.0, + passport_scan_error: None, form_alias: Default::default(), form_xpub: Default::default(), form_safety_net_token: Default::default(), @@ -486,6 +512,167 @@ impl SelectKeySource { } Task::none() } + fn on_select_passport(&mut self) -> Task { + self.focus = Focus::ImportPassport; + self.import_xpub_error = None; + Task::none() + } + + fn on_select_passport_file(&mut self) -> Task { + self.focus = Focus::ImportPassport; + if self.modal.is_none() { + let modal = ExportModal::new(None, ImportExportType::ImportXpub(self.network)); + let launch = modal.launch(false); + self.modal = Some(modal); + return launch; + } + Task::none() + } + + fn on_select_passport_qr(&mut self) -> Task { + self.cancel_passport_scan(); + self.focus = Focus::ScanPassport; + self.processing = true; + self.passport_scan_error = None; + Task::perform(request_camera_access(), |result| { + Self::route(SelectKeySourceMessage::PassportCameras(result)) + }) + } + + fn on_passport_cameras( + &mut self, + result: Result, crate::airgap::CameraFailure>, + ) -> Task { + // The permission callback may arrive after the user cancelled. Do not + // open a camera unless this scanner is still the active installer view. + if self.focus != Focus::ScanPassport { + return Task::none(); + } + self.processing = false; + match result { + Ok(cameras) if !cameras.is_empty() => { + self.passport_cameras = cameras; + self.start_passport_camera(0); + } + Ok(_) => self.passport_scan_error = Some("No camera is available".to_owned()), + Err(error) => self.passport_scan_error = Some(error.to_string()), + } + Task::none() + } + + fn start_passport_camera(&mut self, index: usize) { + self.passport_scanner = None; + self.passport_preview = None; + self.passport_scan_progress = 0.0; + self.passport_scan_error = None; + let Some(camera) = self.passport_cameras.get(index) else { + self.passport_scan_error = Some("Selected camera is no longer available".to_owned()); + return; + }; + match CameraScanner::start( + camera.index.clone(), + UrType::CryptoAccount, + ScanLimits::default(), + ) { + Ok(scanner) => { + self.passport_camera_index = Some(index); + self.passport_scanner = Some(scanner); + } + Err(error) => self.passport_scan_error = Some(error.to_string()), + } + } + + fn poll_passport_camera(&mut self) -> Task { + let mut complete = None; + let mut failed = false; + if let Some(scanner) = self.passport_scanner.as_ref() { + while let Ok(event) = scanner.try_recv() { + match event { + CameraEvent::Preview { + width, + height, + rgba, + } => { + self.passport_preview = Some(image::Handle::from_rgba(width, height, rgba)) + } + CameraEvent::Progress { estimated, .. } => { + self.passport_scan_progress = estimated + } + CameraEvent::Rejected(error) => self.passport_scan_error = Some(error), + CameraEvent::Complete(payload) => complete = Some(payload), + CameraEvent::Failure(error) => { + self.passport_scan_error = Some(error.to_string()); + failed = true; + } + } + } + } + if failed { + self.passport_scanner = None; + } + if let Some(payload) = complete { + self.passport_scanner = None; + match PassportAccount::from_crypto_account_cbor(&payload.data, self.network) { + Ok(account) => return self.accept_passport_account(account), + Err(error) => self.passport_scan_error = Some(error.to_string()), + } + } + Task::none() + } + + fn cancel_passport_scan(&mut self) { + self.passport_scanner = None; + self.passport_preview = None; + self.passport_cameras.clear(); + self.passport_camera_index = None; + self.passport_scan_progress = 0.0; + self.processing = false; + } + + fn on_import_passport(&mut self, value: String) -> Task { + match PassportAccount::from_descriptor_key(&value, self.network) { + Ok(account) => self.accept_passport_account(account), + Err(error) => { + self.error = Some(error.to_string()); + Task::none() + } + } + } + + fn accept_passport_account(&mut self, account: PassportAccount) -> Task { + self.cancel_passport_scan(); + self.focus = Focus::ImportPassport; + let fingerprint = account.fingerprint; + if let Some((_, existing)) = self.keys.get(&fingerprint) { + if existing.key != account.account { + self.error = Some( + "A different key with this master fingerprint is already present".to_owned(), + ); + return Task::none(); + } + self.selected_key = SelectedKey::Existing(fingerprint); + return self.on_next(); + } + let account_number = match account.account_number() { + Ok(number) => number, + Err(error) => { + self.error = Some(error.to_string()); + return Task::none(); + } + }; + self.form_alias.value = "Air-gapped signer".to_owned(); + self.form_alias.valid = true; + self.form_account = Some(account_number); + self.selected_key = SelectedKey::New(Box::new(Key { + source: KeySource::Airgapped(AirgappedSignerKind::Qr), + name: self.form_alias.value.clone(), + fingerprint, + key: account.account, + account: Some(account_number), + })); + self.step = Step::Details; + Task::none() + } fn on_select_enter_xpub(&mut self) -> Task { self.focus = Focus::EnterXpub; Task::none() @@ -926,6 +1113,66 @@ impl SelectKeySource { let cont = Container::new(column).padding(15).style(theme::card::modal); cont.into() } + + fn passport_scanner_view(&self) -> Element<'_, Message> { + let header = modal::header( + Some("Scan air-gapped signer account"), + Some(Self::route(SelectKeySourceMessage::CancelPassportScan)), + Some(Message::Close), + ); + let mut content = Column::new().spacing(12).push(header).push(p1_regular( + "On your compatible signer, export the account key as an animated QR code.", + )); + + if self.processing { + content = content.push(p1_regular("Requesting camera access…")); + } + if let Some(error) = &self.passport_scan_error { + content = content.push(card::error("Camera", error.clone())); + } + if self.passport_cameras.len() > 1 { + content = content.push(p1_bold("Camera")); + for (index, camera) in self.passport_cameras.iter().enumerate() { + let selected = self.passport_camera_index == Some(index); + let label = if selected { + format!("{} (active)", camera.name) + } else { + camera.name.clone() + }; + content = + content.push(button::secondary(None, label).width(Length::Fill).on_press( + Self::route(SelectKeySourceMessage::SelectPassportCamera(index)), + )); + } + } + if let Some(preview) = &self.passport_preview { + content = content.push( + image(preview.clone()) + .width(Length::Fill) + .height(Length::Fixed(300.0)), + ); + } + if self.passport_scan_progress > 0.0 { + content = content.push(p1_regular(format!( + "QR progress: {:.0}%", + self.passport_scan_progress * 100.0 + ))); + } + content = content.push( + button::btn_secondary( + None, + "Cancel", + button::BtnWidth::Fill, + Some(Self::route(SelectKeySourceMessage::CancelPassportScan)), + ) + .width(Length::Fill), + ); + Container::new(content.width(modal::MODAL_WIDTH as u32)) + .padding(15) + .style(theme::card::modal) + .into() + } + fn details_view(&self) -> Element<'_, Message> { let apply = match ( &self.selected_key, @@ -978,8 +1225,36 @@ impl SelectKeySource { let pick_account = edit_account.then_some(pick_account); + let passport_details = if self.focus == Focus::ImportPassport { + match &self.selected_key { + SelectedKey::New(key) => match &key.key { + DescriptorPublicKey::XPub(xpub) => { + xpub.origin + .as_ref() + .map(|(origin_fingerprint, origin_path)| { + column![ + p1_bold("Air-gapped signer account"), + p1_regular("Device: Compatible air-gapped signer"), + p1_regular(format!("Master fingerprint: {origin_fingerprint}")), + p1_regular(format!("Origin path: {origin_path}")), + p1_regular(format!("Network: {}", self.network)), + p1_regular(format!("Account: {account}")), + ] + .spacing(4) + .into() + }) + } + _ => None, + }, + _ => None, + } + } else { + None + }; + details_view( header, + passport_details, pick_account, &self.form_alias, self.details_error.clone(), @@ -1083,12 +1358,34 @@ impl SelectKeySource { ) }); + let passport = safety_net_token.is_none().then(|| { + modal::import_airgapped_signer_entry(Some(|| { + Self::route(SelectKeySourceMessage::SelectPassport) + })) + }); + + let passport_transport = (self.focus == Focus::ImportPassport).then(|| { + column![ + p1_regular("Import the public signer account key using:"), + button::primary(None, "Scan animated QR code") + .width(Length::Fill) + .on_press(Self::route(SelectKeySourceMessage::SelectPassportQr)), + button::secondary(None, "Import key file from microSD") + .width(Length::Fill) + .on_press(Self::route(SelectKeySourceMessage::SelectPassportFile)), + ] + .spacing(8) + .width(Length::Fill) + }); + let mut col = Column::new() .push(option_section) .spacing(modal::V_SPACING) .width(modal::BTN_W); if collapsed { col = col + .push_maybe(passport) + .push_maybe(passport_transport) .push_maybe(load_key) .push_maybe(paste_xpub) .push_maybe(hot_signer) @@ -1171,6 +1468,7 @@ impl SelectKeySource { let (source, alias, fg, available) = key; let kind = match source { KeySource::Device(..) => KeySourceKind::Device, + KeySource::Airgapped(_) => KeySourceKind::Device, KeySource::HotSigner => KeySourceKind::HotKey, KeySource::Manual => KeySourceKind::Xpub, KeySource::Token(..) => KeySourceKind::Token, @@ -1217,7 +1515,11 @@ impl super::DescriptorEditModal for SelectKeySource { } Message::ImportExport(ImportExportMessage::Xpub(xpub)) => { self.modal = None; - self.on_import_xpub(xpub) + if self.focus == Focus::ImportPassport { + self.on_import_passport(xpub) + } else { + self.on_import_xpub(xpub) + } } Message::ImportExport(iem) => { if let Some(modal) = &mut self.modal { @@ -1235,6 +1537,21 @@ impl super::DescriptorEditModal for SelectKeySource { } SelectKeySourceMessage::SelectKey(fingerprint) => self.on_select_key(fingerprint), SelectKeySourceMessage::SelectLoadXpub => self.on_select_load_xpub(), + SelectKeySourceMessage::SelectPassport => self.on_select_passport(), + SelectKeySourceMessage::SelectPassportQr => self.on_select_passport_qr(), + SelectKeySourceMessage::SelectPassportFile => self.on_select_passport_file(), + SelectKeySourceMessage::PassportCameras(result) => self.on_passport_cameras(result), + SelectKeySourceMessage::SelectPassportCamera(index) => { + self.start_passport_camera(index); + Task::none() + } + SelectKeySourceMessage::PollPassportCamera => self.poll_passport_camera(), + SelectKeySourceMessage::CancelPassportScan => { + self.cancel_passport_scan(); + self.focus = Focus::ImportPassport; + Task::none() + } + SelectKeySourceMessage::PassportAccount(value) => self.on_import_passport(value), SelectKeySourceMessage::LoadKey(key) => self.on_load_key(key), SelectKeySourceMessage::SelectEnterXpub => self.on_select_enter_xpub(), SelectKeySourceMessage::PasteXpub => self.on_paste_xpub(), @@ -1266,6 +1583,12 @@ impl super::DescriptorEditModal for SelectKeySource { } fn subscription(&self, hws: &HardwareWallets) -> Subscription { let hw = hws.refresh().map(Message::HardwareWallets); + let camera = if self.passport_scanner.is_some() { + iced::time::every(std::time::Duration::from_millis(33)) + .map(|_| Self::route(SelectKeySourceMessage::PollPassportCamera)) + } else { + Subscription::none() + }; if let Some(modal) = self.modal.as_ref() { if let Some(sub) = modal.subscription() { let import = sub.map(|m| { @@ -1273,14 +1596,15 @@ impl super::DescriptorEditModal for SelectKeySource { ImportExportMessage::Progress(m), )) }); - return Subscription::batch(vec![hw, import]); + return Subscription::batch(vec![hw, camera, import]); } } - hw + Subscription::batch(vec![hw, camera]) } fn view<'a>(&'a self, hws: &'a HardwareWallets) -> Element<'a, Message> { let detected_hws = self.detected_hws(hws); let content = match self.step { + Step::Select if self.focus == Focus::ScanPassport => self.passport_scanner_view(), Step::Select => self.main_view(detected_hws), Step::Details => self.details_view(), }; @@ -1299,6 +1623,7 @@ impl super::DescriptorEditModal for SelectKeySource { #[allow(clippy::too_many_arguments)] pub fn details_view<'a, Alias>( header: Element<'a, Message>, + key_details: Option>, pick_account: Option>, alias: &'a form::Value, error: Option, @@ -1349,6 +1674,7 @@ where let column = Column::new() .spacing(5) .push(header) + .push_maybe(key_details) .push(row![ p1_bold("Key name (alias):"), Space::with_width(Length::Fill) @@ -1460,6 +1786,7 @@ impl super::DescriptorEditModal for EditKeyAlias { details_view( header, None, + None, &self.form_alias, None, |s| Message::EditKeyAlias(EditKeyAliasMessage::Alias(s)), diff --git a/liana-gui/src/installer/step/descriptor/editor/mod.rs b/liana-gui/src/installer/step/descriptor/editor/mod.rs index e4c73fe33a..bdff8fa879 100644 --- a/liana-gui/src/installer/step/descriptor/editor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/editor/mod.rs @@ -10,8 +10,11 @@ use liana::miniscript::bitcoin::bip32::ChildNumber; use liana::{ descriptors::{LianaDescriptor, PathInfo}, miniscript::{ - bitcoin::{bip32::Fingerprint, Network}, - descriptor::DescriptorPublicKey, + bitcoin::{ + bip32::{Fingerprint, Xpub}, + Network, + }, + descriptor::{DescriptorPublicKey, DescriptorXKey}, }, }; @@ -37,6 +40,37 @@ use liana_connect::keys::api::KeyKind; use key::{new_multixkey_from_xpub, EditKeyAlias, PathData, SelectKeySource}; +fn record_installer_key(ctx: &mut Context, key: &Key, xpub: &DescriptorXKey) -> bool { + let Some((master_fingerprint, _)) = xpub.origin else { + return false; + }; + ctx.keys.insert( + master_fingerprint, + KeySetting { + master_fingerprint, + name: key.name.clone(), + provider_key: key.source.provider_key(), + }, + ); + if let crate::installer::descriptor::KeySource::Airgapped(kind) = key.source { + ctx.airgapped_signers.insert( + master_fingerprint, + crate::airgap::AirgappedSignerConfig { + kind, + fingerprint: master_fingerprint, + alias: (!key.name.is_empty()).then(|| key.name.clone()), + account: DescriptorPublicKey::XPub(xpub.clone()), + registration: crate::airgap::RegistrationState::NotRegistered, + }, + ); + } + matches!( + key.source, + crate::installer::descriptor::KeySource::Device(_, _) + | crate::installer::descriptor::KeySource::Airgapped(_) + ) +} + pub trait DescriptorEditModal { fn processing(&self) -> bool { false @@ -491,6 +525,7 @@ impl Step for DefineDescriptor { ctx.bitcoin_config.network = self.network; ctx.keys = HashMap::new(); + ctx.airgapped_signers.clear(); let mut hw_is_used = false; let mut spending_keys: Vec = Vec::new(); let mut key_derivation_index = HashMap::::new(); @@ -504,19 +539,7 @@ impl Step for DefineDescriptor { .get(&fingerprint) .expect("Must be present at this step"); if let DescriptorPublicKey::XPub(xpub) = &key.key { - if let Some((master_fingerprint, _)) = xpub.origin { - ctx.keys.insert( - master_fingerprint, - KeySetting { - master_fingerprint, - name: key.name.clone(), - provider_key: key.source.provider_key(), - }, - ); - if key.source.device_kind().is_some() { - hw_is_used = true; - } - } + hw_is_used |= record_installer_key(ctx, key, xpub); let derivation_index = key_derivation_index.get(&fingerprint).unwrap_or(&0); spending_keys.push(DescriptorPublicKey::MultiXPub(new_multixkey_from_xpub( xpub.clone(), @@ -540,19 +563,7 @@ impl Step for DefineDescriptor { .get(&fingerprint) .expect("Must be present at this step"); if let DescriptorPublicKey::XPub(xpub) = &key.key { - if let Some((master_fingerprint, _)) = xpub.origin { - ctx.keys.insert( - master_fingerprint, - KeySetting { - master_fingerprint, - name: key.name.clone(), - provider_key: key.source.provider_key(), - }, - ); - if key.source.device_kind().is_some() { - hw_is_used = true; - } - } + hw_is_used |= record_installer_key(ctx, key, xpub); let derivation_index = key_derivation_index.get(&fingerprint).unwrap_or(&0); recovery_keys.push(DescriptorPublicKey::MultiXPub(new_multixkey_from_xpub( diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index e57d517d87..68a9f6bd95 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -2,10 +2,11 @@ pub mod editor; use std::{ collections::{HashMap, HashSet}, + fs, str::FromStr, }; -use iced::{Subscription, Task}; +use iced::{widget::qr_code, Subscription, Task}; use liana::{ descriptors::LianaDescriptor, miniscript::bitcoin::{bip32::Fingerprint, Network}, @@ -16,9 +17,13 @@ use liana_ui::{component::form, widget::Element}; use async_hwi::DeviceKind; use crate::{ + airgap::{ + encode_ur, AirgappedRequest, AirgappedSignerConfig, AnimatedQr, PolicyRegistration, + QrDensity, UrPayload, + }, app::{settings::KeySetting, state::export::ExportModal, wallet::wallet_name}, backup::Backup, - export::{ImportExportMessage, ImportExportType, Progress}, + export::{get_path, ImportExportMessage, ImportExportType, Progress}, hw::{HardwareWallet, HardwareWallets}, installer::{ decrypt::{Decrypt, DecryptModal}, @@ -225,6 +230,7 @@ impl Step for ImportDescriptor { fn revert(&self, ctx: &mut Context) { ctx.keys = HashMap::new(); + ctx.airgapped_signers.clear(); ctx.backup = None; ctx.descriptor = None; ctx.wallet_alias = String::new(); @@ -270,6 +276,61 @@ pub struct RegisterDescriptor { /// whether a signing device is used, to explicit this step is not required if the user isn't /// using a signing device. created_desc: bool, + network: Network, + airgapped_signers: Vec, + passport_qr: Option, +} + +struct PassportRegistrationQr { + fingerprint: Fingerprint, + payload: UrPayload, + density: QrDensity, + animation: AnimatedQr, + qr_data: qr_code::Data, +} + +impl PassportRegistrationQr { + fn new(fingerprint: Fingerprint, payload: UrPayload) -> Result { + let density = QrDensity::default(); + let (animation, qr_data) = Self::encode(&payload, density)?; + Ok(Self { + fingerprint, + payload, + density, + animation, + qr_data, + }) + } + + fn encode( + payload: &UrPayload, + density: QrDensity, + ) -> Result<(AnimatedQr, qr_code::Data), String> { + let animation = encode_ur(payload, density.fragment_length()) + .and_then(|encoded| AnimatedQr::new(encoded, 5)) + .map_err(|error| error.to_string())?; + let frame = animation + .frame() + .ok_or_else(|| "QR animation has no frames".to_owned())?; + let qr_data = qr_code::Data::new(frame).map_err(|error| error.to_string())?; + Ok((animation, qr_data)) + } + + fn set_density(&mut self, density: QrDensity) -> Result<(), String> { + let (animation, qr_data) = Self::encode(&self.payload, density)?; + self.density = density; + self.animation = animation; + self.qr_data = qr_data; + Ok(()) + } + + fn refresh(&mut self) { + if let Some(frame) = self.animation.frame() { + if let Ok(data) = qr_code::Data::new(frame) { + self.qr_data = data; + } + } + } } impl RegisterDescriptor { @@ -283,6 +344,9 @@ impl RegisterDescriptor { registered: Default::default(), error: Default::default(), done: Default::default(), + network: Network::Bitcoin, + airgapped_signers: Vec::new(), + passport_qr: None, } } @@ -303,6 +367,10 @@ impl Step for RegisterDescriptor { self.done = false; } self.descriptor.clone_from(&ctx.descriptor); + self.network = ctx.network; + self.airgapped_signers = ctx.airgapped_signers.values().cloned().collect(); + self.airgapped_signers + .sort_by_key(|signer| signer.fingerprint); let mut map = HashMap::new(); for key in ctx.keys.values().filter(|k| !k.name.is_empty()) { map.insert(key.master_fingerprint, key.name.clone()); @@ -356,6 +424,115 @@ impl Step for RegisterDescriptor { } } } + Message::RegisterPassport(fingerprint) => { + let Some(descriptor) = self.descriptor.as_ref() else { + return Task::none(); + }; + let registration = PolicyRegistration::from_descriptor( + wallet_name(descriptor), + self.network, + descriptor, + ); + match registration + .and_then(|registration| { + AirgappedRequest::RegisterPolicy(registration).encode() + }) + .map_err(|error| error.to_string()) + .and_then(|payload| PassportRegistrationQr::new(fingerprint, payload)) + { + Ok(qr) => { + self.passport_qr = Some(qr); + self.error = None; + } + Err(error) => self.error = Some(Error::Unexpected(error)), + } + } + Message::PassportQrTick => { + if let Some(qr) = &mut self.passport_qr { + qr.refresh(); + } + } + Message::PausePassportQr => { + if let Some(qr) = &mut self.passport_qr { + qr.animation.pause(); + } + } + Message::ResumePassportQr => { + if let Some(qr) = &mut self.passport_qr { + qr.animation.resume(); + } + } + Message::RestartPassportQr => { + if let Some(qr) = &mut self.passport_qr { + qr.animation.restart(); + qr.refresh(); + } + } + Message::LessDensePassportQr => { + if let Some(qr) = &mut self.passport_qr { + if let Some(density) = qr.density.less_dense() { + if let Err(error) = qr.set_density(density) { + self.error = Some(Error::Unexpected(error)); + } + } + } + } + Message::MoreDensePassportQr => { + if let Some(qr) = &mut self.passport_qr { + if let Some(density) = qr.density.more_dense() { + if let Err(error) = qr.set_density(density) { + self.error = Some(Error::Unexpected(error)); + } + } + } + } + Message::ExportPassportRegistration => { + let Some(descriptor) = self.descriptor.as_ref() else { + return Task::none(); + }; + let bytes = match PolicyRegistration::from_descriptor( + wallet_name(descriptor), + self.network, + descriptor, + ) + .and_then(|registration| { + AirgappedRequest::RegisterPolicy(registration) + .encode() + .map(|payload| payload.data) + }) { + Ok(bytes) => bytes, + Err(error) => { + self.error = Some(Error::Unexpected(error.to_string())); + return Task::none(); + } + }; + return Task::perform( + async move { + let Some(path) = get_path("liana-policy.json".to_owned(), true).await + else { + return Ok(None); + }; + fs::write(&path, bytes) + .map(|_| Some(path)) + .map_err(|error| error.to_string()) + }, + Message::PassportRegistrationFileExported, + ); + } + Message::PassportRegistrationFileExported(result) => match result { + Ok(Some(_)) => { + self.error = None; + } + Ok(None) => {} + Err(error) => self.error = Some(Error::Unexpected(error)), + }, + Message::PassportRegistrationExported(fingerprint) => { + if self.passport_qr.as_ref().map(|qr| qr.fingerprint) == Some(fingerprint) { + self.registered.insert(fingerprint); + self.passport_qr = None; + } + } + Message::CancelPassportRegistration => self.passport_qr = None, Message::Reload => { return self.load(); } @@ -373,10 +550,30 @@ impl Step for RegisterDescriptor { for (fingerprint, kind, token) in &self.hmacs { ctx.hws.push((*kind, *fingerprint, *token)); } + if let Some(descriptor) = self.descriptor.as_ref() { + let checksum = descriptor + .to_string() + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .unwrap_or_default(); + for signer in ctx.airgapped_signers.values_mut() { + if self.registered.contains(&signer.fingerprint) { + signer.registration = crate::airgap::RegistrationState::Exported { + descriptor_checksum: checksum.clone(), + }; + } + } + } true } fn subscription(&self, hws: &HardwareWallets) -> Subscription { - hws.refresh().map(Message::HardwareWallets) + let hws = hws.refresh().map(Message::HardwareWallets); + let qr = if self.passport_qr.is_some() { + iced::time::every(std::time::Duration::from_millis(50)).map(|_| Message::PassportQrTick) + } else { + Subscription::none() + }; + Subscription::batch(vec![hws, qr]) } fn load(&self) -> Task { Task::none() @@ -396,6 +593,18 @@ impl Step for RegisterDescriptor { email, desc, &hws.list, + &self.airgapped_signers, + self.passport_qr.as_ref().map(|qr| { + let state = qr.animation.state(); + ( + qr.fingerprint, + &qr.qr_data, + state.frame, + state.total_frames, + state.paused, + qr.density, + ) + }), &self.registered, self.error.as_ref(), self.processing, diff --git a/liana-gui/src/installer/view/mod.rs b/liana-gui/src/installer/view/mod.rs index e61f47740b..f327832d6f 100644 --- a/liana-gui/src/installer/view/mod.rs +++ b/liana-gui/src/installer/view/mod.rs @@ -3,7 +3,9 @@ pub mod editor; use async_hwi::utils::extract_keys_and_template; use iced::{ alignment, - widget::{checkbox, column, progress_bar, radio, row, tooltip, Button, Space, TextInput}, + widget::{ + checkbox, column, progress_bar, qr_code, radio, row, tooltip, Button, Space, TextInput, + }, Alignment, Length, }; @@ -44,6 +46,7 @@ use liana_ui::{ use crate::node::electrum::validate_domain_checkbox; use crate::{ + airgap::{AirgappedSignerConfig, QrDensity}, app::settings, help, hw::HardwareWallet, @@ -568,6 +571,15 @@ pub fn register_descriptor<'a>( email: Option<&'a str>, descriptor: &'a LianaDescriptor, hws: &'a [HardwareWallet], + airgapped_signers: &'a [AirgappedSignerConfig], + passport_qr: Option<( + Fingerprint, + &'a qr_code::Data, + usize, + usize, + bool, + QrDensity, + )>, registered: &HashSet, error: Option<&Error>, processing: bool, @@ -576,6 +588,10 @@ pub fn register_descriptor<'a>( created_desc: bool, ) -> Element<'a, Message> { let descriptor_str = descriptor.to_string(); + let policy_checksum = descriptor_str + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .unwrap_or_default(); let displayed_descriptor = if let Ok((template, keys)) = extract_keys_and_template::(&descriptor_str) { policy_view(template, keys) @@ -589,15 +605,15 @@ pub fn register_descriptor<'a>( let error_card = error.map(|e| card::error("Failed to register descriptor", e.to_string())); let devices_title = Container::new(if created_desc { - new::b5_bold("Select hardware wallet to register descriptor on:") + new::b5_bold("Select signing device to register descriptor on:") } else { new::b5_bold("If necessary, please select the signing device to register descriptor on:") }) .width(Length::Fill); - let devices: Element<'a, Message> = if hws.is_empty() { + let devices: Element<'a, Message> = if hws.is_empty() && airgapped_signers.is_empty() { modal::modal_no_devices_placeholder() } else { - Column::with_children(hws.iter().enumerate().map(|(i, hw)| { + let mut devices = Column::with_children(hws.iter().enumerate().map(|(i, hw)| { let entry = crate::view::hw::device_list_entry( hw, crate::view::hw::HwRowMode::Registration { @@ -613,9 +629,23 @@ pub fn register_descriptor<'a>( move || Message::Select(i), ); Container::new(entry).width(EntryWidth::Standard).into() - })) - .spacing(10) - .into() + })); + for signer in airgapped_signers { + let fingerprint = signer.fingerprint; + let complete = registered.contains(&fingerprint); + let alias = signer.alias.as_deref().unwrap_or("Air-gapped signer"); + let label = if complete { + format!("{alias} ({fingerprint}) — registration completed") + } else { + format!("Register on {alias} ({fingerprint})") + }; + devices = devices.push( + button::secondary(None, label) + .width(EntryWidth::Standard) + .on_press_maybe((!complete).then_some(Message::RegisterPassport(fingerprint))), + ); + } + devices.spacing(10).into() }; let signing_devices = column![devices_title, devices] .align_x(Alignment::Center) @@ -633,13 +663,73 @@ pub fn register_descriptor<'a>( let next_button = row![Space::fill_width(), btn_next(next)]; let help = new::caption(prompt::REGISTER_DESCRIPTOR_HELP); + let qr_card = passport_qr.map(|(fingerprint, qr, frame, total, paused, density)| { + let animation_controls = (total > 1).then(|| { + row![ + if paused { + button::secondary(None, "Resume").on_press(Message::ResumePassportQr) + } else { + button::secondary(None, "Pause").on_press(Message::PausePassportQr) + }, + button::secondary(None, "Restart").on_press(Message::RestartPassportQr), + ] + .spacing(10) + }); + let content = column![ + new::b5_bold(format!("Scan with signer {fingerprint}")), + Container::new(qr_code::QRCode::::new(qr).cell_size(7.0)) + .center_x(Length::Fill), + new::caption(if total > 1 { + format!("Animated QR frame {} of {total}", frame + 1) + } else { + "Registration QR code".to_owned() + }), + ] + .push_maybe(animation_controls) + .push(new::caption(format!("QR density: {}", density.label()))) + .push( + row![ + button::secondary(None, "Less dense") + .on_press_maybe(density.less_dense().map(|_| Message::LessDensePassportQr)), + button::secondary(None, "More dense") + .on_press_maybe(density.more_dense().map(|_| Message::MoreDensePassportQr)), + ] + .spacing(10), + ) + .push( + row![ + button::secondary(None, "Cancel").on_press(Message::CancelPassportRegistration), + button::secondary(None, "Export to microSD") + .on_press(Message::ExportPassportRegistration), + button::primary(None, "Confirmed on signer") + .on_press(Message::PassportRegistrationExported(fingerprint)) + ] + .spacing(10), + ) + .spacing(12) + .align_x(Alignment::Center); + card::simple(content) + }); let content = column![ warning, + card::simple( + column![ + new::b5_bold("Policy checksum:"), + row![ + new::b5_bold(policy_checksum.clone()), + button::btn_copy(Some(Message::Clipboard(policy_checksum))) + ] + .spacing(10), + new::caption("Compare this exact checksum with your signer; matching wallet names are not sufficient."), + ] + .spacing(10) + ), displayed_descriptor, help, error_card, signing_devices, + qr_card, registered_checkbox, next_button, Space::with_height(5), @@ -685,6 +775,10 @@ pub fn backup_descriptor<'a>( let error_card = error.map(|e| card::error("Failed to export backup", e.to_string())); let descriptor_str = descriptor.to_string(); + let policy_checksum = descriptor_str + .rsplit_once('#') + .map(|(_, checksum)| checksum.to_owned()) + .unwrap_or_default(); let backup_button = btn_backup_descriptor(Some(Message::BackupDescriptor), !done); let copy_button = column![ @@ -700,6 +794,13 @@ pub fn backup_descriptor<'a>( let descriptor_card = card::simple( column![ text::new::b5_bold("The descriptor:"), + row![ + text::new::b5_bold("Policy checksum:"), + text::new::b5_bold(policy_checksum.clone()), + button::btn_copy(Some(Message::Clipboard(policy_checksum))) + ] + .spacing(10) + .align_y(Alignment::Center), descriptor_header, descriptor_actions, ] diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index 9c600a8570..cd09be7568 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -1,3 +1,4 @@ +pub mod airgap; pub mod app; pub mod args; pub mod backup; diff --git a/liana-gui/src/window.rs b/liana-gui/src/window.rs index 85cf6ec8d2..6176cb1bee 100644 --- a/liana-gui/src/window.rs +++ b/liana-gui/src/window.rs @@ -34,7 +34,7 @@ pub fn load_initial_size(liana_directory: &LianaDirectory, default_size: Option< /// Create iced window Settings. #[allow(unused_mut)] -pub fn create_window_settings(app_id: &str, initial_size: Size) -> iced::window::Settings { +pub fn create_window_settings(_app_id: &str, initial_size: Size) -> iced::window::Settings { let mut window_settings = iced::window::Settings { size: initial_size, icon: Some(image::liana_app_icon()), @@ -50,7 +50,7 @@ pub fn create_window_settings(app_id: &str, initial_size: Size) -> iced::window: #[cfg(target_os = "linux")] { window_settings.platform_specific = PlatformSpecific { - application_id: app_id.to_string(), + application_id: _app_id.to_string(), ..Default::default() }; } diff --git a/liana-gui/test_assets/passport/README.md b/liana-gui/test_assets/passport/README.md new file mode 100644 index 0000000000..6461315681 --- /dev/null +++ b/liana-gui/test_assets/passport/README.md @@ -0,0 +1,24 @@ +# Passport protocol fixtures + +These deterministic, public-only fixtures lock Passport air-gap protocol v1. +They contain no private key material and must remain stable unless the protocol +version changes. + +| Fixture | Purpose | +| --- | --- | +| `account-mainnet.txt` | Mainnet BIP48 native-SegWit microSD key export | +| `account-testnet.txt` | Testnet BIP48 native-SegWit microSD key export | +| `policy-registration-mainnet.json` | Single-signer inheritance policy with immediate and timelocked paths | +| `liana-multisig-testnet.descriptor` | Multipath multisig inheritance policy with immediate and timelocked paths | +| `address-request-mainnet.json` | Receive-address verification request | +| `address-response-mainnet.json` | Passport-bound address verification response | +| `unsigned.psbt.base64` | Canonical unsigned PSBT | +| `partially-signed.psbt.base64` | Same transaction with one expected partial signature | +| `ur-single-bytes.txt` | Single-part BC-UR v2 value | +| `ur-multipart-bytes.txt` | One deterministic multipart BC-UR v2 cycle | + +The integration tests verify canonical re-encoding, policy identity and +descriptor checksum, network/type/resource rejection, request-response +binding, PSBT immutability, and UR corruption/reordering/duplicate behavior. +Passport Core's host decoder independently accepts the policy-registration +fixture and derives the recorded policy identity. diff --git a/liana-gui/test_assets/passport/account-mainnet.txt b/liana-gui/test_assets/passport/account-mainnet.txt new file mode 100644 index 0000000000..ecde548e2d --- /dev/null +++ b/liana-gui/test_assets/passport/account-mainnet.txt @@ -0,0 +1 @@ +[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW diff --git a/liana-gui/test_assets/passport/account-testnet.txt b/liana-gui/test_assets/passport/account-testnet.txt new file mode 100644 index 0000000000..d13f9ab308 --- /dev/null +++ b/liana-gui/test_assets/passport/account-testnet.txt @@ -0,0 +1 @@ +[9f141cf0/48'/1'/0'/2']tpubDFnReAwXvYd6RA46X55HuFpmvZsLanDrwHAUsdYEGEpNGTRnCdbDRXJGLTwDeqKURCPZUDgdkuuu9dYkuBNQHmSNBUu7V2CdLKwpJjx2JuC diff --git a/liana-gui/test_assets/passport/address-request-mainnet.json b/liana-gui/test_assets/passport/address-request-mainnet.json new file mode 100644 index 0000000000..ce20edeb01 --- /dev/null +++ b/liana-gui/test_assets/passport/address-request-mainnet.json @@ -0,0 +1 @@ +{"format":"passport-address-verification","version":1,"network":"BTC","policy_id":"506b3dd1ce28b757cde12e2977c483b0afb518de9ad8edbdfbc01e5d9763dd9f","descriptor_checksum":"y7qrgwup","branch":0,"index":7} diff --git a/liana-gui/test_assets/passport/address-response-mainnet.json b/liana-gui/test_assets/passport/address-response-mainnet.json new file mode 100644 index 0000000000..9e41987942 --- /dev/null +++ b/liana-gui/test_assets/passport/address-response-mainnet.json @@ -0,0 +1 @@ +{"format":"passport-address-verification-response","version":1,"network":"BTC","policy_id":"506b3dd1ce28b757cde12e2977c483b0afb518de9ad8edbdfbc01e5d9763dd9f","descriptor_checksum":"y7qrgwup","branch":0,"index":7,"address":"bc1qvqtd2lx6368nuwxy9frnmf55ft8mhp376ussqp9gywtl5qepaa6s260tt9","fingerprint":"abcdef01"} diff --git a/liana-gui/test_assets/passport/liana-multisig-testnet.descriptor b/liana-gui/test_assets/passport/liana-multisig-testnet.descriptor new file mode 100644 index 0000000000..031ee88e58 --- /dev/null +++ b/liana-gui/test_assets/passport/liana-multisig-testnet.descriptor @@ -0,0 +1 @@ +wsh(or_i(and_v(v:thresh(2,pkh([9f141cf0/48'/1'/0'/2']tpubDFnReAwXvYd6RA46X55HuFpmvZsLanDrwHAUsdYEGEpNGTRnCdbDRXJGLTwDeqKURCPZUDgdkuuu9dYkuBNQHmSNBUu7V2CdLKwpJjx2JuC/<2;3>/*),a:pkh([daba2d5f/48'/1'/0'/2']tpubDDwKEc4i4k8rBgVLGxytHrP13VVYucUGmL2cadux7AfMwMnRHKcw1YZKt9SMB4fWut7ZAiZqPefzm3BBCNXLZMxDrWJ4Q6VA1AFB6b8GzbT/<2;3>/*),a:pkh([141cfdf4/48'/1'/0'/2']tpubDEnYysximqdZkZnW5W9gYc7N3sxizKyqfdJfZ2qRfwNvSv6E11yDgyLTAnWQqDmVJ7oQ3h3ui59RQm1qmGxMm4jinq5wvSzyueKgrLJj5Cy/<0;1>/*)),older(52596)),and_v(v:pk([9f141cf0/48'/1'/0'/2']tpubDFnReAwXvYd6RA46X55HuFpmvZsLanDrwHAUsdYEGEpNGTRnCdbDRXJGLTwDeqKURCPZUDgdkuuu9dYkuBNQHmSNBUu7V2CdLKwpJjx2JuC/<0;1>/*),pk([daba2d5f/48'/1'/0'/2']tpubDDwKEc4i4k8rBgVLGxytHrP13VVYucUGmL2cadux7AfMwMnRHKcw1YZKt9SMB4fWut7ZAiZqPefzm3BBCNXLZMxDrWJ4Q6VA1AFB6b8GzbT/<0;1>/*))))#u768v50p diff --git a/liana-gui/test_assets/passport/partially-signed.psbt.base64 b/liana-gui/test_assets/passport/partially-signed.psbt.base64 new file mode 100644 index 0000000000..ef42878e01 --- /dev/null +++ b/liana-gui/test_assets/passport/partially-signed.psbt.base64 @@ -0,0 +1 @@ +cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIC9GXVlCuWVDJOzLkiQPy2L+8zzlR3qGcwD1cjLSMukHIgID3BlTwnVsfFjU9Iyhu7p2f0FP0ja/TWYrZ3IaxibFFOBHMEQCIFECOI0DAffMaMlAG7ZNZvBRcL1EsAnZnn3P+nTFSt9OAiAFU7DLVe6/zWi5aaQIcA06oM4zShXIbV+jjDti7zlS+AEBBSMhA9wZU8J1bHxY1PSMobu6dn9BT9I2v01mK2dyGsYmxRTgrCIGA9wZU8J1bHxY1PSMobu6dn9BT9I2v01mK2dyGsYmxRTgHHPF2gowAACAAAAAgAAAAIACAACAAAAAAAAAAAAAAAA= diff --git a/liana-gui/test_assets/passport/policy-registration-mainnet.json b/liana-gui/test_assets/passport/policy-registration-mainnet.json new file mode 100644 index 0000000000..589f5bfeca --- /dev/null +++ b/liana-gui/test_assets/passport/policy-registration-mainnet.json @@ -0,0 +1 @@ +{"format":"passport-wallet-policy","version":1,"name":"Recovery","network":"BTC","template":"wsh(or_d(pk(@0/<0;1>/*),and_v(v:pkh(@1/<0;1>/*),older(52560))))","keys":["[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW","[abcdef02]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe"],"policy_id":"506b3dd1ce28b757cde12e2977c483b0afb518de9ad8edbdfbc01e5d9763dd9f"} diff --git a/liana-gui/test_assets/passport/unsigned.psbt.base64 b/liana-gui/test_assets/passport/unsigned.psbt.base64 new file mode 100644 index 0000000000..e8c542c308 --- /dev/null +++ b/liana-gui/test_assets/passport/unsigned.psbt.base64 @@ -0,0 +1 @@ +cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIC9GXVlCuWVDJOzLkiQPy2L+8zzlR3qGcwD1cjLSMukHAQUjIQPcGVPCdWx8WNT0jKG7unZ/QU/SNr9NZitnchrGJsUU4KwiBgPcGVPCdWx8WNT0jKG7unZ/QU/SNr9NZitnchrGJsUU4BxzxdoKMAAAgAAAAIAAAACAAgAAgAAAAAAAAAAAAAAA diff --git a/liana-gui/test_assets/passport/ur-multipart-bytes.txt b/liana-gui/test_assets/passport/ur-multipart-bytes.txt new file mode 100644 index 0000000000..c15080d32c --- /dev/null +++ b/liana-gui/test_assets/passport/ur-multipart-bytes.txt @@ -0,0 +1,4 @@ +ur:bytes/1-4/lpadaacfadcwcyykteadfyhdflhkadcsgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykplglbjzon +ur:bytes/2-4/lpaoaacfadcwcyykteadfyhdfljpihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjprdcygyyt +ur:bytes/3-4/lpaxaacfadcwcyykteadfyhdflihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihghgyjoid +ur:bytes/4-4/lpaaaacfadcwcyykteadfyhdflgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihgdhsjkjkjojljpjycxjnkpjzjyinjohsjpjycxjojpjljyjliajljzcxiyinksjykpjpihaehpbssasn diff --git a/liana-gui/test_assets/passport/ur-single-bytes.txt b/liana-gui/test_assets/passport/ur-single-bytes.txt new file mode 100644 index 0000000000..2c706a0fdd --- /dev/null +++ b/liana-gui/test_assets/passport/ur-single-bytes.txt @@ -0,0 +1 @@ +ur:bytes/grgdhsjkjkjojljpjycxkoehykfslbwz diff --git a/liana-gui/tests/airgap_protocol.rs b/liana-gui/tests/airgap_protocol.rs new file mode 100644 index 0000000000..df0797f0ce --- /dev/null +++ b/liana-gui/tests/airgap_protocol.rs @@ -0,0 +1,469 @@ +use std::str::FromStr; + +use liana::{ + descriptors::LianaDescriptor, + miniscript::bitcoin::{secp256k1::Secp256k1, Network}, +}; +use liana_gui::airgap::{ + encode_ur, validate_and_merge_psbt, AddressVerificationRequest, AirgappedResponse, + DecodeProgress, Error, ExpectedResponse, PassportAccount, PolicyRegistration, ScanLimits, + UrDecodeSession, UrPayload, UrType, VerifiedAddress, +}; + +const POLICY: &[u8] = include_bytes!("../test_assets/passport/policy-registration-mainnet.json"); +const ADDRESS_REQUEST: &[u8] = + include_bytes!("../test_assets/passport/address-request-mainnet.json"); +const ADDRESS_RESPONSE: &[u8] = + include_bytes!("../test_assets/passport/address-response-mainnet.json"); +const SINGLE_UR: &str = include_str!("../test_assets/passport/ur-single-bytes.txt"); +const MULTIPART_UR: &str = include_str!("../test_assets/passport/ur-multipart-bytes.txt"); + +fn strip_fixture_line_ending(bytes: &[u8]) -> &[u8] { + bytes + .strip_suffix(b"\r\n") + .or_else(|| bytes.strip_suffix(b"\n")) + .unwrap_or(bytes) +} + +#[test] +fn policy_fixture_matches_passport_core_identity_and_liana_checksum() { + let policy = PolicyRegistration::from_json(POLICY).unwrap(); + assert_eq!( + policy.policy_id, + "506b3dd1ce28b757cde12e2977c483b0afb518de9ad8edbdfbc01e5d9763dd9f" + ); + assert_eq!(policy.descriptor_checksum().unwrap(), "y7qrgwup"); + assert_eq!(policy.to_json().unwrap(), strip_fixture_line_ending(POLICY)); +} + +#[test] +fn liana_multisig_descriptor_preserves_paths_key_order_and_timelock() { + let source = include_str!("../test_assets/passport/liana-multisig-testnet.descriptor").trim(); + let descriptor = LianaDescriptor::from_str(source).unwrap(); + let policy = + PolicyRegistration::from_descriptor("Family Vault", Network::Testnet4, &descriptor) + .unwrap(); + assert_eq!(policy.keys.len(), 3); + assert_eq!( + policy.template, + "wsh(or_i(and_v(v:thresh(2,pkh(@0/<2;3>/*),a:pkh(@1/<2;3>/*),a:pkh(@2/<0;1>/*)),older(52596)),and_v(v:pk(@0/<0;1>/*),pk(@1/<0;1>/*))))" + ); + assert_eq!(policy.full_descriptor(), source.rsplit_once('#').unwrap().0); + assert_eq!(policy.descriptor_checksum().unwrap(), "u768v50p"); + // Generated independently by Passport Core's MiniscriptPolicy v1. + assert_eq!( + policy.policy_id, + "54c9de390dd71ce7f500cac1b20b3ec2bbea26fd31dea892f936e78b61833151" + ); +} + +#[test] +fn address_is_bound_to_the_active_policy() { + let policy = PolicyRegistration::from_json(POLICY).unwrap(); + let request: AddressVerificationRequest = serde_json::from_slice(ADDRESS_REQUEST).unwrap(); + let response = match ExpectedResponse::VerifiedAddress + .decode(UrPayload::bytes( + strip_fixture_line_ending(ADDRESS_RESPONSE).to_vec(), + )) + .unwrap() + { + AirgappedResponse::VerifiedAddress(value) => value, + _ => unreachable!(), + }; + let descriptor = LianaDescriptor::from_str(&policy.full_descriptor()).unwrap(); + let address = descriptor + .receive_descriptor() + .derive(7.into(), &Secp256k1::verification_only()) + .address(Network::Bitcoin) + .to_string(); + response + .validate_for(&request, &address, "abcdef01") + .unwrap(); + + let stale = AddressVerificationRequest { + index: 8, + ..request + }; + assert_eq!( + response.validate_for(&stale, &address, "abcdef01"), + Err(Error::WrongResponseType) + ); +} + +#[test] +fn account_key_file_vectors_enforce_network() { + let mainnet = include_str!("../test_assets/passport/account-mainnet.txt"); + let testnet = include_str!("../test_assets/passport/account-testnet.txt"); + let mainnet = PassportAccount::from_descriptor_key(mainnet, Network::Bitcoin).unwrap(); + let testnet = PassportAccount::from_descriptor_key(testnet, Network::Testnet4).unwrap(); + assert_eq!(mainnet.fingerprint.to_string(), "aabb0011"); + assert_eq!(testnet.fingerprint.to_string(), "9f141cf0"); + assert_eq!( + PassportAccount::from_descriptor_key( + include_str!("../test_assets/passport/account-testnet.txt"), + Network::Bitcoin, + ), + Err(Error::InvalidNetwork) + ); +} + +#[test] +fn passport_core_crypto_account_vector_is_accepted() { + let encoded = hex::decode(concat!( + "a2011aa1b2c3d40281d90134d90191d9019ad9012fa602f40358210279be667e", + "f9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179804582000", + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05", + "d99d71a20100020006d99d70a301881830f500f507f502f5021aa1b2c3d40304", + "081a11223344" + )) + .unwrap(); + let account = PassportAccount::from_crypto_account_cbor(&encoded, Network::Bitcoin).unwrap(); + assert_eq!(account.fingerprint.to_string(), "a1b2c3d4"); + assert_eq!(account.account_number().unwrap().to_string(), "7'"); + assert!(account + .account + .to_string() + .starts_with("[a1b2c3d4/48'/0'/7'/2']xpub")); +} + +#[test] +fn single_and_multipart_ur_vectors_are_stable() { + let single = encode_ur(&UrPayload::bytes(b"Passport v1".to_vec()), 200).unwrap(); + assert_eq!(single.frames, [SINGLE_UR.trim()]); + + let expected: Vec<_> = MULTIPART_UR.lines().collect(); + let encoded = encode_ur( + &UrPayload::bytes(b"Passport multipart protocol fixture".repeat(8)), + 80, + ) + .unwrap(); + assert_eq!(encoded.frames, expected); + + let mut decoder = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + let mut frames = expected; + frames.reverse(); + frames.insert(1, frames[0]); + let mut decoded = None; + for frame in frames { + if let DecodeProgress::Complete(payload) = decoder.receive(frame).unwrap() { + decoded = Some(payload.data); + break; + } + } + assert_eq!( + decoded.unwrap(), + b"Passport multipart protocol fixture".repeat(8) + ); +} + +#[test] +fn malformed_wrong_type_and_missing_fragments_fail_safely() { + let mut wrong_type = UrDecodeSession::new(UrType::CryptoPsbt, ScanLimits::default()); + assert!(matches!( + wrong_type.receive(SINGLE_UR.trim()), + Err(Error::WrongUrType { .. }) + )); + + let mut corrupt = SINGLE_UR.trim().to_owned(); + corrupt.replace_range(corrupt.len() - 1.., "a"); + let mut decoder = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + assert!(matches!( + decoder.receive(&corrupt), + Err(Error::InvalidUr(_)) + )); + + let mut incomplete = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + for frame in MULTIPART_UR.lines().take(3) { + assert!(matches!( + incomplete.receive(frame), + Ok(DecodeProgress::Incomplete { .. }) + )); + } +} + +#[test] +fn mixed_and_oversized_multipart_sessions_are_rejected_before_allocation() { + let first = encode_ur(&UrPayload::bytes(vec![1; 500]), 100).unwrap(); + let second = encode_ur(&UrPayload::bytes(vec![2; 500]), 100).unwrap(); + let mut decoder = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + decoder.receive(&first.frames[0]).unwrap(); + assert_eq!(decoder.receive(&second.frames[1]), Err(Error::MixedSession)); + + let limits = ScanLimits { + maximum_decoded_bytes: 128, + ..ScanLimits::default() + }; + let mut oversized = UrDecodeSession::new(UrType::Bytes, limits); + assert!(matches!( + oversized.receive(&first.frames[0]), + Err(Error::PayloadTooLarge { .. }) + )); + + // Build an externally supplied sequence that exceeds Liana's encoder cap + // to verify the decoder independently rejects the declared geometry. + let mut cbor = minicbor::Encoder::new(Vec::new()); + cbor.bytes(&vec![3; 500]).unwrap(); + let cbor = cbor.into_writer(); + let mut encoder = foundation_ur::Encoder::new(); + encoder.start("bytes", &cbor, 1); + assert!(encoder.sequence_count() > 128); + let first_oversized_frame = encoder.next_part().to_string(); + let mut bounded = UrDecodeSession::new(UrType::Bytes, ScanLimits::default()); + assert!(matches!( + bounded.receive(&first_oversized_frame), + Err(Error::TooManyFragments { .. }) + )); +} + +#[test] +fn response_decoder_rejects_an_unexpected_json_envelope() { + assert!(matches!( + ExpectedResponse::VerifiedAddress.decode(UrPayload::bytes(POLICY.to_vec())), + Err(Error::InvalidJson(_)) + )); + + // Compile-time use of the public response type also locks the v1 API. + let _: fn(&[u8]) -> Result = VerifiedAddress::from_json; +} + +#[test] +fn psbt_vectors_roundtrip_and_only_signatures_are_merged() { + use liana::miniscript::bitcoin::{ + ecdsa, + psbt::{raw, Psbt}, + secp256k1::{Message, SecretKey}, + }; + + let mut unsigned = + Psbt::from_str(include_str!("../test_assets/passport/unsigned.psbt.base64").trim()) + .unwrap(); + let mut signed = + Psbt::from_str(include_str!("../test_assets/passport/partially-signed.psbt.base64").trim()) + .unwrap(); + assert_eq!(unsigned.unsigned_tx, signed.unsigned_tx); + assert_eq!(unsigned.inputs[0].partial_sigs.len(), 0); + assert_eq!(signed.inputs[0].partial_sigs.len(), 1); + + let proprietary = raw::ProprietaryKey { + prefix: b"liana-test".to_vec(), + subtype: 7, + key: vec![1, 2, 3], + }; + unsigned + .proprietary + .insert(proprietary.clone(), vec![4, 5, 6]); + signed + .proprietary + .insert(proprietary.clone(), vec![4, 5, 6]); + let merged = validate_and_merge_psbt(&unsigned, &signed).unwrap(); + assert_eq!(merged.inputs[0].partial_sigs.len(), 1); + assert_eq!(merged.proprietary.get(&proprietary), Some(&vec![4, 5, 6])); + + let mut invalid_signature = signed.clone(); + let (public_key, signature) = invalid_signature.inputs[0] + .partial_sigs + .iter() + .next() + .map(|(public_key, signature)| (*public_key, *signature)) + .unwrap(); + let secp = Secp256k1::signing_only(); + let wrong_signature = secp.sign_ecdsa( + &Message::from_digest([42; 32]), + &SecretKey::from_slice(&[7; 32]).unwrap(), + ); + invalid_signature.inputs[0].partial_sigs.insert( + public_key, + ecdsa::Signature { + signature: wrong_signature, + sighash_type: signature.sighash_type, + }, + ); + assert!(matches!( + validate_and_merge_psbt(&unsigned, &invalid_signature), + Err(Error::InvalidPsbt(_)) + )); + + let encoded = encode_ur(&UrPayload::psbt(&signed), 100).unwrap(); + let mut decoder = UrDecodeSession::new(UrType::CryptoPsbt, ScanLimits::default()); + let mut decoded = None; + for frame in encoded.frames { + if let DecodeProgress::Complete(payload) = decoder.receive(&frame).unwrap() { + decoded = Some(payload); + break; + } + } + match ExpectedResponse::SignedPsbt + .decode(decoded.unwrap()) + .unwrap() + { + AirgappedResponse::SignedPsbt(value) => assert_eq!(value, signed), + _ => unreachable!(), + } + + let mut wrong_transaction = signed.clone(); + wrong_transaction.unsigned_tx.output[0].value = liana::miniscript::bitcoin::Amount::from_sat(1); + assert!(matches!( + validate_and_merge_psbt(&unsigned, &wrong_transaction), + Err(Error::InvalidPsbt(_)) + )); + + let mut mutated_metadata = signed.clone(); + mutated_metadata + .proprietary + .insert(proprietary.clone(), vec![7]); + let merged = validate_and_merge_psbt(&unsigned, &mutated_metadata).unwrap(); + assert_eq!(merged.proprietary.get(&proprietary), Some(&vec![4, 5, 6])); +} + +#[test] +fn taproot_key_path_signature_is_verified_and_merged() { + use liana::{ + miniscript::bitcoin::{ + absolute, + bip32::{ChildNumber, DerivationPath}, + psbt::{Input, Output, Psbt}, + transaction, Address, Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, + }, + signer::HotSigner, + }; + + let secp = Secp256k1::new(); + let signer = HotSigner::from_str( + Network::Bitcoin, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap(); + let account_path = DerivationPath::from_str("m/86'/0'/0'").unwrap(); + let relative = [ + ChildNumber::from_normal_idx(0).unwrap(), + ChildNumber::from_normal_idx(0).unwrap(), + ]; + let account = signer.xpub_at(&account_path, &secp); + let child = account.derive_pub(&secp, &relative).unwrap(); + let internal_key = child.public_key.x_only_public_key().0; + let mut input = Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: Address::p2tr(&secp, internal_key, None, Network::Bitcoin) + .script_pubkey(), + }), + tap_internal_key: Some(internal_key), + ..Input::default() + }; + input.tap_key_origins.insert( + internal_key, + ( + vec![], + (signer.fingerprint(&secp), account_path.extend(relative)), + ), + ); + let unsigned = Psbt { + unsigned_tx: Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..TxIn::default() + }], + output: vec![TxOut::NULL], + }, + version: 0, + xpub: Default::default(), + proprietary: Default::default(), + unknown: Default::default(), + inputs: vec![input], + outputs: vec![Output::default()], + }; + let signed = signer.sign_psbt(unsigned.clone(), &secp).unwrap(); + assert!(signed.inputs[0].tap_key_sig.is_some()); + let merged = validate_and_merge_psbt(&unsigned, &signed).unwrap(); + assert_eq!(merged.inputs[0].tap_key_sig, signed.inputs[0].tap_key_sig); +} + +#[test] +fn repeated_multisig_rounds_preserve_and_verify_each_signature() { + use std::collections::BTreeMap; + + use liana::{ + descriptors::{LianaPolicy, PathInfo}, + miniscript::{ + bitcoin::{ + absolute, + bip32::DerivationPath, + psbt::{Input, Output, Psbt}, + transaction, Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, + }, + descriptor::DescriptorPublicKey, + }, + signer::HotSigner, + }; + + let secp = Secp256k1::new(); + let signer_a = HotSigner::from_str( + Network::Bitcoin, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap(); + let signer_b = HotSigner::from_str( + Network::Bitcoin, + "legal winner thank year wave sausage worth useful legal winner thank yellow", + ) + .unwrap(); + let signer_c = HotSigner::from_str( + Network::Bitcoin, + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + ) + .unwrap(); + let account_path = DerivationPath::from_str("m/48'/0'/0'/2'").unwrap(); + let key = |signer: &HotSigner, branches: &str| { + DescriptorPublicKey::from_str(&format!( + "[{}/48'/0'/0'/2']{}/{branches}/*", + signer.fingerprint(&secp), + signer.xpub_at(&account_path, &secp), + )) + .unwrap() + }; + let primary = PathInfo::Multi(2, vec![key(&signer_a, "<0;1>"), key(&signer_b, "<0;1>")]); + let recovery = PathInfo::Single(key(&signer_c, "<2;3>")); + let descriptor = LianaDescriptor::new( + LianaPolicy::new_legacy(primary, BTreeMap::from([(10, recovery)])).unwrap(), + ); + let coin = descriptor.receive_descriptor().derive(0.into(), &secp); + let mut input = Input::default(); + coin.update_psbt_in(&mut input); + input.witness_utxo = Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: coin.script_pubkey(), + }); + let unsigned = Psbt { + unsigned_tx: Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..TxIn::default() + }], + output: vec![TxOut::NULL], + }, + version: 0, + xpub: BTreeMap::new(), + proprietary: BTreeMap::new(), + unknown: BTreeMap::new(), + inputs: vec![input], + outputs: vec![Output::default()], + }; + + let returned_a = signer_a.sign_psbt(unsigned.clone(), &secp).unwrap(); + let round_one = validate_and_merge_psbt(&unsigned, &returned_a).unwrap(); + assert_eq!(round_one.inputs[0].partial_sigs.len(), 1); + + let returned_b = signer_b.sign_psbt(round_one.clone(), &secp).unwrap(); + let round_two = validate_and_merge_psbt(&round_one, &returned_b).unwrap(); + assert_eq!(round_two.inputs[0].partial_sigs.len(), 2); + assert!(round_one.inputs[0] + .partial_sigs + .keys() + .all(|key| round_two.inputs[0].partial_sigs.contains_key(key))); +} diff --git a/liana-ui/src/component/modal/mod.rs b/liana-ui/src/component/modal/mod.rs index ee5c06ade8..0b78a22cf5 100644 --- a/liana-ui/src/component/modal/mod.rs +++ b/liana-ui/src/component/modal/mod.rs @@ -548,6 +548,21 @@ where ) } +/// Entry importing a compatible signer's account through a public air-gap transport. +pub fn import_airgapped_signer_entry<'a, Message, M>(on_press: Option) -> Element<'a, Message> +where + M: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + button_entry( + Tile::Device, + "Air-gapped signer (QR code or key file)", + Some("Imports only the public account key; no USB connection is used"), + None, + on_press, + ) +} + /// Entry generating a key stored on this computer. pub fn generate_hot_key_entry<'a, Message, M>(on_press: Option) -> Element<'a, Message> where diff --git a/liana/src/descriptors/mod.rs b/liana/src/descriptors/mod.rs index 86ff4a0bf8..70f01ca69b 100644 --- a/liana/src/descriptors/mod.rs +++ b/liana/src/descriptors/mod.rs @@ -809,6 +809,7 @@ impl DerivedSinglePathLianaDesc { match self.0 { descriptor::Descriptor::Wsh(_) => { psbtout.bip32_derivation = self.bip32_derivations(); + psbtout.witness_script = Some(self.witness_script()); } descriptor::Descriptor::Tr(_) => { let desc = self.definite_desc(); @@ -2223,6 +2224,7 @@ mod tests { desc: LianaDescriptor, secp: &secp256k1::Secp256k1, ) { + let expect_witness_script = !desc.is_taproot(); // Unrelated PSBT from another unit test above. let mut psbt = Psbt::from_str("cHNidP8BAFICAAAAAc+3IQFejOVro5Hlwy18au5Jr5mJX+tNMGk0ZE1hydIbAQAAAAD9////ARhzAQAAAAAAFgAUqJZUU7Fqu+bIvxjNw+TAtTwP9HQAAAAAAAEAzQIAAAAAAQEIoAeUdfZj04Ds8EspEK222TJdDNy1WZb/Mg1PJbQekwAAAAAA/f///wKQCQQAAAAAACJRIPJojBgnDc9oUS5lDNx/YJznYR2NPQue7h/d+o5Z+2FQoIYBAAAAAAAiACDZrCBvscZpg+S+IaoZBJjyKDdrNS3oXPaF17DNaB+4mAFAe9yuRS3Vn8A5NUglhwiX7vN0wpQ0Q43ClWtJRnC2HJ66h5HYJ/p8xHgHOhRDUWRzcXLLGl+brc5dW+k0OvIZEyuLAgABASughgEAAAAAACIAINmsIG+xxmmD5L4hqhkEmPIoN2s1Lehc9oXXsM1oH7iYAQX9GQFjdqkU2zK+b9oTL/KfnOSYtq3wmtf4qP6IrGt2qRTSNOD0U7fuHdAnKchIf8GmUO904YisbJNrdqkUE5TQk5mdyYtviaGAsIiOgc4y6wGIrGyTU4hWsmdTIQOirPI1KXBtP2Tg2FQxSo4BjFBTf+dCKtZwDQt056slgCEDDHE7Hpxq++JsjZdbfwsPiA6pmq0dV00tR3hc2sus8KkhA2nPUthIMe1SeFegiZEKZF69yJerP1RFVlyu66C5lOVVU65zZHapFEUmCTccyLJXczvUfPUOCXr7CN0uiKxrdqkUeJmVqUt1Q4aFREOUWKX9U/SuZZ2IrGyTa3apFBDmKn40ceTWVbwxRI21c2qji1tOiKxsk1KIU7JoaCIGAjCZLg7xtlG43xEvns0TRd5gHpPrZWzAaYjo3lheMw/hHJAxFe8wAACAAQAAgAAAAIACAACAAgAAAAgAAAAiBgI0Y2/HRNvXA3niUE3RvrzQcCDiJ4F6vVog0uIanRUWHhwXK6G8MAAAgAEAAIAAAACAAgAAgAIAAAAIAAAAIgYCQKZf/IBUWv4F4mGVTv5PlqCceXFtlhfOgW0kIAPI74scFyuhvDAAAIABAACAAAAAgAIAAIAEAAAACAAAACIGAkDfArY5kwHyHvKllcCMhQLErtDmT/A13vABH8PBQ6yIHGNq3z8wAACAAQAAgAAAAIACAACABAAAAAgAAAAiBgLp9dq4ku0u9UKpIRasIb5QEPgPkDcxdcSXYBfW7mUcqByQMRXvMAAAgAEAAIAAAACAAgAAgAQAAAAIAAAAIgYDDHE7Hpxq++JsjZdbfwsPiA6pmq0dV00tR3hc2sus8KkcFyuhvDAAAIABAACAAAAAgAIAAIAAAAAACAAAACIGA0SIq7IkQJYb7brFx54mPzwUl/DzCGja0pdwFFckfm6WHGNq3z8wAACAAQAAgAAAAIACAACAAgAAAAgAAAAiBgNpz1LYSDHtUnhXoImRCmRevciXqz9URVZcruuguZTlVRyQMRXvMAAAgAEAAIAAAACAAgAAgAAAAAAIAAAAIgYDoqzyNSlwbT9k4NhUMUqOAYxQU3/nQirWcA0LdOerJYAcY2rfPzAAAIABAACAAAAAgAIAAIAAAAAACAAAAAAA").unwrap(); @@ -2238,6 +2240,7 @@ mod tests { }; let mut psbt_out = Default::default(); der_desc.update_change_psbt_out(&mut psbt_out); + assert_eq!(psbt_out.witness_script.is_some(), expect_witness_script); psbt.unsigned_tx.output.push(txo); psbt.outputs.push(psbt_out); let indexes = desc.change_indexes(&psbt, secp); @@ -2255,6 +2258,7 @@ mod tests { }; let mut psbt_out = Default::default(); der_desc.update_change_psbt_out(&mut psbt_out); + assert_eq!(psbt_out.witness_script.is_some(), expect_witness_script); psbt.unsigned_tx.output.push(txo); psbt.outputs.push(psbt_out); let indexes = desc.change_indexes(&psbt, secp); From e96c8b3165275be51d215d65259bb2c6d0d742d8 Mon Sep 17 00:00:00 2001 From: BitcoinQnA Date: Thu, 13 Aug 2026 11:19:25 +0100 Subject: [PATCH 2/2] Harden air-gap transport and release integration Keep address verification on the QR transport implemented by the signer, add the Linux bindgen release dependency, document protocol provenance and physical validation status, and remove unrelated window-setting churn. --- contrib/reproducible/guix/manifest.scm | 2 + doc/passport-airgap-protocol.md | 79 ++++++++++++--- doc/passport-user-guide.md | 17 ++-- liana-gui/src/airgap/payload.rs | 7 ++ liana-gui/src/airgap/ur.rs | 7 +- liana-gui/src/app/state/airgap.rs | 121 +++++++++++++++++------ liana-gui/src/window.rs | 4 +- liana-gui/test_assets/passport/README.md | 6 ++ 8 files changed, 184 insertions(+), 59 deletions(-) diff --git a/contrib/reproducible/guix/manifest.scm b/contrib/reproducible/guix/manifest.scm index 3c08d91109..be741f7fc4 100644 --- a/contrib/reproducible/guix/manifest.scm +++ b/contrib/reproducible/guix/manifest.scm @@ -516,6 +516,8 @@ "coreutils-minimal" "patchelf" "gcc-toolchain" + ;; v4l2-sys-mit generates the Linux camera bindings with bindgen. + "clang-toolchain" "pkg-config" "eudev" "fontconfig")) diff --git a/doc/passport-airgap-protocol.md b/doc/passport-airgap-protocol.md index 121790f49c..30ea2e0738 100644 --- a/doc/passport-airgap-protocol.md +++ b/doc/passport-airgap-protocol.md @@ -14,12 +14,13 @@ an `async-hwi` USB device. | --- | --- | --- | | Passport account import | `ur:crypto-account` | UTF-8 descriptor key | | Wallet-policy registration | `ur:bytes` containing UTF-8 JSON | UTF-8 JSON | -| Address-verification request/response | `ur:bytes` containing UTF-8 JSON | UTF-8 JSON | +| Address-verification request/response | `ur:bytes` containing UTF-8 JSON | Not supported | | PSBT request/response | `ur:crypto-psbt` | binary PSBT | BC-UR uses bytewords and fountain encoding. Single-part and multipart values carry the same registry CBOR. `bytes` and `crypto-psbt` wrap their value in a -CBOR byte string. `crypto-account` uses the BCR-2020-015 account map and the +CBOR byte string. For compatibility with Passport exports, `crypto-account` +uses the legacy BCR-2020-015 account map (superseded by BCR-2023-019) and the following deliberately narrow profile: - one top-level master fingerprint; @@ -29,7 +30,7 @@ following deliberately narrow profile: - matching Bitcoin network in `crypto-coin-info` and the origin coin type; - no child derivation expression and no private key material. -The current Passport microSD fallback is one line: +The Passport account-import microSD fallback is one line: ```text [fingerprint/48'/coin_type'/account'/2']xpub-or-tpub @@ -80,6 +81,9 @@ checksum. No second descriptor hash is introduced. ## Address verification +Address verification is QR-only. The reference signer does not expose a +file-based request/response workflow for this operation. + Request: ```json @@ -126,7 +130,7 @@ new signatures for keys expected by the wallet. ## Decoder resource limits The default limits intentionally match Passport Core's own UR decoder where -possible: +possible. They are interoperability limits, not general BC-UR limits: | Resource | Limit | | --- | ---: | @@ -154,7 +158,7 @@ success, cancellation, timeout, and error. They are downstream consumers of this module and may apply smaller limits, never larger ones without a protocol review. -The QR ceiling is the shared Passport decoder limit, not a PSBT-format limit. +The QR ceiling is the Passport Core decoder limit, not a PSBT-format limit. When a PSBT cannot fit, Liana rejects QR presentation before generating an unscannable sequence and keeps the bounded binary microSD workflow available. @@ -207,16 +211,61 @@ and the transitive dependency graph remain locked in `Cargo.lock`. | Dependency | License | Scope | Reason | | --- | --- | --- | --- | -| `foundation-ur` | MIT | all desktop targets | BC-UR bytewords and fountain encoding/decoding | -| `minicbor` | BlueOak-1.0.0 | all desktop targets | bounded registry-CBOR parsing and encoding | -| `nokhwa` | Apache-2.0 | all desktop targets | camera enumeration, permission handling, and native Windows/Linux capture | -| `quircs` | MIT | all desktop targets | fast in-memory QR detection and decoding | -| `rxing` | Apache-2.0 | all desktop targets | robust inverted and difficult-image QR fallback | -| `zeroize` | Apache-2.0 OR MIT | all desktop targets | overwrite owned animated PSBT QR strings on release | -| `nokhwa-bindings-macos` | Apache-2.0 | macOS only | negotiated AVFoundation capture | -| `flume` | Apache-2.0 OR MIT | macOS only | AVFoundation callback transport | -| `objc` | MIT | macOS only | two typed AVFoundation session-preset messages | -| `qrcode` | MIT OR Apache-2.0 | tests only | deterministic synthetic camera frames | +| [`foundation-ur` 0.4.0](https://github.com/Foundation-Devices/foundation-rs) | MIT | all desktop targets | BC-UR bytewords and fountain encoding/decoding | +| [`minicbor` 0.24.4](https://crates.io/crates/minicbor/0.24.4) | BlueOak-1.0.0 | all desktop targets | bounded registry-CBOR parsing and encoding | +| [`nokhwa` 0.10.11](https://crates.io/crates/nokhwa/0.10.11) | Apache-2.0 | all desktop targets | camera enumeration, permission handling, and native Windows/Linux capture | +| [`quircs` 0.10.3](https://crates.io/crates/quircs/0.10.3) | MIT | all desktop targets | fast in-memory QR detection and decoding | +| [`rxing` 0.9.2](https://crates.io/crates/rxing/0.9.2) | Apache-2.0 | all desktop targets | robust inverted and difficult-image QR fallback | +| [`zeroize` 1.8.1](https://crates.io/crates/zeroize/1.8.1) | Apache-2.0 OR MIT | all desktop targets | overwrite owned animated PSBT QR strings on release | +| [`nokhwa-bindings-macos` 0.2.4](https://crates.io/crates/nokhwa-bindings-macos/0.2.4) | Apache-2.0 | macOS only | negotiated AVFoundation capture | +| [`flume` 0.11.1](https://crates.io/crates/flume/0.11.1) | Apache-2.0 OR MIT | macOS only | AVFoundation callback transport | +| [`objc` 0.2.7](https://crates.io/crates/objc/0.2.7) | MIT | macOS only | two typed AVFoundation session-preset messages | +| [`qrcode` 0.14.1](https://crates.io/crates/qrcode/0.14.1) | MIT OR Apache-2.0 | tests only | deterministic synthetic camera frames | + +## Specifications and compatibility status + +The wire formats build on the following published specifications: + +- [ISO/IEC 18004:2024](https://www.iso.org/standard/83389.html) for QR symbols; +- [RFC 8949](https://www.rfc-editor.org/rfc/rfc8949.html) for CBOR; +- [BIP 174](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) + and [BIP 371](https://github.com/bitcoin/bips/blob/master/bip-0371.mediawiki) + for PSBT; +- [BIP 48](https://github.com/bitcoin/bips/blob/master/bip-0048.mediawiki) + for multisig account derivation and + [BIP 388](https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki) + for wallet-policy terminology; +- Blockchain Commons' [UR v2](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md), + [registry types](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md), + [HD key](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-007-hdkey.md), + [Bytewords](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-012-bytewords.md), + legacy [crypto-account](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-015-account.md), + legacy [crypto-psbt](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2021-001-request.md), + and [multipart UR](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2024-001-multipart-ur.md) + research specifications. Blockchain Commons [explicitly describes + BCRs](https://github.com/BlockchainCommons/Research) as interoperability + research rather than formal standards; the two legacy types are retained + only for compatibility with current Passport firmware. + +The `passport-wallet-policy` and `passport-address-verification` JSON envelopes, +including `policy_id`, are Foundation-specific protocols defined completely in +this document and locked by public fixtures. They are not BIPs or Blockchain +Commons registry types. + +Camera capture uses the operating-system APIs behind Nokhwa: +[AVFoundation](https://developer.apple.com/documentation/avfoundation/setting-up-a-capture-session) +on macOS, [Media Foundation](https://learn.microsoft.com/en-us/windows/win32/medfound/audio-video-capture-in-media-foundation) +on Windows, and [V4L2](https://docs.kernel.org/userspace-api/media/v4l/v4l2.html) +on Linux. + +Automated tests cover encoding, decoding, resource limits, policy identity, +response binding, PSBT invariants, scanner progress, and scanner lifecycle. +The complete physical workflow and built-in camera have been exercised on +Passport Core and macOS. Passport Prime protocol compatibility is implemented +but has not yet been exercised on physical hardware. Windows and Linux use the +same scanner state machine and decoders and are compile/CI targets, but their +native camera backends still require physical runtime testing before this +feature can be described as validated on those platforms. ## Threat model diff --git a/doc/passport-user-guide.md b/doc/passport-user-guide.md index 53cf55c830..c570bb4875 100644 --- a/doc/passport-user-guide.md +++ b/doc/passport-user-guide.md @@ -1,8 +1,10 @@ # Using Passport with Liana -Liana supports Passport Core and Passport Prime as air-gapped signers. The -workflow uses animated QR codes or microSD and never requires USB, copying an -xpub, or editing a descriptor by hand. +Liana supports compatible air-gapped signers through animated QR codes and +microSD without requiring USB, copying an xpub, or editing a descriptor by +hand. Passport Core is the physically validated reference device. Passport +Prime support uses the same protocol but still requires validation on a +physical Prime before release. ## Add a Passport key @@ -47,10 +49,10 @@ policy to be registered again. 1. Reveal a receive address in Liana and select **Verify**. 2. Select the configured Passport. -3. Show the request as an animated QR or save it to microSD. +3. Show the request as an animated QR. 4. Passport looks up the registered policy, independently derives the selected branch and index, and displays the complete address. -5. Compare the address and return Passport's confirmation by QR or microSD. +5. Compare the address and return Passport's confirmation by QR. Liana accepts the confirmation only when the network, policy identity, checksum, branch, index, address, and Passport fingerprint all match. @@ -83,8 +85,9 @@ Animated QR codes reveal the public wallet policy or transaction details to anyone who can see them. Use microSD in environments where displaying those details is inappropriate. -If camera access is denied or unavailable, use the microSD actions on the same -screen. If a Passport is replaced or restored with a different seed or +If camera access is denied or unavailable, use the microSD actions where they +are offered. Address verification is QR-only. If a Passport is replaced or +restored with a different seed or passphrase, import its account again and verify the full master fingerprint and BIP48 xpub before registering the policy. A restored Passport with the same seed and passphrase can reuse the public account key, but the wallet policy must diff --git a/liana-gui/src/airgap/payload.rs b/liana-gui/src/airgap/payload.rs index 4d671a4bd9..399767cca6 100644 --- a/liana-gui/src/airgap/payload.rs +++ b/liana-gui/src/airgap/payload.rs @@ -239,6 +239,13 @@ impl AirgappedRequest { Self::SignPsbt(_) => Some(ExpectedResponse::SignedPsbt), } } + + /// Whether the signer-side workflow supports exchanging this request by + /// file. Address verification is QR-only: unlike policy registration and + /// PSBT signing, the reference signer does not expose a file-based flow. + pub fn supports_file_transport(&self) -> bool { + !matches!(self, Self::VerifyAddress(_)) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/liana-gui/src/airgap/ur.rs b/liana-gui/src/airgap/ur.rs index 23e24cf2c3..a25e615568 100644 --- a/liana-gui/src/airgap/ur.rs +++ b/liana-gui/src/airgap/ur.rs @@ -6,9 +6,10 @@ use liana::miniscript::bitcoin::psbt::Psbt; use super::Error; -// Passport Core and Passport Prime share Foundation's bounded BC-UR decoder. -// Keep the encoded registry message within the device's 24 KiB ceiling; larger -// binary PSBTs remain available through the bounded microSD path. +// Match Passport Core's bounded BC-UR decoder. Keep the encoded registry +// message within the device's 24 KiB ceiling; larger binary PSBTs remain +// available through the bounded microSD path. Other compatible signers may +// impose smaller limits. const PASSPORT_MAX_UR_MESSAGE_BYTES: usize = 24 * 1024; const PASSPORT_MAX_UR_FRAGMENTS: u32 = 128; diff --git a/liana-gui/src/app/state/airgap.rs b/liana-gui/src/app/state/airgap.rs index f7a9a05731..6848023a82 100644 --- a/liana-gui/src/app/state/airgap.rs +++ b/liana-gui/src/app/state/airgap.rs @@ -75,8 +75,9 @@ enum Phase { Done, } -/// Reusable QR/microSD exchange state for registration, address verification, -/// and PSBT signing. It owns and releases the camera and animated QR material. +/// Reusable QR exchange state for registration, address verification, and PSBT +/// signing, with file transport where the signer workflow supports it. It owns +/// and releases the camera and animated QR material. pub struct AirgapModal { title: String, request: AirgappedRequest, @@ -178,6 +179,10 @@ impl AirgapModal { } } AirgapAction::ExportFile => { + if !self.request.supports_file_transport() { + self.error = Some("This exchange supports QR codes only".to_owned()); + return Task::none(); + } let filename = self.filename.clone(); let bytes = match self.request_file_bytes() { Ok(bytes) => bytes, @@ -212,6 +217,10 @@ impl AirgapModal { Err(error) => self.error = Some(error), }, AirgapAction::ImportResponse => { + if !self.request.supports_file_transport() { + self.error = Some("This exchange supports QR codes only".to_owned()); + return Task::none(); + } let Some(expected) = self.expected else { return Task::none(); }; @@ -304,20 +313,28 @@ impl AirgapModal { body = body.push(card::error("Air-gapped signer", error.clone())); } body = match self.phase { - Phase::Choose => body - .push(p1_regular( - "Choose how to exchange this request with your air-gapped signer.", - )) - .push( - button::primary(None, "Show animated QR code") - .width(Length::Fill) - .on_press(msg(AirgapAction::ShowQr)), - ) - .push( - button::secondary(None, "Export to microSD") - .width(Length::Fill) - .on_press(msg(AirgapAction::ExportFile)), - ), + Phase::Choose => { + let body = body + .push(p1_regular(if self.request.supports_file_transport() { + "Choose how to exchange this request with your air-gapped signer." + } else { + "Exchange this request with your air-gapped signer using QR codes." + })) + .push( + button::primary(None, "Show animated QR code") + .width(Length::Fill) + .on_press(msg(AirgapAction::ShowQr)), + ); + if self.request.supports_file_transport() { + body.push( + button::secondary(None, "Export to microSD") + .width(Length::Fill) + .on_press(msg(AirgapAction::ExportFile)), + ) + } else { + body + } + } Phase::DisplayQr => { let mut controls = Column::new().spacing(12).align_x(Horizontal::Center); if let Some(data) = &self.qr_data { @@ -442,12 +459,14 @@ impl AirgapModal { ), ] .spacing(10), - ) - .push( + ); + if self.request.supports_file_transport() { + controls = controls.push( button::secondary(None, "Use microSD instead") .width(Length::Fill) .on_press(msg(AirgapAction::ExportFile)), ); + } self.response_buttons(controls, msg) } @@ -463,16 +482,20 @@ impl AirgapModal { .on_press(msg(AirgapAction::Finish)), ); } - body.push( + let body = body.push( button::primary(None, "Scan signer response") .width(Length::Fill) .on_press(msg(AirgapAction::ScanResponse)), - ) - .push( - button::secondary(None, "Import response from microSD") - .width(Length::Fill) - .on_press(msg(AirgapAction::ImportResponse)), - ) + ); + if self.request.supports_file_transport() { + body.push( + button::secondary(None, "Import response from microSD") + .width(Length::Fill) + .on_press(msg(AirgapAction::ImportResponse)), + ) + } else { + body + } } fn refresh_qr(&mut self) { @@ -504,15 +527,23 @@ impl AirgapModal { continue; } self.error = Some( - "This request needs too many animated QR frames. Export it to microSD instead." - .to_owned(), + if self.request.supports_file_transport() { + "This request needs too many animated QR frames. Export it to microSD instead." + } else { + "This request needs too many animated QR frames." + } + .to_owned(), ); return; } Err(crate::airgap::Error::PayloadTooLarge { .. }) => { self.error = Some( - "This request is too large for animated QR. Export it to microSD instead." - .to_owned(), + if self.request.supports_file_transport() { + "This request is too large for animated QR. Export it to microSD instead." + } else { + "This request is too large for animated QR." + } + .to_owned(), ); return; } @@ -534,8 +565,12 @@ impl AirgapModal { } Err(crate::airgap::Error::TooManyFragments { .. }) => { self.error = Some( - "This request needs too many frames at that density. Choose a denser setting or use microSD." - .to_owned(), + if self.request.supports_file_transport() { + "This request needs too many frames at that density. Choose a denser setting or use microSD." + } else { + "This request needs too many frames at that density. Choose a denser setting." + } + .to_owned(), ); } Err(error) => self.error = Some(error.to_string()), @@ -711,7 +746,7 @@ impl Drop for AirgapModal { mod tests { use std::{fs, time::SystemTime}; - use crate::airgap::PolicyRegistration; + use crate::airgap::{AddressVerificationRequest, PolicyRegistration}; use super::*; @@ -793,4 +828,26 @@ mod tests { assert_eq!(modal.phase, Phase::Choose); assert!(modal.error.is_none()); } + + #[test] + fn address_verification_rejects_file_transport() { + let registration = PolicyRegistration::from_json(include_bytes!( + "../../../test_assets/passport/policy-registration-mainnet.json" + )) + .unwrap(); + let request = AddressVerificationRequest::new(®istration, 0, 0).unwrap(); + let mut modal = AirgapModal::new( + "Verify address", + AirgappedRequest::VerifyAddress(request), + "liana-address-0.json", + ); + + let _ = modal.update(AirgapAction::ExportFile); + + assert_eq!(modal.phase, Phase::Choose); + assert_eq!( + modal.error.as_deref(), + Some("This exchange supports QR codes only") + ); + } } diff --git a/liana-gui/src/window.rs b/liana-gui/src/window.rs index 6176cb1bee..85cf6ec8d2 100644 --- a/liana-gui/src/window.rs +++ b/liana-gui/src/window.rs @@ -34,7 +34,7 @@ pub fn load_initial_size(liana_directory: &LianaDirectory, default_size: Option< /// Create iced window Settings. #[allow(unused_mut)] -pub fn create_window_settings(_app_id: &str, initial_size: Size) -> iced::window::Settings { +pub fn create_window_settings(app_id: &str, initial_size: Size) -> iced::window::Settings { let mut window_settings = iced::window::Settings { size: initial_size, icon: Some(image::liana_app_icon()), @@ -50,7 +50,7 @@ pub fn create_window_settings(_app_id: &str, initial_size: Size) -> iced::window #[cfg(target_os = "linux")] { window_settings.platform_specific = PlatformSpecific { - application_id: _app_id.to_string(), + application_id: app_id.to_string(), ..Default::default() }; } diff --git a/liana-gui/test_assets/passport/README.md b/liana-gui/test_assets/passport/README.md index 6461315681..6b23ce7795 100644 --- a/liana-gui/test_assets/passport/README.md +++ b/liana-gui/test_assets/passport/README.md @@ -4,6 +4,12 @@ These deterministic, public-only fixtures lock Passport air-gap protocol v1. They contain no private key material and must remain stable unless the protocol version changes. +The account decoder intentionally accepts Passport's legacy BCR-2020-015 +`crypto-account` profile; newer Blockchain Commons account types are not +silently treated as equivalent. The policy, verification, and identity JSON +fixtures cover Foundation-specific envelopes documented in +`doc/passport-airgap-protocol.md`. + | Fixture | Purpose | | --- | --- | | `account-mainnet.txt` | Mainnet BIP48 native-SegWit microSD key export |