app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 2e4a2948abec948b662200583023fcf3c2f0008d
parent 1ba84c50a23ce2fef1c269ce8d2beec87242dd4a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 21:00:02 +0000

remote-signer: add app local bunker session crate

- add a dedicated app-local crate for bunker and discovery url parsing
- add pending and active remote signer session store models with json persistence helpers
- add blocking nip-46 connect and get-public-key polling helpers for native launchers
- cover the new parser and session store boundary with focused unit tests

Diffstat:
MCargo.lock | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 7+++++++
Acrates/remote-signer/Cargo.toml | 28++++++++++++++++++++++++++++
Acrates/remote-signer/src/error.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/remote-signer/src/input.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/remote-signer/src/lib.rs | 20++++++++++++++++++++
Acrates/remote-signer/src/protocol.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/remote-signer/src/session.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1208 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -45,6 +45,17 @@ dependencies = [ ] [[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] name = "ahash" version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -197,6 +208,43 @@ dependencies = [ ] [[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -695,7 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -706,6 +754,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] name = "dbus" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -960,6 +1014,12 @@ dependencies = [ ] [[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] name = "emath" version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1173,12 +1233,48 @@ dependencies = [ ] [[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1190,8 +1286,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1266,6 +1366,18 @@ dependencies = [ ] [[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "glow" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1486,6 +1598,22 @@ dependencies = [ ] [[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1900,6 +2028,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + +[[package]] name = "malloc_buf" version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2050,6 +2184,12 @@ dependencies = [ ] [[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2071,6 +2211,7 @@ version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" dependencies = [ + "aes", "base64 0.22.1", "bech32", "bip39", @@ -2103,6 +2244,59 @@ dependencies = [ ] [[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2563,7 +2757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2916,6 +3110,22 @@ dependencies = [ ] [[package]] +name = "radroots-app-remote-signer" +version = "0.1.0" +dependencies = [ + "nostr", + "radroots-app-test-support", + "radroots-identity", + "radroots-nostr", + "radroots-nostr-connect", + "serde", + "serde_json", + "tempfile", + "tokio", + "url", +] + +[[package]] name = "radroots-app-test-support" version = "0.1.0" dependencies = [ @@ -2988,6 +3198,18 @@ dependencies = [ ] [[package]] +name = "radroots-nostr" +version = "0.1.0-alpha.1" +dependencies = [ + "nostr", + "nostr-sdk", + "radroots-identity", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] name = "radroots-nostr-accounts" version = "0.1.0-alpha.1" dependencies = [ @@ -3051,8 +3273,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3062,7 +3294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3075,6 +3317,15 @@ dependencies = [ ] [[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] name = "range-alloc" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3160,6 +3411,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] name = "ron" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3274,6 +3539,40 @@ dependencies = [ ] [[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3340,7 +3639,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3449,6 +3748,17 @@ dependencies = [ ] [[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3600,6 +3910,16 @@ dependencies = [ ] [[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3847,10 +4167,12 @@ version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -3867,6 +4189,44 @@ dependencies = [ ] [[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] name = "toml" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4006,6 +4366,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] name = "type-map" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4070,6 +4449,12 @@ dependencies = [ ] [[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4083,6 +4468,12 @@ dependencies = [ ] [[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4416,6 +4807,24 @@ dependencies = [ ] [[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/core", "crates/desktop", "crates/ios", + "crates/remote-signer", "crates/test-support", "crates/web", ] @@ -35,7 +36,13 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = ["s radroots-app-apple-security = { path = "crates/apple/security" } radroots-geocoder = { path = "../lib/crates/geocoder" } radroots-identity = { path = "../lib/crates/identity", default-features = false, features = ["std", "nip49"] } +radroots-nostr = { path = "../lib/crates/nostr", default-features = false, features = ["std", "client"] } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] } +radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +tokio = { version = "1.48.0", features = ["rt", "sync", "time"] } +url = "2.5.7" wasm-bindgen-futures = "0.4.50" web-sys = { version = "0.3.91", features = ["Document", "HtmlCanvasElement", "Window"] } wgpu = { version = "27.0.1", default-features = false } diff --git a/crates/remote-signer/Cargo.toml b/crates/remote-signer/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "radroots-app-remote-signer" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots app local remote signer session support" +publish = false + +[lints] +workspace = true + +[dependencies] +nostr = { workspace = true, features = ["nip44"] } +radroots-identity.workspace = true +radroots-nostr.workspace = true +radroots-nostr-connect.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[dev-dependencies] +radroots-app-test-support = { path = "../test-support" } +tempfile = "3.23.0" diff --git a/crates/remote-signer/src/error.rs b/crates/remote-signer/src/error.rs @@ -0,0 +1,64 @@ +use radroots_nostr_connect::prelude::{RadrootsNostrConnectError, RadrootsNostrConnectMethod}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsAppRemoteSignerError { + EmptyInput, + UnsupportedClientUri, + MissingDiscoveryUri, + InvalidDiscoveryUrl(String), + InvalidBunkerUri(String), + InvalidSessionStore(String), + SessionStoreIo(String), + PendingSessionExists, + MissingClientSecret, + ConnectFailed(String), + RequestTimedOut { + method: RadrootsNostrConnectMethod, + }, + UnexpectedResponse { + method: RadrootsNostrConnectMethod, + response: String, + }, +} + +impl fmt::Display for RadrootsAppRemoteSignerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyInput => f.write_str("enter a bunker or discovery url to continue"), + Self::UnsupportedClientUri => f.write_str( + "enter a bunker or discovery url from the signer; raw nostrconnect client uris are signer-side only", + ), + Self::MissingDiscoveryUri => { + f.write_str("discovery url does not contain a remote signer uri") + } + Self::InvalidDiscoveryUrl(reason) => { + write!(f, "invalid discovery url: {reason}") + } + Self::InvalidBunkerUri(reason) => { + write!(f, "invalid remote signer uri: {reason}") + } + Self::InvalidSessionStore(reason) => write!(f, "invalid remote signer store: {reason}"), + Self::SessionStoreIo(reason) => write!(f, "remote signer storage failed: {reason}"), + Self::PendingSessionExists => { + f.write_str("a remote signer connection is already pending approval") + } + Self::MissingClientSecret => f.write_str("remote signer session secret is missing"), + Self::ConnectFailed(reason) => write!(f, "remote signer connection failed: {reason}"), + Self::RequestTimedOut { method } => { + write!(f, "remote signer request `{method}` timed out") + } + Self::UnexpectedResponse { method, response } => { + write!(f, "remote signer returned an unexpected `{method}` response: {response}") + } + } + } +} + +impl std::error::Error for RadrootsAppRemoteSignerError {} + +impl From<RadrootsNostrConnectError> for RadrootsAppRemoteSignerError { + fn from(value: RadrootsNostrConnectError) -> Self { + Self::InvalidBunkerUri(value.to_string()) + } +} diff --git a/crates/remote-signer/src/input.rs b/crates/remote-signer/src/input.rs @@ -0,0 +1,136 @@ +use crate::error::RadrootsAppRemoteSignerError; +use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::RadrootsNostrConnectUri; +use radroots_nostr_connect::uri::RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME; +use url::Url; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppRemoteSignerSource { + BunkerUri, + DiscoveryUrl, +} + +#[derive(Debug, Clone)] +pub struct RadrootsAppRemoteSignerTarget { + pub source: RadrootsAppRemoteSignerSource, + pub signer_identity: RadrootsIdentityPublic, + pub relays: Vec<String>, + pub connect_secret: Option<String>, +} + +impl RadrootsAppRemoteSignerTarget { + pub fn source_label(&self) -> &'static str { + match self.source { + RadrootsAppRemoteSignerSource::BunkerUri => "bunker uri", + RadrootsAppRemoteSignerSource::DiscoveryUrl => "discovery url", + } + } +} + +pub fn radroots_app_remote_signer_preview( + input: &str, +) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(RadrootsAppRemoteSignerError::EmptyInput); + } + + if trimmed.starts_with(&format!("{RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME}://")) { + return parse_bunker_uri(trimmed, RadrootsAppRemoteSignerSource::BunkerUri); + } + + if trimmed.starts_with("nostrconnect://") { + return Err(RadrootsAppRemoteSignerError::UnsupportedClientUri); + } + + parse_discovery_url(trimmed) +} + +fn parse_discovery_url( + value: &str, +) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> { + let url = Url::parse(value) + .map_err(|error| RadrootsAppRemoteSignerError::InvalidDiscoveryUrl(error.to_string()))?; + let Some((_, bunker_uri)) = url.query_pairs().find(|(key, _)| key == "uri") else { + return Err(RadrootsAppRemoteSignerError::MissingDiscoveryUri); + }; + parse_bunker_uri( + bunker_uri.as_ref(), + RadrootsAppRemoteSignerSource::DiscoveryUrl, + ) +} + +fn parse_bunker_uri( + value: &str, + source: RadrootsAppRemoteSignerSource, +) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> { + let uri = RadrootsNostrConnectUri::parse(value)?; + let RadrootsNostrConnectUri::Bunker(bunker_uri) = uri else { + return Err(RadrootsAppRemoteSignerError::UnsupportedClientUri); + }; + Ok(RadrootsAppRemoteSignerTarget { + source, + signer_identity: RadrootsIdentityPublic::new(bunker_uri.remote_signer_public_key), + relays: bunker_uri + .relays + .into_iter() + .map(|relay| relay.to_string()) + .collect(), + connect_secret: bunker_uri.secret, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_test_support::{FIXTURE_ALICE, RELAY_PRIMARY_WSS}; + + fn bunker_uri() -> String { + format!( + "bunker://{}?relay={}", + FIXTURE_ALICE.npub, + urlencoding(RELAY_PRIMARY_WSS) + ) + } + + fn discovery_url() -> String { + format!( + "http://localhost/connect?uri={}", + urlencoding(bunker_uri().as_str()) + ) + } + + fn urlencoding(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() + } + + #[test] + fn parses_direct_bunker_uri() { + let preview = radroots_app_remote_signer_preview(bunker_uri().as_str()).expect("preview"); + + assert_eq!(preview.source, RadrootsAppRemoteSignerSource::BunkerUri); + assert_eq!(preview.signer_identity.public_key_npub, FIXTURE_ALICE.npub); + assert_eq!(preview.relays, vec![RELAY_PRIMARY_WSS.to_owned()]); + assert_eq!(preview.connect_secret, None); + } + + #[test] + fn parses_discovery_url_with_bunker_uri() { + let preview = + radroots_app_remote_signer_preview(discovery_url().as_str()).expect("preview"); + + assert_eq!(preview.source, RadrootsAppRemoteSignerSource::DiscoveryUrl); + assert_eq!(preview.signer_identity.public_key_npub, FIXTURE_ALICE.npub); + assert_eq!(preview.relays, vec![RELAY_PRIMARY_WSS.to_owned()]); + } + + #[test] + fn rejects_client_side_nostrconnect_uri_input() { + let err = radroots_app_remote_signer_preview( + "nostrconnect://npub1test?relay=wss%3A%2F%2Frelay.example.com&secret=test", + ) + .expect_err("client uri rejected"); + + assert_eq!(err, RadrootsAppRemoteSignerError::UnsupportedClientUri); + } +} diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] + +mod error; +mod input; +mod protocol; +mod session; + +pub use error::RadrootsAppRemoteSignerError; +pub use input::{ + RadrootsAppRemoteSignerSource, RadrootsAppRemoteSignerTarget, + radroots_app_remote_signer_preview, +}; +pub use protocol::{ + RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession, + radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session, +}; +pub use session::{ + RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, RadrootsAppRemoteSignerSessionRecord, + RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreState, +}; diff --git a/crates/remote-signer/src/protocol.rs b/crates/remote-signer/src/protocol.rs @@ -0,0 +1,290 @@ +use crate::error::RadrootsAppRemoteSignerError; +use crate::input::{RadrootsAppRemoteSignerTarget, radroots_app_remote_signer_preview}; +use crate::session::RadrootsAppRemoteSignerSessionRecord; +use nostr::nips::nip44; +use nostr::nips::nip44::Version; +use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; +use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, + RadrootsNostrKind, RadrootsNostrRelayPoolNotification, RadrootsNostrTag, + RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_kind, +}; +use radroots_nostr_connect::message::RADROOTS_NOSTR_CONNECT_RPC_KIND; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, + RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, +}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::runtime::Builder; +use tokio::sync::broadcast; +use tokio::time::timeout; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const GET_PUBLIC_KEY_TIMEOUT: Duration = Duration::from_secs(10); + +static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct RadrootsAppRemoteSignerPendingSession { + pub record: RadrootsAppRemoteSignerSessionRecord, + pub client_secret_key_hex: String, +} + +#[derive(Debug, Clone)] +pub enum RadrootsAppRemoteSignerPendingPollOutcome { + PendingApproval, + Approved(RadrootsIdentityPublic), + RetryableError { message: String }, + FatalError { message: String }, +} + +pub fn radroots_app_remote_signer_connect_pending( + input: &str, +) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> { + let target = radroots_app_remote_signer_preview(input)?; + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + runtime.block_on(connect_pending_session(target)) +} + +pub fn radroots_app_remote_signer_poll_pending_session( + record: &RadrootsAppRemoteSignerSessionRecord, + client_secret_key_hex: &str, +) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + runtime.block_on(poll_pending_session(record, client_secret_key_hex)) +} + +async fn connect_pending_session( + target: RadrootsAppRemoteSignerTarget, +) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> { + let client_identity = RadrootsIdentity::generate(); + let response = execute_request( + &client_identity, + &target, + RadrootsNostrConnectMethod::Connect, + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: parse_public_key_hex( + target.signer_identity.public_key_hex.as_str(), + )?, + secret: target.connect_secret.clone(), + requested_permissions: Default::default(), + }, + CONNECT_TIMEOUT, + ) + .await?; + + match response { + RadrootsNostrConnectResponse::ConnectAcknowledged + | RadrootsNostrConnectResponse::ConnectSecretEcho(_) => { + Ok(RadrootsAppRemoteSignerPendingSession { + record: RadrootsAppRemoteSignerSessionRecord::pending( + client_identity.to_public(), + target.signer_identity, + target.relays, + ), + client_secret_key_hex: client_identity.secret_key_hex(), + }) + } + other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse { + method: RadrootsNostrConnectMethod::Connect, + response: format!("{other:?}"), + }), + } +} + +async fn poll_pending_session( + record: &RadrootsAppRemoteSignerSessionRecord, + client_secret_key_hex: &str, +) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> { + let client_identity = RadrootsIdentity::from_secret_key_str(client_secret_key_hex) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let target = RadrootsAppRemoteSignerTarget { + source: crate::RadrootsAppRemoteSignerSource::BunkerUri, + signer_identity: record.signer_identity.clone(), + relays: record.relays.clone(), + connect_secret: None, + }; + + match execute_request( + &client_identity, + &target, + RadrootsNostrConnectMethod::GetPublicKey, + RadrootsNostrConnectRequest::GetPublicKey, + GET_PUBLIC_KEY_TIMEOUT, + ) + .await + { + Ok(RadrootsNostrConnectResponse::UserPublicKey(public_key)) => { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved( + RadrootsIdentityPublic::new(public_key), + )) + } + Ok(RadrootsNostrConnectResponse::Error { error, .. }) => { + if error.contains("pending") { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) + } else { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message: error }) + } + } + Ok(other) => Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { + message: format!("unexpected remote signer response: {other:?}"), + }), + Err(RadrootsAppRemoteSignerError::RequestTimedOut { .. }) => { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { + message: "remote signer did not respond yet".to_owned(), + }) + } + Err(error) => Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { + message: error.to_string(), + }), + } +} + +async fn execute_request( + client_identity: &RadrootsIdentity, + target: &RadrootsAppRemoteSignerTarget, + method: RadrootsNostrConnectMethod, + request: RadrootsNostrConnectRequest, + request_timeout: Duration, +) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> { + let client = RadrootsNostrClient::from_identity(client_identity); + for relay in &target.relays { + client + .add_relay(relay) + .await + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + } + client.connect().await; + + let filter = radroots_nostr_filter_tag( + RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) + .since(RadrootsNostrTimestamp::now()), + "p", + vec![client_identity.public_key_hex()], + ) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let mut notifications = client.notifications(); + client + .subscribe(filter, None) + .await + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + + let request_id = next_request_id(method.to_string().as_str()); + let event_builder = build_request_event( + client_identity, + &target.signer_identity, + request_id.as_str(), + request.clone(), + )?; + client + .send_event_builder(event_builder) + .await + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + + let response_method = method.clone(); + let response = timeout(request_timeout, async move { + loop { + let notification = match notifications.recv().await { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(RadrootsAppRemoteSignerError::ConnectFailed( + "remote signer notification stream closed".to_owned(), + )); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = *event; + if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + continue; + } + if event.pubkey.to_hex() != target.signer_identity.public_key_hex { + continue; + } + match parse_response_event( + client_identity, + &event, + &response_method, + request_id.as_str(), + )? { + Some(response) => return Ok(response), + None => continue, + } + } + }) + .await + .map_err(|_| RadrootsAppRemoteSignerError::RequestTimedOut { + method: method.clone(), + })??; + + Ok(response) +} + +fn build_request_event( + client_identity: &RadrootsIdentity, + signer_identity: &RadrootsIdentityPublic, + request_id: &str, + request: RadrootsNostrConnectRequest, +) -> Result<RadrootsNostrEventBuilder, RadrootsAppRemoteSignerError> { + let payload = serde_json::to_string(&RadrootsNostrConnectRequestMessage::new( + request_id.to_owned(), + request, + )) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let signer_public_key = parse_public_key_hex(signer_identity.public_key_hex.as_str())?; + let ciphertext = nip44::encrypt( + client_identity.keys().secret_key(), + &signer_public_key, + payload, + Version::V2, + ) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + Ok(RadrootsNostrEventBuilder::new( + radroots_nostr_kind(RADROOTS_NOSTR_CONNECT_RPC_KIND), + ciphertext, + ) + .tags(vec![RadrootsNostrTag::public_key(signer_public_key)])) +} + +fn parse_response_event( + client_identity: &RadrootsIdentity, + event: &RadrootsNostrEvent, + method: &RadrootsNostrConnectMethod, + request_id: &str, +) -> Result<Option<RadrootsNostrConnectResponse>, RadrootsAppRemoteSignerError> { + let decrypted = nip44::decrypt( + client_identity.keys().secret_key(), + &event.pubkey, + &event.content, + ) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let envelope: RadrootsNostrConnectResponseEnvelope = serde_json::from_str(&decrypted) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + if envelope.id != request_id { + return Ok(None); + } + let response = RadrootsNostrConnectResponse::from_envelope(method, envelope) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + Ok(Some(response)) +} + +fn next_request_id(prefix: &str) -> String { + let tick = REQUEST_COUNTER.fetch_add(1, Ordering::AcqRel); + format!("{prefix}-{tick}") +} + +fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsAppRemoteSignerError> { + nostr::PublicKey::parse(value) + .or_else(|_| nostr::PublicKey::from_hex(value)) + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string())) +} diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs @@ -0,0 +1,248 @@ +use crate::error::RadrootsAppRemoteSignerError; +use radroots_identity::RadrootsIdentityPublic; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsAppRemoteSignerSessionStatus { + PendingApproval, + Active, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadrootsAppRemoteSignerSessionRecord { + pub client_identity: RadrootsIdentityPublic, + pub signer_identity: RadrootsIdentityPublic, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_identity: Option<RadrootsIdentityPublic>, + pub relays: Vec<String>, + pub status: RadrootsAppRemoteSignerSessionStatus, + pub created_at_unix: u64, + pub updated_at_unix: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadrootsAppRemoteSignerSessionStoreState { + pub version: u32, + pub sessions: Vec<RadrootsAppRemoteSignerSessionRecord>, +} + +impl Default for RadrootsAppRemoteSignerSessionStoreState { + fn default() -> Self { + Self { + version: RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, + sessions: Vec::new(), + } + } +} + +impl RadrootsAppRemoteSignerSessionRecord { + pub fn pending( + client_identity: RadrootsIdentityPublic, + signer_identity: RadrootsIdentityPublic, + relays: Vec<String>, + ) -> Self { + let now = now_unix_secs(); + Self { + client_identity, + signer_identity, + user_identity: None, + relays, + status: RadrootsAppRemoteSignerSessionStatus::PendingApproval, + created_at_unix: now, + updated_at_unix: now, + } + } + + pub fn account_id(&self) -> Option<&str> { + self.user_identity + .as_ref() + .map(|identity| identity.id.as_str()) + } + + pub fn client_account_id(&self) -> &str { + self.client_identity.id.as_str() + } +} + +impl RadrootsAppRemoteSignerSessionStoreState { + pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> { + match std::fs::read_to_string(path) { + Ok(contents) => { + let state: Self = serde_json::from_str(&contents).map_err(|error| { + RadrootsAppRemoteSignerError::InvalidSessionStore(error.to_string()) + })?; + if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION { + return Err(RadrootsAppRemoteSignerError::InvalidSessionStore(format!( + "unsupported schema version {}", + state.version + ))); + } + Ok(state) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo( + error.to_string(), + )), + } + } + + pub fn save(&self, path: &Path) -> Result<(), RadrootsAppRemoteSignerError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; + } + let json = serde_json::to_string_pretty(self) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; + std::fs::write(path, json) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string())) + } + + pub fn pending_session(&self) -> Option<&RadrootsAppRemoteSignerSessionRecord> { + self.sessions + .iter() + .find(|record| record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval) + } + + pub fn active_session_for_account_id( + &self, + account_id: &str, + ) -> Option<&RadrootsAppRemoteSignerSessionRecord> { + self.sessions.iter().find(|record| { + record.status == RadrootsAppRemoteSignerSessionStatus::Active + && record.account_id() == Some(account_id) + }) + } + + pub fn upsert_pending( + &mut self, + pending: RadrootsAppRemoteSignerSessionRecord, + ) -> Result<(), RadrootsAppRemoteSignerError> { + if self.pending_session().is_some() { + return Err(RadrootsAppRemoteSignerError::PendingSessionExists); + } + self.sessions + .retain(|record| record.client_account_id() != pending.client_account_id()); + self.sessions.push(pending); + Ok(()) + } + + pub fn activate_session( + &mut self, + client_account_id: &str, + user_identity: RadrootsIdentityPublic, + ) -> Option<RadrootsAppRemoteSignerSessionRecord> { + let now = now_unix_secs(); + self.sessions.retain(|record| { + !(record.status == RadrootsAppRemoteSignerSessionStatus::Active + && record.account_id() == Some(user_identity.id.as_str())) + }); + let record = self + .sessions + .iter_mut() + .find(|record| record.client_account_id() == client_account_id)?; + record.user_identity = Some(user_identity); + record.status = RadrootsAppRemoteSignerSessionStatus::Active; + record.updated_at_unix = now; + Some(record.clone()) + } + + pub fn remove_pending_session(&mut self) -> Option<RadrootsAppRemoteSignerSessionRecord> { + let index = self.sessions.iter().position(|record| { + record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval + })?; + Some(self.sessions.remove(index)) + } + + pub fn remove_active_session_for_account_id( + &mut self, + account_id: &str, + ) -> Option<RadrootsAppRemoteSignerSessionRecord> { + let index = self.sessions.iter().position(|record| { + record.status == RadrootsAppRemoteSignerSessionStatus::Active + && record.account_id() == Some(account_id) + })?; + Some(self.sessions.remove(index)) + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB, fixture_identity}; + + fn fixture_public( + label: &radroots_app_test_support::RadrootsAppApprovedFixtureIdentity, + ) -> RadrootsIdentityPublic { + fixture_identity(label).expect("identity").to_public() + } + + fn pending_record() -> RadrootsAppRemoteSignerSessionRecord { + RadrootsAppRemoteSignerSessionRecord::pending( + fixture_public(&FIXTURE_ALICE), + fixture_public(&FIXTURE_BOB), + vec!["wss://relay.example.com".to_owned()], + ) + } + + #[test] + fn pending_store_round_trips() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); + state.upsert_pending(pending_record()).expect("pending"); + state.save(path.as_path()).expect("save"); + + let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); + + assert_eq!(loaded.sessions.len(), 1); + assert_eq!( + loaded.sessions[0].status, + RadrootsAppRemoteSignerSessionStatus::PendingApproval + ); + } + + #[test] + fn activate_session_replaces_pending_with_active_user_identity() { + let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); + let pending = pending_record(); + let client_account_id = pending.client_account_id().to_owned(); + state.upsert_pending(pending).expect("pending"); + + let alice_public = fixture_public(&FIXTURE_ALICE); + let active = state + .activate_session(client_account_id.as_str(), alice_public.clone()) + .expect("active"); + + assert_eq!(active.status, RadrootsAppRemoteSignerSessionStatus::Active); + assert_eq!(active.account_id(), Some(alice_public.id.as_str())); + assert!(state.pending_session().is_none()); + } + + #[test] + fn remove_active_session_matches_user_account_id() { + let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); + let pending = pending_record(); + let client_account_id = pending.client_account_id().to_owned(); + state.upsert_pending(pending).expect("pending"); + let alice_public = fixture_public(&FIXTURE_ALICE); + state.activate_session(client_account_id.as_str(), alice_public.clone()); + + let removed = state + .remove_active_session_for_account_id(alice_public.id.as_str()) + .expect("removed"); + + assert_eq!(removed.account_id(), Some(alice_public.id.as_str())); + assert!(state.sessions.is_empty()); + } +}