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