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/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 new file mode 100644 index 0000000000..30ea2e0738 --- /dev/null +++ b/doc/passport-airgap-protocol.md @@ -0,0 +1,279 @@ +# 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 | 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. 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; +- 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 Passport account-import 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 + +Address verification is QR-only. The reference signer does not expose a +file-based request/response workflow for this operation. + +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. They are interoperability limits, not general BC-UR limits: + +| 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 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. + +## 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` 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 + +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..c570bb4875 --- /dev/null +++ b/doc/passport-user-guide.md @@ -0,0 +1,94 @@ +# Using Passport with Liana + +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 + +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. +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. + +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 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 +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..399767cca6 --- /dev/null +++ b/liana-gui/src/airgap/payload.rs @@ -0,0 +1,976 @@ +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), + } + } + + /// 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)] +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..a25e615568 --- /dev/null +++ b/liana-gui/src/airgap/ur.rs @@ -0,0 +1,243 @@ +use foundation_ur::{ + bytewords::{self, Style}, + Encoder, UR, +}; +use liana::miniscript::bitcoin::psbt::Psbt; + +use super::Error; + +// 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; + +// 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..6848023a82 --- /dev/null +++ b/liana-gui/src/app/state/airgap.rs @@ -0,0 +1,853 @@ +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 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, + 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 => { + 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, + 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 => { + 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(); + }; + 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 => { + 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 { + 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), + ); + 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) + } + + 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)), + ); + } + let body = body.push( + button::primary(None, "Scan signer response") + .width(Length::Fill) + .on_press(msg(AirgapAction::ScanResponse)), + ); + 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) { + 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( + 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( + 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; + } + 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( + 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()), + } + } + + 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::{AddressVerificationRequest, 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()); + } + + #[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/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/test_assets/passport/README.md b/liana-gui/test_assets/passport/README.md new file mode 100644 index 0000000000..6b23ce7795 --- /dev/null +++ b/liana-gui/test_assets/passport/README.md @@ -0,0 +1,30 @@ +# 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. + +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 | +| `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);