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:
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());
+ }
+}