commit 3d3f43afffa0aa1acb25fadbd68f6910501d244b
parent 5f0bc868701ae6fe97b749d9924df66077c1e41e
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 22:08:55 +0000
provider: remove obsolete signer stack
Diffstat:
26 files changed, 123 insertions(+), 17558 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -71,6 +71,12 @@
exact canonical bytes, and returns only a sealed redacted result.
Verification is not publication and performs no provider execution or
database mutation.
+- Step 136 removes the orphaned prototype provider tree and its keyring,
+ managed-account, plaintext/adjacent-key, child-process, implicit-identity,
+ generic remote-session, legacy logging/client, and unused dependency
+ surfaces. Do not restore those files, dependencies, or Tokio process
+ capability; `radroots_nostr_connect` remains only as the active NIP-46
+ protocol dependency.
- Treat checked-in source, tests, and prototype behavior as implementation
evidence, not permission to preserve behavior that the active requirement
removes.
diff --git a/Cargo.lock b/Cargo.lock
@@ -115,37 +115,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[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 = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -155,12 +124,6 @@ dependencies = [
]
[[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"
@@ -173,54 +136,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
-name = "axum"
-version = "0.8.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
-dependencies = [
- "axum-core",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-util",
- "itoa",
- "matchit",
- "memchr",
- "mime",
- "percent-encoding",
- "pin-project-lite",
- "serde_core",
- "serde_json",
- "serde_path_to_error",
- "sync_wrapper",
- "tokio",
- "tower",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
-name = "axum-core"
-version = "0.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "http-body-util",
- "mime",
- "pin-project-lite",
- "sync_wrapper",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -330,12 +245,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -463,32 +372,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
-name = "core-foundation"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "core-foundation"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -513,15 +396,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
-name = "crossbeam-channel"
-version = "0.5.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
name = "crossbeam-queue"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -543,7 +417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
"zeroize",
]
@@ -555,7 +429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
- "rand_core 0.6.4",
+ "rand_core",
"typenum",
]
@@ -566,28 +440,6 @@ 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"
-checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
-dependencies = [
- "libc",
- "libdbus-sys",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "dbus-secret-service"
-version = "4.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
-dependencies = [
- "dbus",
- "openssl",
- "zeroize",
-]
-
-[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -598,15 +450,6 @@ dependencies = [
]
[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "powerfmt",
-]
-
-[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -654,7 +497,7 @@ dependencies = [
"ff",
"generic-array",
"group",
- "rand_core 0.6.4",
+ "rand_core",
"sec1",
"subtle",
"zeroize",
@@ -719,7 +562,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -764,21 +607,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
-name = "foreign-types"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-dependencies = [
- "foreign-types-shared",
-]
-
-[[package]]
-name = "foreign-types-shared"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
-[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -958,25 +786,13 @@ 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 = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -1128,7 +944,6 @@ dependencies = [
"hyper",
"pin-project-lite",
"tokio",
- "tower-service",
]
[[package]]
@@ -1308,8 +1123,6 @@ version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
- "cfg-if",
- "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1377,23 +1190,6 @@ dependencies = [
]
[[package]]
-name = "keyring"
-version = "3.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
-dependencies = [
- "byteorder",
- "dbus-secret-service",
- "linux-keyutils",
- "log",
- "openssl",
- "security-framework 2.11.1",
- "security-framework 3.7.0",
- "windows-sys 0.60.2",
- "zeroize",
-]
-
-[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1412,16 +1208,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
-name = "libdbus-sys"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
-dependencies = [
- "cc",
- "pkg-config",
-]
-
-[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1433,16 +1219,6 @@ dependencies = [
]
[[package]]
-name = "linux-keyutils"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
-dependencies = [
- "bitflags",
- "libc",
-]
-
-[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1470,27 +1246,6 @@ 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 = "matchers"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
-dependencies = [
- "regex-automata",
-]
-
-[[package]]
-name = "matchit"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
-
-[[package]]
name = "mediatype"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1509,12 +1264,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1529,54 +1278,33 @@ dependencies = [
name = "myc"
version = "0.1.0"
dependencies = [
- "axum",
"base64",
"chacha20poly1305",
"clap",
"futures-executor",
- "futures-util",
"hex",
"jsonschema",
- "keyring",
"nostr",
- "nostr-sdk",
- "radroots_event",
- "radroots_identity",
"radroots_nostr",
"radroots_nostr_connect",
"radroots_runtime_paths",
"radroots_secrets",
"radroots_service_host",
"radroots_service_sqlite",
- "radroots_signing",
"radroots_storage",
- "rand 0.9.2",
"rustix",
"serde",
"serde_json",
- "serial_test",
"sha2",
"sqlx",
"tempfile",
- "thiserror 2.0.18",
"tokio",
- "tokio-tungstenite",
"toml",
- "tracing",
- "tracing-appender",
- "tracing-subscriber",
"url",
- "uuid",
"zeroize",
]
[[package]]
-name = "negentropy"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
-
-[[package]]
name = "nostr"
version = "0.44.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1603,68 +1331,6 @@ 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"
-checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1704,12 +1370,6 @@ dependencies = [
]
[[package]]
-name = "num-conv"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
-
-[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1767,54 +1427,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
-name = "openssl"
-version = "0.10.76"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
-dependencies = [
- "bitflags",
- "cfg-if",
- "foreign-types",
- "libc",
- "once_cell",
- "openssl-macros",
- "openssl-sys",
-]
-
-[[package]]
-name = "openssl-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "openssl-src"
-version = "300.6.0+3.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "openssl-sys"
-version = "0.9.112"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
-dependencies = [
- "cc",
- "libc",
- "openssl-src",
- "pkg-config",
- "vcpkg",
-]
-
-[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1856,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -1909,12 +1521,6 @@ dependencies = [
]
[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2082,7 +1688,6 @@ version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
dependencies = [
"chacha20poly1305",
- "keyring",
"serde",
"sha2",
"subtle",
@@ -2129,38 +1734,24 @@ dependencies = [
]
[[package]]
-name = "radroots_signing"
+name = "radroots_storage"
version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
dependencies = [
- "hex",
"radroots_event",
"radroots_event_codec",
- "radroots_identity",
"radroots_protocol",
- "serde",
+ "radroots_trade",
+ "radroots_transport",
"sha2",
]
[[package]]
-name = "radroots_storage"
+name = "radroots_trade"
version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
dependencies = [
- "radroots_event",
- "radroots_event_codec",
- "radroots_protocol",
- "radroots_trade",
- "radroots_transport",
- "sha2",
-]
-
-[[package]]
-name = "radroots_trade"
-version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
-dependencies = [
- "radroots_core",
+ "radroots_core",
"radroots_event",
"radroots_identity",
]
@@ -2183,18 +1774,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
- "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",
+ "rand_chacha",
+ "rand_core",
]
[[package]]
@@ -2204,17 +1785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "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",
+ "rand_core",
]
[[package]]
@@ -2227,15 +1798,6 @@ 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 = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2311,20 +1873,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[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 = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2350,40 +1898,6 @@ 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"
@@ -2435,7 +1949,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
- "rand 0.8.5",
+ "rand",
"secp256k1-sys",
"serde",
]
@@ -2450,42 +1964,6 @@ dependencies = [
]
[[package]]
-name = "security-framework"
-version = "2.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-dependencies = [
- "bitflags",
- "core-foundation 0.9.4",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
-[[package]]
-name = "security-framework"
-version = "3.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
-dependencies = [
- "bitflags",
- "core-foundation 0.10.1",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
-[[package]]
-name = "security-framework-sys"
-version = "2.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2535,17 +2013,6 @@ dependencies = [
]
[[package]]
-name = "serde_path_to_error"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
-dependencies = [
- "itoa",
- "serde",
- "serde_core",
-]
-
-[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2555,42 +2022,6 @@ dependencies = [
]
[[package]]
-name = "serial_test"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
-dependencies = [
- "futures-executor",
- "futures-util",
- "log",
- "once_cell",
- "parking_lot",
- "serial_test_derive",
-]
-
-[[package]]
-name = "serial_test_derive"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[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"
@@ -2602,31 +2033,12 @@ dependencies = [
]
[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
-dependencies = [
- "errno",
- "libc",
-]
-
-[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2823,12 +2235,6 @@ dependencies = [
]
[[package]]
-name = "sync_wrapper"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
-
-[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2893,46 +2299,6 @@ dependencies = [
]
[[package]]
-name = "thread_local"
-version = "1.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "time"
-version = "0.3.47"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
-dependencies = [
- "deranged",
- "itoa",
- "num-conv",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
-
-[[package]]
-name = "time-macros"
-version = "0.2.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
-[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2967,7 +2333,6 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
- "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -2985,28 +2350,6 @@ 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-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3018,22 +2361,6 @@ dependencies = [
]
[[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 = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3089,33 +2416,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
-name = "tower"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
-dependencies = [
- "futures-core",
- "futures-util",
- "pin-project-lite",
- "sync_wrapper",
- "tokio",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
-name = "tower-layer"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3128,18 +2428,6 @@ dependencies = [
]
[[package]]
-name = "tracing-appender"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
-dependencies = [
- "crossbeam-channel",
- "thiserror 2.0.18",
- "time",
- "tracing-subscriber",
-]
-
-[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3157,36 +2445,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
-dependencies = [
- "matchers",
- "nu-ansi-term",
- "once_cell",
- "regex-automata",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing",
- "tracing-core",
- "tracing-log",
]
[[package]]
@@ -3196,25 +2454,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[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 = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3264,12 +2503,6 @@ 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"
@@ -3295,12 +2528,6 @@ 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"
@@ -3313,18 +2540,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
-name = "uuid"
-version = "1.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
-dependencies = [
- "getrandom 0.4.2",
- "js-sys",
- "serde_core",
- "wasm-bindgen",
-]
-
-[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3335,12 +2550,6 @@ dependencies = [
]
[[package]]
-name = "valuable"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
-
-[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3406,16 +2615,6 @@ dependencies = [
]
[[package]]
-name = "wasm-bindgen-futures"
-version = "0.4.67"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
name = "wasm-bindgen-macro"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3492,24 +2691,6 @@ 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 = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3543,25 +2724,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -3579,31 +2742,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -3613,96 +2759,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
-[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
-[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
-[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
-[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
-[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
-[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
-[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
-[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3874,20 +2972,6 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
-dependencies = [
- "zeroize_derive",
-]
-
-[[package]]
-name = "zeroize_derive"
-version = "1.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
[[package]]
name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
@@ -30,44 +30,29 @@ default = ["service-host"]
service-host = []
[dependencies]
-axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
base64 = "0.22"
chacha20poly1305 = "0.10"
clap = { version = "4.5", features = ["derive"] }
futures-executor = "0.3"
hex = "0.4"
jsonschema = { version = "0.48.1", default-features = false }
-keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service"] }
nostr = { version = "0.44.2", features = ["nip04", "nip44", "nip46", "nip49"] }
-nostr-sdk = { version = "0.44.1" }
-radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
-radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["serde"] }
radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] }
radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
-radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] }
-radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] }
+radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] }
radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
sqlx = { version = "0.9.0", default-features = false, features = ["derive", "sqlite-bundled"] }
-rand = "0.9"
rustix = { version = "1", features = ["fs", "process", "std"] }
-thiserror = "2.0"
-tempfile = "3.17"
-tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] }
-tracing = "0.1"
-tracing-appender = "0.2"
-tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
toml = "0.8"
url = "2.5"
-uuid = { version = "1.18", features = ["serde", "v7"] }
zeroize = "1.8"
[dev-dependencies]
-futures-util = "0.3.32"
-serial_test = "3"
-tokio-tungstenite = "0.26.2"
+tempfile = "3.17"
diff --git a/README b/README
@@ -131,6 +131,14 @@ known. NIP-44 v2 plaintext is admitted only through its exact 65,408-byte
implementation ceiling. Verified output remains sealed and redacted, and
neither verification nor signing constitutes publication.
+The obsolete prototype provider tree has been removed. Myc ships no account
+keyring, managed-account selector, plaintext or adjacent-key file adapter,
+child-process signer, implicit host identity, generic remote-session signer,
+or legacy logging/client wrapper. Its removed production dependencies and
+Tokio process capability are absent from the locked graph; the active typed
+provider contract, encrypted envelope, credential resolver, local-signer
+transport, and independent verifier are the only provider boundaries.
+
Signer-request admission validates bounded client, request, event, method,
canonical request, injected operation entropy, and injected time evidence
before storage. Stable domain-separated operation and correlation identities
diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml
@@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e
version = "0.1.0-alpha"
source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379"
lockfile = "Cargo.lock"
-lockfile_sha256 = "07cc49b4ef22923aadf33b2d6f245f7c84db9df9bd666df9a838dd401d7e418c"
+lockfile_sha256 = "04f566ee4c444c81002f090ac77e61b77000e8aeb1a337ae38447e2f0913a3b9"
diff --git a/src/accounts.rs b/src/accounts.rs
@@ -1,420 +0,0 @@
-//! Myc-owned managed-account persistence and secret custody.
-//!
-//! Public account values come from `radroots_identity`; selection, persistence,
-//! keyring access, and secret-bearing Nostr keys remain owned by the service.
-
-use std::path::{Path, PathBuf};
-use std::sync::{Arc, RwLock};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use nostr::{Keys, SecretKey};
-use radroots_identity::account::{Record, Status};
-use radroots_identity::{AccountId, PublicIdentity, PublicKey};
-use serde::{Deserialize, Serialize};
-use thiserror::Error;
-use zeroize::Zeroizing;
-
-const STORE_VERSION: u32 = 1;
-
-#[derive(Debug, Error)]
-pub enum AccountsError {
- #[error("identity error: {0}")]
- Identity(String),
- #[error("store error: {0}")]
- Store(String),
- #[error("vault error: {0}")]
- Vault(String),
- #[error("account not found: {0}")]
- AccountNotFound(String),
- #[error("invalid account state: {0}")]
- InvalidState(String),
- #[error("public key does not match secret key")]
- PublicKeyMismatch,
-}
-
-#[derive(Debug, Error)]
-pub enum SecretVaultError {
- #[error("secret backend failed")]
- Backend,
-}
-
-pub trait SecretVault: Send + Sync {
- fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError>;
- fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError>;
- fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError>;
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct MemorySecretVault {
- entries: Arc<RwLock<std::collections::BTreeMap<String, String>>>,
-}
-
-impl MemorySecretVault {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-impl SecretVault for MemorySecretVault {
- fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError> {
- self.entries
- .write()
- .map_err(|_| SecretVaultError::Backend)?
- .insert(slot.to_owned(), secret.to_owned());
- Ok(())
- }
-
- fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError> {
- Ok(self
- .entries
- .read()
- .map_err(|_| SecretVaultError::Backend)?
- .get(slot)
- .cloned())
- }
-
- fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError> {
- self.entries
- .write()
- .map_err(|_| SecretVaultError::Backend)?
- .remove(slot);
- Ok(())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct OsKeyringSecretVault {
- service_name: String,
-}
-
-impl OsKeyringSecretVault {
- pub fn new(service_name: impl Into<String>) -> Self {
- Self {
- service_name: service_name.into(),
- }
- }
-
- fn entry(&self, slot: &str) -> Result<keyring::Entry, SecretVaultError> {
- keyring::Entry::new(self.service_name.as_str(), slot).map_err(|_| SecretVaultError::Backend)
- }
-}
-
-impl SecretVault for OsKeyringSecretVault {
- fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError> {
- self.entry(slot)?
- .set_password(secret)
- .map_err(|_| SecretVaultError::Backend)
- }
-
- fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError> {
- match self.entry(slot)?.get_password() {
- Ok(secret) => Ok(Some(secret)),
- Err(keyring::Error::NoEntry) => Ok(None),
- Err(_) => Err(SecretVaultError::Backend),
- }
- }
-
- fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError> {
- match self.entry(slot)?.delete_credential() {
- Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
- Err(_) => Err(SecretVaultError::Backend),
- }
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct AccountStoreState {
- version: u32,
- default_account_id: Option<AccountId>,
- accounts: Vec<Record>,
-}
-
-impl Default for AccountStoreState {
- fn default() -> Self {
- Self {
- version: STORE_VERSION,
- default_account_id: None,
- accounts: Vec::new(),
- }
- }
-}
-
-pub trait AccountStore: Send + Sync {
- fn load(&self) -> Result<AccountStoreState, AccountsError>;
- fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError>;
-}
-
-#[derive(Debug, Clone)]
-pub struct FileAccountStore {
- path: PathBuf,
-}
-
-impl FileAccountStore {
- pub fn new(path: impl AsRef<Path>) -> Self {
- Self {
- path: path.as_ref().to_path_buf(),
- }
- }
-}
-
-impl AccountStore for FileAccountStore {
- fn load(&self) -> Result<AccountStoreState, AccountsError> {
- if !self.path.exists() {
- return Ok(AccountStoreState::default());
- }
- let bytes =
- std::fs::read(&self.path).map_err(|_| AccountsError::Store("read failed".into()))?;
- serde_json::from_slice(&bytes).map_err(|_| AccountsError::Store("invalid JSON".into()))
- }
-
- fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError> {
- if let Some(parent) = self.path.parent() {
- std::fs::create_dir_all(parent)
- .map_err(|_| AccountsError::Store("create directory failed".into()))?;
- }
- let bytes = serde_json::to_vec_pretty(state)
- .map_err(|_| AccountsError::Store("serialization failed".into()))?;
- let temporary = self.path.with_extension("json.tmp");
- std::fs::write(&temporary, bytes)
- .map_err(|_| AccountsError::Store("write failed".into()))?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))
- .map_err(|_| AccountsError::Store("permission update failed".into()))?;
- }
- std::fs::rename(&temporary, &self.path)
- .map_err(|_| AccountsError::Store("atomic replace failed".into()))
- }
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct MemoryAccountStore {
- state: Arc<RwLock<AccountStoreState>>,
-}
-
-impl MemoryAccountStore {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-impl AccountStore for MemoryAccountStore {
- fn load(&self) -> Result<AccountStoreState, AccountsError> {
- self.state
- .read()
- .map(|state| state.clone())
- .map_err(|_| AccountsError::Store("memory store lock poisoned".into()))
- }
-
- fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError> {
- *self
- .state
- .write()
- .map_err(|_| AccountsError::Store("memory store lock poisoned".into()))? =
- state.clone();
- Ok(())
- }
-}
-
-#[derive(Clone)]
-pub struct AccountsManager {
- store: Arc<dyn AccountStore>,
- vault: Arc<dyn SecretVault>,
- state: Arc<RwLock<AccountStoreState>>,
-}
-
-impl AccountsManager {
- pub fn new(
- store: Arc<dyn AccountStore>,
- vault: Arc<dyn SecretVault>,
- ) -> Result<Self, AccountsError> {
- let state = store.load()?;
- if state.version != STORE_VERSION {
- return Err(AccountsError::InvalidState(
- "unsupported account store version".into(),
- ));
- }
- Ok(Self {
- store,
- vault,
- state: Arc::new(RwLock::new(state)),
- })
- }
-
- pub fn new_file_backed_with_vault(
- path: impl AsRef<Path>,
- vault: impl SecretVault + 'static,
- ) -> Result<Self, AccountsError> {
- Self::new(Arc::new(FileAccountStore::new(path)), Arc::new(vault))
- }
-
- pub fn default_account(&self) -> Result<Option<Record>, AccountsError> {
- let state = self.read_state()?;
- Ok(state.default_account_id.and_then(|id| {
- state
- .accounts
- .iter()
- .find(|account| account.id() == id)
- .cloned()
- }))
- }
-
- pub fn default_account_id(&self) -> Result<Option<AccountId>, AccountsError> {
- Ok(self.read_state()?.default_account_id)
- }
-
- pub fn list_accounts(&self) -> Result<Vec<Record>, AccountsError> {
- Ok(self.read_state()?.accounts.clone())
- }
-
- pub fn default_account_status(&self) -> Result<Status, AccountsError> {
- let Some(account) = self.default_account()? else {
- return Ok(Status::NotConfigured);
- };
- if self.vault.load_secret(&account.id().to_string())?.is_some() {
- Ok(Status::Ready { account })
- } else {
- Ok(Status::PublicOnly { account })
- }
- }
-
- pub fn default_signing_keys(&self) -> Result<Option<Keys>, AccountsError> {
- let Some(account) = self.default_account()? else {
- return Ok(None);
- };
- let Some(secret) = self.vault.load_secret(&account.id().to_string())? else {
- return Ok(None);
- };
- let secret = Zeroizing::new(secret);
- let key = SecretKey::parse(secret.as_str())
- .map_err(|_| AccountsError::InvalidState("invalid stored secret".into()))?;
- let keys = Keys::new(key);
- if keys.public_key().to_hex() != account.public_identity().public_key().to_hex() {
- return Err(AccountsError::PublicKeyMismatch);
- }
- Ok(Some(keys))
- }
-
- pub fn upsert_keys(
- &self,
- keys: &Keys,
- label: Option<String>,
- make_default: bool,
- ) -> Result<AccountId, AccountsError> {
- let public_key = PublicKey::from_hex(&keys.public_key().to_hex())
- .map_err(|error| AccountsError::Identity(error.to_string()))?;
- let public_identity = PublicIdentity::new(public_key);
- let account_id = AccountId::from_public_identity(&public_identity);
- let secret = Zeroizing::new(keys.secret_key().to_secret_hex());
- self.vault
- .store_secret(&account_id.to_string(), secret.as_str())?;
- self.update_state(|state| {
- let now = now_unix_secs();
- if let Some(record) = state
- .accounts
- .iter_mut()
- .find(|record| record.id() == account_id)
- {
- let created = record.created_at_unix();
- *record = Record::try_from_parts(
- account_id,
- public_identity.clone(),
- label.clone(),
- created,
- now,
- )
- .map_err(|error| AccountsError::Identity(error.to_string()))?;
- } else {
- state
- .accounts
- .push(Record::new(public_identity, label.clone(), now));
- }
- if state.default_account_id.is_none() || make_default {
- state.default_account_id = Some(account_id);
- }
- Ok(())
- })?;
- Ok(account_id)
- }
-
- pub fn generate_keys(
- &self,
- label: Option<String>,
- make_default: bool,
- ) -> Result<AccountId, AccountsError> {
- self.upsert_keys(&Keys::generate(), label, make_default)
- }
-
- pub fn set_default_account(&self, account_id: &AccountId) -> Result<(), AccountsError> {
- self.update_state(|state| {
- if !state
- .accounts
- .iter()
- .any(|record| record.id() == *account_id)
- {
- return Err(AccountsError::AccountNotFound(account_id.to_string()));
- }
- state.default_account_id = Some(*account_id);
- Ok(())
- })
- }
-
- pub fn remove_account(&self, account_id: &AccountId) -> Result<(), AccountsError> {
- self.update_state(|state| {
- let before = state.accounts.len();
- state.accounts.retain(|record| record.id() != *account_id);
- if before == state.accounts.len() {
- return Err(AccountsError::AccountNotFound(account_id.to_string()));
- }
- if state.default_account_id == Some(*account_id) {
- state.default_account_id = None;
- }
- Ok(())
- })?;
- self.vault.remove_secret(&account_id.to_string())?;
- Ok(())
- }
-
- fn read_state(
- &self,
- ) -> Result<std::sync::RwLockReadGuard<'_, AccountStoreState>, AccountsError> {
- self.state
- .read()
- .map_err(|_| AccountsError::Store("account state lock poisoned".into()))
- }
-
- fn update_state(
- &self,
- update: impl FnOnce(&mut AccountStoreState) -> Result<(), AccountsError>,
- ) -> Result<(), AccountsError> {
- let mut state = self
- .state
- .write()
- .map_err(|_| AccountsError::Store("account state lock poisoned".into()))?;
- let mut next = state.clone();
- update(&mut next)?;
- self.store.save(&next)?;
- *state = next;
- Ok(())
- }
-}
-
-impl From<SecretVaultError> for AccountsError {
- fn from(_: SecretVaultError) -> Self {
- Self::Vault("secret backend failed".into())
- }
-}
-
-fn now_unix_secs() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_or(0, |duration| duration.as_secs())
-}
-
-pub type RadrootsNostrAccountsManager = AccountsManager;
-pub type RadrootsNostrAccountsError = AccountsError;
-pub type RadrootsNostrMemoryAccountStore = MemoryAccountStore;
-pub type RadrootsNostrSecretVaultMemory = MemorySecretVault;
-pub type RadrootsSecretVaultOsKeyring = OsKeyringSecretVault;
-pub use SecretVault as RadrootsSecretVault;
diff --git a/src/custody.rs b/src/custody.rs
@@ -1,2863 +0,0 @@
-use std::fs;
-use std::path::Path;
-use std::path::PathBuf;
-use std::process::Stdio;
-use std::sync::Arc;
-use std::time::Duration;
-
-use crate::accounts::{
- RadrootsNostrAccountsManager, RadrootsSecretVault, RadrootsSecretVaultOsKeyring,
-};
-use crate::host_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
-use crate::nostr_contract::{
- RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrExternalSigningRequest,
- RadrootsNostrGenericEventBuilder, RadrootsNostrPublicKey,
-};
-use nostr::nips::nip44::Version;
-use nostr::nips::{nip04, nip44};
-use radroots_identity::account::{
- Record as RadrootsNostrAccountRecord, Status as RadrootsNostrAccountStatus,
-};
-use serde::{Deserialize, Serialize};
-use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
-use tokio::runtime::RuntimeFlavor;
-use tokio::time::Instant as TokioInstant;
-use zeroize::Zeroizing;
-
-use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec};
-use crate::error::MycError;
-use crate::identity_files::{
- load_encrypted_identity, load_identity_profile, rotate_encrypted_identity,
- store_encrypted_identity, store_identity_profile,
-};
-
-#[derive(Clone)]
-pub struct MycActiveIdentity {
- public_identity: RadrootsIdentityPublic,
- public_key: RadrootsNostrPublicKey,
- operations: Arc<dyn MycIdentityOperations>,
-}
-
-fn store_plaintext_identity(
- path: impl AsRef<Path>,
- identity: &RadrootsIdentity,
-) -> Result<(), MycError> {
- identity.save_json(path).map_err(MycError::from)
-}
-
-fn store_secret_text(path: impl AsRef<Path>, value: &str) -> Result<(), MycError> {
- let path = path.as_ref();
- if let Some(parent) = path.parent()
- && !parent.as_os_str().is_empty()
- {
- fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
- path: parent.to_path_buf(),
- source,
- })?;
- }
-
- fs::write(path, value).map_err(|source| MycError::PersistenceIo {
- path: path.to_path_buf(),
- source,
- })?;
- set_secret_permissions(path)?;
- Ok(())
-}
-
-fn set_secret_permissions(path: &Path) -> Result<(), MycError> {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
-
- let permissions = std::fs::Permissions::from_mode(0o600);
- fs::set_permissions(path, permissions).map_err(|source| MycError::PersistenceIo {
- path: path.to_path_buf(),
- source,
- })?;
- }
- Ok(())
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum MycManagedAccountSelectionState {
- NotConfigured,
- PublicOnly,
- Ready,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
-pub struct MycIdentityStatusOutput {
- pub backend: MycIdentityBackend,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub path: Option<PathBuf>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub keyring_account_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub keyring_service_name: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub profile_path: Option<PathBuf>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub inherited_from: Option<String>,
- pub resolved: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub selected_account_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub selected_account_label: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub selected_account_state: Option<MycManagedAccountSelectionState>,
- pub default_shared_secret_backend: MycIdentityBackend,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub runtime_specific_custody_modes: Vec<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub host_vault_policy: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub identity_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub public_key_hex: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub error: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize)]
-pub struct MycManagedAccountsOutput {
- pub role: String,
- pub backend: MycIdentityBackend,
- pub account_store_path: PathBuf,
- pub keyring_service_name: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub selected_account_id: Option<String>,
- pub selected_account_state: MycManagedAccountSelectionState,
- pub accounts: Vec<RadrootsNostrAccountRecord>,
-}
-
-#[derive(Debug, Clone, Serialize)]
-pub struct MycManagedAccountMutationOutput {
- pub role: String,
- pub action: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub account_id: Option<String>,
- pub state: MycManagedAccountsOutput,
-}
-
-#[derive(Debug, Clone, Serialize)]
-pub struct MycCustodyExportOutput {
- pub role: String,
- pub backend: MycIdentityBackend,
- pub format: String,
- pub out: PathBuf,
- pub identity_id: String,
- pub public_key_hex: String,
-}
-
-#[derive(Debug, Clone, Serialize)]
-pub struct MycCustodyImportOutput {
- pub role: String,
- pub backend: MycIdentityBackend,
- pub format: String,
- pub account_id: String,
- pub status: MycIdentityStatusOutput,
-}
-
-#[derive(Debug, Clone, Serialize)]
-pub struct MycCustodyRotateOutput {
- pub role: String,
- pub backend: MycIdentityBackend,
- pub action: String,
- pub status: MycIdentityStatusOutput,
-}
-
-const MYC_CUSTODY_FORMAT_NIP49: &str = "nip49";
-
-#[derive(Clone)]
-pub struct MycIdentityProvider {
- role: String,
- source: MycIdentitySourceSpec,
- backend: MycIdentityProviderBackend,
-}
-
-#[derive(Clone)]
-enum MycIdentityProviderBackend {
- EncryptedFile {
- path: PathBuf,
- },
- PlaintextFile {
- path: PathBuf,
- },
- HostVault {
- account_id: RadrootsIdentityId,
- service_name: String,
- profile_path: Option<PathBuf>,
- vault: Arc<dyn RadrootsSecretVault>,
- },
- ManagedAccount {
- account_store_path: PathBuf,
- service_name: String,
- manager: RadrootsNostrAccountsManager,
- },
- ExternalCommand {
- command_path: PathBuf,
- timeout: Duration,
- executor: Arc<dyn MycExternalCommandExecutor>,
- },
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum MycExternalCommandOperation {
- Describe,
- SignEvent,
- Nip04Encrypt,
- Nip04Decrypt,
- Nip44Encrypt,
- Nip44Decrypt,
-}
-
-#[derive(Serialize)]
-#[serde(untagged)]
-enum MycExternalCommandUnsignedEvent<'a> {
- CallerSupplied(&'a nostr::UnsignedEvent),
- CheckedProtocol(&'a RadrootsNostrExternalSigningRequest),
-}
-
-#[derive(Serialize)]
-struct MycExternalCommandRequest<'a> {
- version: u8,
- operation: MycExternalCommandOperation,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- unsigned_event: Option<MycExternalCommandUnsignedEvent<'a>>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- public_key_hex: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- content: Option<String>,
-}
-
-#[cfg(test)]
-#[derive(Debug, Clone, Deserialize)]
-struct MycExternalCommandRequestWire {
- version: u8,
- operation: MycExternalCommandOperation,
- #[serde(default)]
- unsigned_event: Option<nostr::UnsignedEvent>,
- #[serde(default)]
- public_key_hex: Option<String>,
- #[serde(default)]
- content: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct MycExternalCommandResponse {
- #[serde(default)]
- identity: Option<RadrootsIdentityPublic>,
- #[serde(default)]
- event: Option<nostr::Event>,
- #[serde(default)]
- content: Option<String>,
- #[serde(default)]
- error: Option<String>,
-}
-
-#[derive(Debug, Clone)]
-struct MycExternalCommandOutput {
- success: bool,
- status: Option<i32>,
- stdout: Vec<u8>,
- stderr: Vec<u8>,
-}
-
-#[derive(Debug)]
-enum MycExternalCommandExecuteError {
- Io(std::io::Error),
- TimedOut,
- OutputLimitExceeded {
- stream: &'static str,
- limit_bytes: usize,
- },
-}
-
-trait MycExternalCommandExecutor: Send + Sync {
- fn execute(
- &self,
- command_path: &Path,
- request_json: &[u8],
- timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError>;
-}
-
-#[derive(Debug, Default)]
-struct MycProcessCommandExecutor;
-
-const MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES: usize = 1024 * 1024;
-const MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES: usize = 64 * 1024;
-
-impl MycProcessCommandExecutor {
- fn execute_on_runtime(
- command_path: &Path,
- request_json: &[u8],
- timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- match tokio::runtime::Handle::try_current() {
- Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
- tokio::task::block_in_place(|| {
- handle.block_on(Self::execute_async(command_path, request_json, timeout))
- })
- }
- Ok(_) => std::thread::scope(|scope| {
- scope
- .spawn(|| Self::execute_on_new_runtime(command_path, request_json, timeout))
- .join()
- .map_err(|_| {
- MycExternalCommandExecuteError::Io(std::io::Error::other(
- "external custody command worker panicked",
- ))
- })?
- }),
- Err(_) => Self::execute_on_new_runtime(command_path, request_json, timeout),
- }
- }
-
- fn execute_on_new_runtime(
- command_path: &Path,
- request_json: &[u8],
- timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .map_err(MycExternalCommandExecuteError::Io)?
- .block_on(Self::execute_async(command_path, request_json, timeout))
- }
-
- async fn execute_async(
- command_path: &Path,
- request_json: &[u8],
- timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- let mut child = tokio::process::Command::new(command_path)
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .kill_on_drop(true)
- .spawn()
- .map_err(MycExternalCommandExecuteError::Io)?;
- let mut stdin = child.stdin.take().ok_or_else(|| {
- MycExternalCommandExecuteError::Io(std::io::Error::other(
- "external custody command stdin was unavailable",
- ))
- })?;
- let stdout = child.stdout.take().ok_or_else(|| {
- MycExternalCommandExecuteError::Io(std::io::Error::other(
- "external custody command stdout was unavailable",
- ))
- })?;
- let stderr = child.stderr.take().ok_or_else(|| {
- MycExternalCommandExecuteError::Io(std::io::Error::other(
- "external custody command stderr was unavailable",
- ))
- })?;
- let request_json = Zeroizing::new(request_json.to_vec());
- let mut stdin_task = tokio::spawn(async move {
- stdin
- .write_all(&request_json)
- .await
- .map_err(MycExternalCommandExecuteError::Io)?;
- stdin
- .shutdown()
- .await
- .map_err(MycExternalCommandExecuteError::Io)
- });
- let mut stdout_task = tokio::spawn(read_limited_external_command_output(
- stdout,
- "stdout",
- MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES,
- ));
- let mut stderr_task = tokio::spawn(read_limited_external_command_output(
- stderr,
- "stderr",
- MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES,
- ));
- let deadline = TokioInstant::now() + timeout;
-
- let status = match tokio::time::timeout_at(deadline, child.wait()).await {
- Ok(Ok(status)) => status,
- Ok(Err(source)) => {
- abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task);
- return Err(MycExternalCommandExecuteError::Io(source));
- }
- Err(_) => {
- let _ = child.kill().await;
- let _ = child.wait().await;
- abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task);
- return Err(MycExternalCommandExecuteError::TimedOut);
- }
- };
-
- let collect_output = async {
- let (stdin_result, stdout_result, stderr_result) =
- tokio::join!(&mut stdin_task, &mut stdout_task, &mut stderr_task);
- flatten_external_command_task(stdin_result)?;
- let stdout = flatten_external_command_task(stdout_result)?;
- let stderr = flatten_external_command_task(stderr_result)?;
- Ok((stdout, stderr))
- };
- let (stdout, stderr) = match tokio::time::timeout_at(deadline, collect_output).await {
- Ok(result) => result?,
- Err(_) => {
- abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task);
- return Err(MycExternalCommandExecuteError::TimedOut);
- }
- };
-
- Ok(MycExternalCommandOutput {
- success: status.success(),
- status: status.code(),
- stdout,
- stderr,
- })
- }
-}
-
-impl MycExternalCommandExecutor for MycProcessCommandExecutor {
- fn execute(
- &self,
- command_path: &Path,
- request_json: &[u8],
- timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- Self::execute_on_runtime(command_path, request_json, timeout)
- }
-}
-
-async fn read_limited_external_command_output<R>(
- mut reader: R,
- stream: &'static str,
- limit_bytes: usize,
-) -> Result<Vec<u8>, MycExternalCommandExecuteError>
-where
- R: AsyncRead + Unpin,
-{
- let mut output = Vec::with_capacity(limit_bytes.min(8 * 1024));
- let mut buffer = [0_u8; 8 * 1024];
- let mut exceeded_limit = false;
- loop {
- let count = reader
- .read(&mut buffer)
- .await
- .map_err(MycExternalCommandExecuteError::Io)?;
- if count == 0 {
- break;
- }
- let remaining = limit_bytes.saturating_sub(output.len());
- output.extend_from_slice(&buffer[..count.min(remaining)]);
- exceeded_limit |= count > remaining;
- }
- if exceeded_limit {
- return Err(MycExternalCommandExecuteError::OutputLimitExceeded {
- stream,
- limit_bytes,
- });
- }
- Ok(output)
-}
-
-fn flatten_external_command_task<T>(
- result: Result<Result<T, MycExternalCommandExecuteError>, tokio::task::JoinError>,
-) -> Result<T, MycExternalCommandExecuteError> {
- result.map_err(|_| {
- MycExternalCommandExecuteError::Io(std::io::Error::other(
- "external custody command I/O task failed",
- ))
- })?
-}
-
-fn abort_external_command_io_tasks<T, U, V>(
- stdin_task: &tokio::task::JoinHandle<T>,
- stdout_task: &tokio::task::JoinHandle<U>,
- stderr_task: &tokio::task::JoinHandle<V>,
-) {
- stdin_task.abort();
- stdout_task.abort();
- stderr_task.abort();
-}
-
-trait MycIdentityOperations: Send + Sync {
- fn nostr_client(&self) -> RadrootsNostrClient;
- fn nostr_client_owned(&self) -> RadrootsNostrClient;
- fn sign_protocol_event_builder(
- &self,
- builder: RadrootsNostrGenericEventBuilder,
- operation: &str,
- ) -> Result<RadrootsNostrEvent, MycError>;
- fn sign_unsigned_event(
- &self,
- unsigned_event: nostr::UnsignedEvent,
- operation: &str,
- ) -> Result<nostr::Event, MycError>;
- fn nip04_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError>;
- fn nip04_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError>;
- fn nip44_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError>;
- fn nip44_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError>;
-}
-
-struct MycLoadedIdentityOperations {
- identity: Arc<RadrootsIdentity>,
-}
-
-impl MycLoadedIdentityOperations {
- fn new(identity: RadrootsIdentity) -> Self {
- Self {
- identity: Arc::new(identity),
- }
- }
-}
-
-impl MycIdentityOperations for MycLoadedIdentityOperations {
- fn nostr_client(&self) -> RadrootsNostrClient {
- RadrootsNostrClient::from_identity(self.identity.as_ref())
- }
-
- fn nostr_client_owned(&self) -> RadrootsNostrClient {
- RadrootsNostrClient::from_identity_owned((*self.identity).clone())
- }
-
- fn sign_protocol_event_builder(
- &self,
- builder: RadrootsNostrGenericEventBuilder,
- operation: &str,
- ) -> Result<RadrootsNostrEvent, MycError> {
- builder
- .sign_with_keys(self.identity.keys())
- .map_err(|error| {
- MycError::InvalidOperation(format!("failed to sign {operation} event: {error}"))
- })
- }
-
- fn sign_unsigned_event(
- &self,
- unsigned_event: nostr::UnsignedEvent,
- operation: &str,
- ) -> Result<nostr::Event, MycError> {
- unsigned_event
- .sign_with_keys(self.identity.keys())
- .map_err(|error| {
- MycError::InvalidOperation(format!("failed to sign {operation}: {error}"))
- })
- }
-
- fn nip04_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError> {
- nip04::encrypt(self.identity.keys().secret_key(), public_key, plaintext)
- .map_err(|error| MycError::Nip46Encrypt(error.to_string()))
- }
-
- fn nip04_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError> {
- nip04::decrypt(self.identity.keys().secret_key(), public_key, ciphertext)
- .map_err(|error| MycError::Nip46Decrypt(error.to_string()))
- }
-
- fn nip44_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError> {
- nip44::encrypt(
- self.identity.keys().secret_key(),
- public_key,
- plaintext,
- Version::V2,
- )
- .map_err(|error| MycError::Nip46Encrypt(error.to_string()))
- }
-
- fn nip44_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError> {
- nip44::decrypt(self.identity.keys().secret_key(), public_key, ciphertext)
- .map_err(|error| MycError::Nip46Decrypt(error.to_string()))
- }
-}
-
-struct MycExternalCommandIdentityOperations {
- role: String,
- command_path: PathBuf,
- timeout: Duration,
- public_key: RadrootsNostrPublicKey,
- executor: Arc<dyn MycExternalCommandExecutor>,
-}
-
-impl MycExternalCommandIdentityOperations {
- fn new(
- role: String,
- command_path: PathBuf,
- timeout: Duration,
- public_key: RadrootsNostrPublicKey,
- executor: Arc<dyn MycExternalCommandExecutor>,
- ) -> Self {
- Self {
- role,
- command_path,
- timeout,
- public_key,
- executor,
- }
- }
-
- fn execute(
- &self,
- request: &MycExternalCommandRequest<'_>,
- ) -> Result<MycExternalCommandResponse, MycError> {
- let request_json = serde_json::to_vec(request)?;
- let output = self
- .executor
- .execute(&self.command_path, &request_json, self.timeout)
- .map_err(|error| match error {
- MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo {
- role: self.role.clone(),
- path: self.command_path.clone(),
- source,
- },
- MycExternalCommandExecuteError::TimedOut => {
- MycError::CustodyExternalCommandTimedOut {
- role: self.role.clone(),
- path: self.command_path.clone(),
- timeout_secs: self.timeout.as_secs(),
- }
- }
- MycExternalCommandExecuteError::OutputLimitExceeded {
- stream,
- limit_bytes,
- } => MycError::CustodyExternalCommandOutputLimit {
- role: self.role.clone(),
- path: self.command_path.clone(),
- stream,
- limit_bytes,
- },
- })?;
- if !output.success {
- return Err(MycError::CustodyExternalCommandFailed {
- role: self.role.clone(),
- path: self.command_path.clone(),
- status: output
- .status
- .map(|status| status.to_string())
- .unwrap_or_else(|| "terminated by signal".to_owned()),
- diagnostic: if output.stderr.is_empty() {
- "helper returned no diagnostic output"
- } else {
- "helper diagnostic output was redacted"
- },
- });
- }
- let response: MycExternalCommandResponse =
- serde_json::from_slice(&output.stdout).map_err(|source| {
- MycError::CustodyExternalCommandParse {
- role: self.role.clone(),
- path: self.command_path.clone(),
- source,
- }
- })?;
- if response.error.is_some() {
- return Err(MycError::CustodyExternalCommandFailed {
- role: self.role.clone(),
- path: self.command_path.clone(),
- status: "0".to_owned(),
- diagnostic: "helper reported a protocol error",
- });
- }
- Ok(response)
- }
-}
-
-impl MycIdentityOperations for MycExternalCommandIdentityOperations {
- fn nostr_client(&self) -> RadrootsNostrClient {
- RadrootsNostrClient::new_signerless()
- }
-
- fn nostr_client_owned(&self) -> RadrootsNostrClient {
- self.nostr_client()
- }
-
- fn sign_protocol_event_builder(
- &self,
- builder: RadrootsNostrGenericEventBuilder,
- operation: &str,
- ) -> Result<RadrootsNostrEvent, MycError> {
- let request = builder.into_external_signing_request(self.public_key)?;
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::SignEvent,
- unsigned_event: Some(MycExternalCommandUnsignedEvent::CheckedProtocol(&request)),
- public_key_hex: None,
- content: None,
- })?;
- let event = response.event.ok_or_else(|| {
- MycError::InvalidOperation(format!(
- "external signer command did not return a signed event for {operation}"
- ))
- })?;
- request.complete(event).map_err(Into::into)
- }
-
- fn sign_unsigned_event(
- &self,
- mut unsigned_event: nostr::UnsignedEvent,
- operation: &str,
- ) -> Result<nostr::Event, MycError> {
- if unsigned_event.pubkey != self.public_key || unsigned_event.verify_id().is_err() {
- return Err(MycError::CustodyExternalCommandUnsignedEventInvalid {
- role: self.role.clone(),
- path: self.command_path.clone(),
- operation: operation.to_owned(),
- });
- }
- let expected_event_id = unsigned_event.id();
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::SignEvent,
- unsigned_event: Some(MycExternalCommandUnsignedEvent::CallerSupplied(
- &unsigned_event,
- )),
- public_key_hex: None,
- content: None,
- })?;
- let event = response.event.ok_or_else(|| {
- MycError::InvalidOperation(format!(
- "external signer command did not return a signed event for {operation}"
- ))
- })?;
- if event.pubkey != self.public_key || event.id != expected_event_id {
- return Err(MycError::CustodyExternalCommandSignedEventMismatch {
- role: self.role.clone(),
- path: self.command_path.clone(),
- operation: operation.to_owned(),
- });
- }
- event
- .verify()
- .map_err(|_| MycError::CustodyExternalCommandSignedEventInvalid {
- role: self.role.clone(),
- path: self.command_path.clone(),
- operation: operation.to_owned(),
- })?;
- Ok(event)
- }
-
- fn nip04_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError> {
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::Nip04Encrypt,
- unsigned_event: None,
- public_key_hex: Some(public_key.to_hex()),
- content: Some(plaintext),
- })?;
- response.content.ok_or_else(|| {
- MycError::InvalidOperation(
- "external signer command did not return NIP-04 ciphertext".to_owned(),
- )
- })
- }
-
- fn nip04_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError> {
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::Nip04Decrypt,
- unsigned_event: None,
- public_key_hex: Some(public_key.to_hex()),
- content: Some(ciphertext.to_owned()),
- })?;
- response.content.ok_or_else(|| {
- MycError::InvalidOperation(
- "external signer command did not return NIP-04 cleartext".to_owned(),
- )
- })
- }
-
- fn nip44_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: String,
- ) -> Result<String, MycError> {
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::Nip44Encrypt,
- unsigned_event: None,
- public_key_hex: Some(public_key.to_hex()),
- content: Some(plaintext),
- })?;
- response.content.ok_or_else(|| {
- MycError::InvalidOperation(
- "external signer command did not return NIP-44 ciphertext".to_owned(),
- )
- })
- }
-
- fn nip44_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, MycError> {
- let response = self.execute(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::Nip44Decrypt,
- unsigned_event: None,
- public_key_hex: Some(public_key.to_hex()),
- content: Some(ciphertext.to_owned()),
- })?;
- response.content.ok_or_else(|| {
- MycError::InvalidOperation(
- "external signer command did not return NIP-44 cleartext".to_owned(),
- )
- })
- }
-}
-
-impl MycIdentityProvider {
- pub fn from_source(
- role: impl Into<String>,
- source: MycIdentitySourceSpec,
- external_command_timeout: Duration,
- ) -> Result<Self, MycError> {
- let role = role.into();
- let backend = match source.backend {
- MycIdentityBackend::EncryptedFile => {
- let path = source.path.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity encrypted_file backend requires a path"
- ))
- })?;
- MycIdentityProviderBackend::EncryptedFile { path }
- }
- MycIdentityBackend::PlaintextFile => {
- let path = source.path.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity plaintext_file backend requires a path"
- ))
- })?;
- MycIdentityProviderBackend::PlaintextFile { path }
- }
- MycIdentityBackend::HostVault => {
- let account_id = RadrootsIdentityId::parse(
- source.keyring_account_id.as_deref().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity host_vault backend requires keyring_account_id"
- ))
- })?,
- )
- .map_err(|_| {
- MycError::InvalidConfig(format!(
- "{role} identity host_vault backend requires a valid keyring_account_id"
- ))
- })?;
- let service_name = source.keyring_service_name.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity host_vault backend requires keyring_service_name"
- ))
- })?;
- Self::vault_provider(role.as_str(), &source, account_id, service_name)?
- }
- MycIdentityBackend::ManagedAccount => {
- let account_store_path = source.path.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity managed_account backend requires a path"
- ))
- })?;
- let service_name = source.keyring_service_name.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity managed_account backend requires keyring_service_name"
- ))
- })?;
- Self::managed_account_provider(role.as_str(), account_store_path, service_name)?
- }
- MycIdentityBackend::ExternalCommand => {
- let command_path = source.path.clone().ok_or_else(|| {
- MycError::InvalidConfig(format!(
- "{role} identity external_command backend requires a path"
- ))
- })?;
- MycIdentityProviderBackend::ExternalCommand {
- command_path,
- timeout: external_command_timeout,
- executor: Arc::new(MycProcessCommandExecutor),
- }
- }
- };
-
- Ok(Self {
- role,
- source,
- backend,
- })
- }
-
- pub fn load_identity(&self) -> Result<RadrootsIdentity, MycError> {
- match &self.backend {
- MycIdentityProviderBackend::EncryptedFile { path } => {
- Ok(load_encrypted_identity(path)?)
- }
- MycIdentityProviderBackend::PlaintextFile { path } => {
- RadrootsIdentity::load_from_path_auto(path).map_err(Into::into)
- }
- MycIdentityProviderBackend::HostVault {
- account_id,
- service_name,
- profile_path,
- vault,
- } => {
- let secret_key_hex = vault
- .load_secret(account_id.as_str())
- .map_err(|source| MycError::CustodyVault {
- role: self.role.clone(),
- source: source.into(),
- })?
- .ok_or_else(|| MycError::CustodySecretNotFound {
- role: self.role.clone(),
- service_name: service_name.clone(),
- account_id: account_id.to_string(),
- })?;
- let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?;
- if identity.id() != *account_id {
- return Err(MycError::CustodySecretIdentityMismatch {
- role: self.role.clone(),
- service_name: service_name.clone(),
- account_id: account_id.to_string(),
- resolved_identity_id: identity.id().to_string(),
- });
- }
- if let Some(profile_path) = profile_path {
- let profile_identity = load_identity_profile(profile_path)?;
- if profile_identity.id != *account_id {
- return Err(MycError::CustodyProfileIdentityMismatch {
- role: self.role.clone(),
- path: profile_path.clone(),
- account_id: account_id.to_string(),
- profile_identity_id: profile_identity.id.to_string(),
- });
- }
- if let Some(profile) = profile_identity.profile {
- identity.set_profile(profile);
- }
- }
- Ok(identity)
- }
- MycIdentityProviderBackend::ManagedAccount {
- account_store_path,
- service_name,
- manager,
- } => match manager.default_account_status().map_err(|source| {
- MycError::CustodyManager {
- role: self.role.clone(),
- source,
- }
- })? {
- RadrootsNostrAccountStatus::NotConfigured => {
- Err(MycError::CustodyManagedAccountNotConfigured {
- role: self.role.clone(),
- path: account_store_path.clone(),
- })
- }
- RadrootsNostrAccountStatus::PublicOnly { account } => {
- Err(MycError::CustodyManagedAccountPublicOnly {
- role: self.role.clone(),
- path: account_store_path.clone(),
- service_name: service_name.clone(),
- account_id: account.id().to_string(),
- })
- }
- RadrootsNostrAccountStatus::Ready { .. } => manager
- .default_signing_keys()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?
- .map(RadrootsIdentity::new)
- .ok_or_else(|| MycError::CustodyManagedAccountNotConfigured {
- role: self.role.clone(),
- path: account_store_path.clone(),
- }),
- _ => Err(MycError::InvalidOperation(format!(
- "{} managed account backend returned an unsupported account status",
- self.role
- ))),
- },
- MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
- Err(MycError::InvalidOperation(format!(
- "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process",
- self.role,
- command_path.display()
- )))
- }
- }
- }
-
- pub fn load_active_identity(&self) -> Result<MycActiveIdentity, MycError> {
- match &self.backend {
- MycIdentityProviderBackend::ExternalCommand {
- command_path,
- timeout,
- executor,
- } => {
- let (public_identity, public_key) =
- self.load_external_command_identity(command_path, *timeout, executor.as_ref())?;
- Ok(MycActiveIdentity::from_operations(
- public_identity.clone(),
- public_key,
- Arc::new(MycExternalCommandIdentityOperations::new(
- self.role.clone(),
- command_path.clone(),
- *timeout,
- public_key,
- executor.clone(),
- )),
- ))
- }
- _ => self.load_identity().map(MycActiveIdentity::new),
- }
- }
-
- pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput {
- match &self.backend {
- MycIdentityProviderBackend::ManagedAccount { .. } => {
- self.managed_account_status(Ok(()), self.selected_managed_account_record_result())
- }
- _ => self.status_with_public_identity(identity.public_identity()),
- }
- }
-
- pub fn probe_status(&self) -> MycIdentityStatusOutput {
- match &self.backend {
- MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status(
- self.load_identity_public().as_ref().map(|_| ()),
- self.selected_managed_account_record_result(),
- ),
- _ => match self.load_identity_public() {
- Ok(identity) => self.status_with_public_identity(&identity),
- Err(error) => self.status_with_error(&error),
- },
- }
- }
-
- pub fn source(&self) -> &MycIdentitySourceSpec {
- &self.source
- }
-
- pub fn status_output(&self) -> MycIdentityStatusOutput {
- self.probe_status()
- }
-
- pub fn export_nip49(
- &self,
- out: impl AsRef<std::path::Path>,
- password: &str,
- ) -> Result<MycCustodyExportOutput, MycError> {
- self.ensure_secret_materialized_operation("export NIP-49 secrets")?;
- let out = out.as_ref();
- let identity = self.load_identity()?;
- let payload = identity.encrypt_secret_key_ncryptsec(password)?;
- store_secret_text(out, payload.as_str())?;
- Ok(MycCustodyExportOutput {
- role: self.role.clone(),
- backend: self.source.backend,
- format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
- out: out.to_path_buf(),
- identity_id: identity.id().to_string(),
- public_key_hex: identity.public_key_hex(),
- })
- }
-
- pub fn import_nip49(
- &self,
- path: impl AsRef<std::path::Path>,
- password: &str,
- label: Option<String>,
- ) -> Result<MycCustodyImportOutput, MycError> {
- self.ensure_secret_materialized_operation("import NIP-49 secrets")?;
- let identity = load_identity_from_nip49_file(path.as_ref(), password)?;
- let account_id = identity.id().to_string();
- match &self.backend {
- MycIdentityProviderBackend::ManagedAccount { manager, .. } => {
- manager
- .upsert_keys(identity.keys(), label, true)
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?;
- }
- _ => {
- if let Some(label) = label {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `{}` does not support --label for `import-nip49` (got `{label}`)",
- self.role,
- self.source.backend.as_str(),
- )));
- }
- self.store_identity(&identity)?;
- }
- }
- Ok(MycCustodyImportOutput {
- role: self.role.clone(),
- backend: self.source.backend,
- format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
- account_id,
- status: self.probe_status(),
- })
- }
-
- pub fn rotate_secret_storage(&self) -> Result<MycCustodyRotateOutput, MycError> {
- match &self.backend {
- MycIdentityProviderBackend::EncryptedFile { path } => {
- rotate_encrypted_identity(path)?;
- }
- MycIdentityProviderBackend::PlaintextFile { .. } => {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `plaintext_file` does not support `custody rotate`; migrate to `encrypted_file`, `host_vault`, or `managed_account` first",
- self.role
- )));
- }
- MycIdentityProviderBackend::HostVault { .. } => {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `host_vault` does not define an in-process `custody rotate` action; rotate or re-provision the secret through the host vault itself",
- self.role
- )));
- }
- MycIdentityProviderBackend::ManagedAccount { .. } => {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `managed_account` does not define an in-process `custody rotate` action; rotate the selected account through the configured host vault policy",
- self.role
- )));
- }
- MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process and cannot rotate local storage",
- self.role,
- command_path.display(),
- )));
- }
- }
-
- Ok(MycCustodyRotateOutput {
- role: self.role.clone(),
- backend: self.source.backend,
- action: "rotate".to_owned(),
- status: self.probe_status(),
- })
- }
-
- pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> {
- self.managed_accounts_output()
- }
-
- pub fn generate_managed_account(
- &self,
- label: Option<String>,
- make_selected: bool,
- ) -> Result<MycManagedAccountMutationOutput, MycError> {
- let account_id = {
- let manager = self.managed_accounts_manager()?;
- manager
- .generate_keys(label, make_selected)
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?
- };
- Ok(MycManagedAccountMutationOutput {
- role: self.role.clone(),
- action: "generate".to_owned(),
- account_id: Some(account_id.to_string()),
- state: self.managed_accounts_output()?,
- })
- }
-
- pub fn import_managed_account_file(
- &self,
- path: impl AsRef<std::path::Path>,
- label: Option<String>,
- make_selected: bool,
- ) -> Result<MycManagedAccountMutationOutput, MycError> {
- let account_id = {
- let manager = self.managed_accounts_manager()?;
- let identity = RadrootsIdentity::load_from_path_auto(path).map_err(MycError::from)?;
- manager
- .upsert_keys(identity.keys(), label, make_selected)
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?
- };
- Ok(MycManagedAccountMutationOutput {
- role: self.role.clone(),
- action: "import_file".to_owned(),
- account_id: Some(account_id.to_string()),
- state: self.managed_accounts_output()?,
- })
- }
-
- pub fn select_managed_account(
- &self,
- account_id: &str,
- ) -> Result<MycManagedAccountMutationOutput, MycError> {
- let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
- MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
- })?;
- {
- let manager = self.managed_accounts_manager()?;
- manager
- .set_default_account(&account_id.to_final().into())
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?;
- }
- Ok(MycManagedAccountMutationOutput {
- role: self.role.clone(),
- action: "select".to_owned(),
- account_id: Some(account_id.to_string()),
- state: self.managed_accounts_output()?,
- })
- }
-
- pub fn remove_managed_account(
- &self,
- account_id: &str,
- ) -> Result<MycManagedAccountMutationOutput, MycError> {
- let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
- MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
- })?;
- {
- let manager = self.managed_accounts_manager()?;
- manager
- .remove_account(&account_id.to_final().into())
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?;
- }
- Ok(MycManagedAccountMutationOutput {
- role: self.role.clone(),
- action: "remove".to_owned(),
- account_id: Some(account_id.to_string()),
- state: self.managed_accounts_output()?,
- })
- }
-
- fn store_identity(&self, identity: &RadrootsIdentity) -> Result<(), MycError> {
- match &self.backend {
- MycIdentityProviderBackend::EncryptedFile { path } => {
- Ok(store_encrypted_identity(path, identity)?)
- }
- MycIdentityProviderBackend::PlaintextFile { path } => {
- store_plaintext_identity(path, identity)
- }
- MycIdentityProviderBackend::HostVault {
- account_id,
- service_name,
- profile_path,
- vault,
- } => {
- let identity_id = identity.id();
- if identity_id != *account_id {
- return Err(MycError::CustodySecretIdentityMismatch {
- role: self.role.clone(),
- service_name: service_name.clone(),
- account_id: account_id.to_string(),
- resolved_identity_id: identity_id.to_string(),
- });
- }
- let secret_key_hex = Zeroizing::new(identity.secret_key_hex());
- vault
- .store_secret(account_id.as_str(), secret_key_hex.as_str())
- .map_err(|source| MycError::CustodyVault {
- role: self.role.clone(),
- source: source.into(),
- })?;
- if let Some(profile_path) = profile_path {
- store_identity_profile(profile_path, identity)?;
- }
- Ok(())
- }
- MycIdentityProviderBackend::ManagedAccount { .. } => {
- Err(MycError::InvalidOperation(format!(
- "{} identity backend `managed_account` requires account-store lifecycle helpers instead of direct identity writes",
- self.role
- )))
- }
- MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
- Err(MycError::InvalidOperation(format!(
- "{} identity backend `external_command` at {} does not support direct secret writes",
- self.role,
- command_path.display(),
- )))
- }
- }
- }
-
- fn ensure_secret_materialized_operation(&self, operation: &str) -> Result<(), MycError> {
- if let MycIdentityProviderBackend::ExternalCommand { command_path, .. } = &self.backend {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `external_command` at {} does not support `{operation}` because secret material never enters the myc process",
- self.role,
- command_path.display(),
- )));
- }
- Ok(())
- }
-
- fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> {
- match &self.backend {
- MycIdentityProviderBackend::ExternalCommand {
- command_path,
- timeout,
- executor,
- } => self
- .load_external_command_identity(command_path, *timeout, executor.as_ref())
- .map(|(identity, _)| identity),
- _ => self.load_identity().map(|identity| identity.to_public()),
- }
- }
-
- fn load_external_command_identity(
- &self,
- command_path: &Path,
- timeout: Duration,
- executor: &dyn MycExternalCommandExecutor,
- ) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
- let request_json = serde_json::to_vec(&MycExternalCommandRequest {
- version: 1,
- operation: MycExternalCommandOperation::Describe,
- unsigned_event: None,
- public_key_hex: None,
- content: None,
- })?;
- let output = executor
- .execute(command_path, &request_json, timeout)
- .map_err(|error| match error {
- MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- source,
- },
- MycExternalCommandExecuteError::TimedOut => {
- MycError::CustodyExternalCommandTimedOut {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- timeout_secs: timeout.as_secs(),
- }
- }
- MycExternalCommandExecuteError::OutputLimitExceeded {
- stream,
- limit_bytes,
- } => MycError::CustodyExternalCommandOutputLimit {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- stream,
- limit_bytes,
- },
- })?;
- if !output.success {
- return Err(MycError::CustodyExternalCommandFailed {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- status: output
- .status
- .map(|status| status.to_string())
- .unwrap_or_else(|| "terminated by signal".to_owned()),
- diagnostic: if output.stderr.is_empty() {
- "helper returned no diagnostic output"
- } else {
- "helper diagnostic output was redacted"
- },
- });
- }
- let response: MycExternalCommandResponse =
- serde_json::from_slice(&output.stdout).map_err(|source| {
- MycError::CustodyExternalCommandParse {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- source,
- }
- })?;
- if response.error.is_some() {
- return Err(MycError::CustodyExternalCommandFailed {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- status: "0".to_owned(),
- diagnostic: "helper reported a protocol error",
- });
- }
- let identity =
- response
- .identity
- .ok_or_else(|| MycError::CustodyExternalCommandInvalidIdentity {
- role: self.role.clone(),
- path: command_path.to_path_buf(),
- message: "missing `identity` in describe response".to_owned(),
- })?;
- validate_external_command_public_identity(&self.role, command_path, identity)
- }
-
- fn status_with_public_identity(
- &self,
- identity: &RadrootsIdentityPublic,
- ) -> MycIdentityStatusOutput {
- MycIdentityStatusOutput {
- backend: self.source.backend,
- path: self.source.path.clone(),
- keyring_account_id: self.source.keyring_account_id.clone(),
- keyring_service_name: self.source.keyring_service_name.clone(),
- profile_path: self.source.profile_path.clone(),
- inherited_from: None,
- resolved: true,
- selected_account_id: None,
- selected_account_label: None,
- selected_account_state: None,
- default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
- allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
- runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
- host_vault_policy: MycConfig::host_vault_policy(),
- identity_id: Some(identity.id.to_string()),
- public_key_hex: Some(identity.public_key_hex.clone()),
- error: None,
- }
- }
-
- fn status_with_error(&self, error: &MycError) -> MycIdentityStatusOutput {
- MycIdentityStatusOutput {
- backend: self.source.backend,
- path: self.source.path.clone(),
- keyring_account_id: self.source.keyring_account_id.clone(),
- keyring_service_name: self.source.keyring_service_name.clone(),
- profile_path: self.source.profile_path.clone(),
- inherited_from: None,
- resolved: false,
- selected_account_id: None,
- selected_account_label: None,
- selected_account_state: None,
- default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
- allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
- runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
- host_vault_policy: MycConfig::host_vault_policy(),
- identity_id: None,
- public_key_hex: None,
- error: Some(error.to_string()),
- }
- }
-
- fn selected_managed_account_record_result(
- &self,
- ) -> Result<Option<RadrootsNostrAccountRecord>, MycError> {
- let manager = self.managed_accounts_manager()?;
- manager
- .default_account()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })
- }
-
- fn managed_account_status(
- &self,
- identity_result: Result<(), &MycError>,
- account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>,
- ) -> MycIdentityStatusOutput {
- let MycIdentityProviderBackend::ManagedAccount {
- account_store_path,
- service_name,
- manager,
- } = &self.backend
- else {
- return match self.load_identity_public() {
- Ok(identity) => self.status_with_public_identity(&identity),
- Err(error) => self.status_with_error(&error),
- };
- };
-
- let (selected_account_id, selected_account_label, identity_id, public_key_hex) =
- match account_result {
- Ok(Some(account)) => (
- Some(account.id().to_string()),
- account.label().map(ToOwned::to_owned),
- Some(account.id().to_string()),
- Some(account.public_identity().public_key().to_hex()),
- ),
- Ok(None) => (None, None, None, None),
- Err(error) => {
- return MycIdentityStatusOutput {
- backend: self.source.backend,
- path: Some(account_store_path.clone()),
- keyring_account_id: None,
- keyring_service_name: Some(service_name.clone()),
- profile_path: None,
- inherited_from: None,
- resolved: false,
- selected_account_id: None,
- selected_account_label: None,
- selected_account_state: None,
- default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
- allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
- runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
- host_vault_policy: MycConfig::host_vault_policy(),
- identity_id: None,
- public_key_hex: None,
- error: Some(error.to_string()),
- };
- }
- };
-
- let (resolved, selected_account_state, error) = match manager
- .default_account_status()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- }) {
- Ok(RadrootsNostrAccountStatus::NotConfigured) => (
- false,
- Some(MycManagedAccountSelectionState::NotConfigured),
- Some(
- MycError::CustodyManagedAccountNotConfigured {
- role: self.role.clone(),
- path: account_store_path.clone(),
- }
- .to_string(),
- ),
- ),
- Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => (
- false,
- Some(MycManagedAccountSelectionState::PublicOnly),
- Some(
- MycError::CustodyManagedAccountPublicOnly {
- role: self.role.clone(),
- path: account_store_path.clone(),
- service_name: service_name.clone(),
- account_id: account.id().to_string(),
- }
- .to_string(),
- ),
- ),
- Ok(RadrootsNostrAccountStatus::Ready { .. }) => match identity_result {
- Ok(_) => (true, Some(MycManagedAccountSelectionState::Ready), None),
- Err(error) => (
- false,
- Some(MycManagedAccountSelectionState::Ready),
- Some(error.to_string()),
- ),
- },
- Ok(_) => (
- false,
- None,
- Some("managed account backend returned an unsupported account status".to_owned()),
- ),
- Err(error) => (false, None, Some(error.to_string())),
- };
-
- MycIdentityStatusOutput {
- backend: self.source.backend,
- path: Some(account_store_path.clone()),
- keyring_account_id: None,
- keyring_service_name: Some(service_name.clone()),
- profile_path: None,
- inherited_from: None,
- resolved,
- selected_account_id,
- selected_account_label,
- selected_account_state,
- default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
- allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
- runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
- host_vault_policy: MycConfig::host_vault_policy(),
- identity_id,
- public_key_hex,
- error,
- }
- }
-
- fn managed_accounts_output(&self) -> Result<MycManagedAccountsOutput, MycError> {
- let MycIdentityProviderBackend::ManagedAccount {
- account_store_path,
- service_name,
- manager,
- } = &self.backend
- else {
- return Err(MycError::InvalidOperation(format!(
- "{} identity backend `{}` does not support managed account lifecycle commands",
- self.role,
- self.source.backend.as_str(),
- )));
- };
-
- let accounts = manager
- .list_accounts()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?;
- let selected_account_id = manager
- .default_account_id()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })?
- .map(|value| value.to_string());
- let selected_account_state =
- match manager
- .default_account_status()
- .map_err(|source| MycError::CustodyManager {
- role: self.role.clone(),
- source,
- })? {
- RadrootsNostrAccountStatus::NotConfigured => {
- MycManagedAccountSelectionState::NotConfigured
- }
- RadrootsNostrAccountStatus::PublicOnly { .. } => {
- MycManagedAccountSelectionState::PublicOnly
- }
- RadrootsNostrAccountStatus::Ready { .. } => MycManagedAccountSelectionState::Ready,
- _ => MycManagedAccountSelectionState::PublicOnly,
- };
-
- Ok(MycManagedAccountsOutput {
- role: self.role.clone(),
- backend: self.source.backend,
- account_store_path: account_store_path.clone(),
- keyring_service_name: service_name.clone(),
- selected_account_id,
- selected_account_state,
- accounts,
- })
- }
-
- fn managed_accounts_manager(&self) -> Result<&RadrootsNostrAccountsManager, MycError> {
- match &self.backend {
- MycIdentityProviderBackend::ManagedAccount { manager, .. } => Ok(manager),
- _ => Err(MycError::InvalidOperation(format!(
- "{} identity backend `{}` does not support managed account lifecycle commands",
- self.role,
- self.source.backend.as_str(),
- ))),
- }
- }
-
- fn vault_provider(
- role: &str,
- source: &MycIdentitySourceSpec,
- account_id: RadrootsIdentityId,
- service_name: String,
- ) -> Result<MycIdentityProviderBackend, MycError> {
- if service_name.trim().is_empty() {
- return Err(MycError::InvalidConfig(format!(
- "{role} identity host_vault backend requires a non-empty keyring_service_name"
- )));
- }
- Ok(MycIdentityProviderBackend::HostVault {
- account_id,
- service_name: service_name.clone(),
- profile_path: source.profile_path.clone(),
- vault: Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)),
- })
- }
-
- fn managed_account_provider(
- role: &str,
- account_store_path: PathBuf,
- service_name: String,
- ) -> Result<MycIdentityProviderBackend, MycError> {
- if account_store_path.as_os_str().is_empty() {
- return Err(MycError::InvalidConfig(format!(
- "{role} identity managed_account backend requires a non-empty path"
- )));
- }
- if service_name.trim().is_empty() {
- return Err(MycError::InvalidConfig(format!(
- "{role} identity managed_account backend requires a non-empty keyring_service_name"
- )));
- }
- if let Some(parent) = account_store_path.parent()
- && !parent.as_os_str().is_empty()
- {
- fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
- path: parent.to_path_buf(),
- source,
- })?;
- }
- let manager = RadrootsNostrAccountsManager::new_file_backed_with_vault(
- account_store_path.as_path(),
- RadrootsSecretVaultOsKeyring::new(service_name.clone()),
- )
- .map_err(|source| MycError::CustodyManager {
- role: role.to_owned(),
- source,
- })?;
- Ok(MycIdentityProviderBackend::ManagedAccount {
- account_store_path,
- service_name,
- manager,
- })
- }
-}
-
-impl MycActiveIdentity {
- pub fn new(identity: RadrootsIdentity) -> Self {
- let public_identity = identity.to_public();
- let public_key = identity.public_key();
- Self::from_operations(
- public_identity,
- public_key,
- Arc::new(MycLoadedIdentityOperations::new(identity)),
- )
- }
-
- fn from_operations(
- public_identity: RadrootsIdentityPublic,
- public_key: RadrootsNostrPublicKey,
- operations: Arc<dyn MycIdentityOperations>,
- ) -> Self {
- Self {
- public_identity,
- public_key,
- operations,
- }
- }
-
- pub fn id(&self) -> RadrootsIdentityId {
- self.public_identity.id.clone()
- }
-
- pub fn public_key(&self) -> RadrootsNostrPublicKey {
- self.public_key
- }
-
- pub fn public_key_hex(&self) -> String {
- self.public_identity.public_key_hex.clone()
- }
-
- pub fn to_public(&self) -> RadrootsIdentityPublic {
- self.public_identity.clone()
- }
-
- pub fn public_identity(&self) -> &RadrootsIdentityPublic {
- &self.public_identity
- }
-
- pub fn nostr_client(&self) -> RadrootsNostrClient {
- self.operations.nostr_client()
- }
-
- pub fn nostr_client_owned(&self) -> RadrootsNostrClient {
- self.operations.nostr_client_owned()
- }
-
- pub fn sign_protocol_event_builder(
- &self,
- builder: RadrootsNostrGenericEventBuilder,
- operation: &str,
- ) -> Result<RadrootsNostrEvent, MycError> {
- self.operations
- .sign_protocol_event_builder(builder, operation)
- }
-
- pub fn sign_unsigned_event(
- &self,
- unsigned_event: nostr::UnsignedEvent,
- operation: &str,
- ) -> Result<nostr::Event, MycError> {
- self.operations
- .sign_unsigned_event(unsigned_event, operation)
- }
-
- pub fn nip04_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: impl Into<String>,
- ) -> Result<String, MycError> {
- self.operations.nip04_encrypt(public_key, plaintext.into())
- }
-
- pub fn nip04_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: impl AsRef<str>,
- ) -> Result<String, MycError> {
- self.operations
- .nip04_decrypt(public_key, ciphertext.as_ref())
- }
-
- pub fn nip44_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: impl Into<String>,
- ) -> Result<String, MycError> {
- self.operations.nip44_encrypt(public_key, plaintext.into())
- }
-
- pub fn nip44_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: impl AsRef<str>,
- ) -> Result<String, MycError> {
- self.operations
- .nip44_decrypt(public_key, ciphertext.as_ref())
- }
-}
-
-fn load_identity_from_nip49_file(
- path: &std::path::Path,
- password: &str,
-) -> Result<RadrootsIdentity, MycError> {
- let encoded = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo {
- path: path.to_path_buf(),
- source,
- })?;
- let payload = encoded.trim();
- if payload.is_empty() {
- return Err(MycError::InvalidOperation(format!(
- "NIP-49 payload at {} was empty",
- path.display()
- )));
- }
- RadrootsIdentity::from_encrypted_secret_key_str(payload, password).map_err(MycError::from)
-}
-
-fn validate_external_command_public_identity(
- role: &str,
- command_path: &Path,
- identity: RadrootsIdentityPublic,
-) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
- let public_key =
- RadrootsNostrPublicKey::parse(identity.public_key_hex.as_str()).map_err(|error| {
- MycError::CustodyExternalCommandInvalidIdentity {
- role: role.to_owned(),
- path: command_path.to_path_buf(),
- message: format!(
- "invalid public_key_hex `{}`: {error}",
- identity.public_key_hex
- ),
- }
- })?;
- let expected_id = RadrootsIdentityId::from_public_key(public_key)?;
- if identity.id != expected_id {
- return Err(MycError::CustodyExternalCommandInvalidIdentity {
- role: role.to_owned(),
- path: command_path.to_path_buf(),
- message: format!(
- "identity id `{}` does not match public_key_hex `{}`",
- identity.id, identity.public_key_hex
- ),
- });
- }
- Ok((identity, public_key))
-}
-
-impl MycIdentityStatusOutput {
- pub fn with_inherited_from(mut self, inherited_from: impl Into<String>) -> Self {
- self.inherited_from = Some(inherited_from.into());
- self
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- #[cfg(unix)]
- use std::os::unix::fs::PermissionsExt;
- use std::path::{Path, PathBuf};
- use std::process::Command;
- use std::sync::Mutex;
- use std::time::Instant;
-
- use crate::accounts::RadrootsSecretVault;
- use crate::accounts::{
- RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore,
- RadrootsNostrSecretVaultMemory,
- };
- use crate::host_identity::RadrootsIdentity;
-
- use super::*;
-
- fn write_identity(path: &Path, secret_key: &str) {
- let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
- crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
- }
-
- fn fixture_source(path: &Path) -> MycIdentitySourceSpec {
- MycIdentitySourceSpec {
- backend: MycIdentityBackend::EncryptedFile,
- path: Some(path.to_path_buf()),
- keyring_account_id: None,
- keyring_service_name: None,
- profile_path: None,
- }
- }
-
- #[cfg(unix)]
- fn shell_single_quote(value: &str) -> String {
- format!("'{}'", value.replace('\'', "'\"'\"'"))
- }
-
- #[cfg(unix)]
- fn write_timeout_helper(path: &Path, pid_path: &Path) {
- let script = format!(
- "#!/bin/sh\nprintf '%s\\n' \"$$\" > {}\nwhile :; do\n :\ndone\n",
- shell_single_quote(&pid_path.display().to_string())
- );
- fs::write(path, script).expect("write helper");
- let mut permissions = fs::metadata(path).expect("helper metadata").permissions();
- permissions.set_mode(0o755);
- fs::set_permissions(path, permissions).expect("helper permissions");
- }
-
- #[cfg(unix)]
- fn write_output_helper(path: &Path) {
- let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do\n printf '0123456789abcdef\\n'\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n";
- fs::write(path, script).expect("write helper");
- let mut permissions = fs::metadata(path).expect("helper metadata").permissions();
- permissions.set_mode(0o755);
- fs::set_permissions(path, permissions).expect("helper permissions");
- }
-
- #[cfg(unix)]
- fn write_delayed_helper(path: &Path) {
- let script = "#!/bin/sh\nsleep 1\nprintf '{}'\n";
- fs::write(path, script).expect("write helper");
- let mut permissions = fs::metadata(path).expect("helper metadata").permissions();
- permissions.set_mode(0o755);
- fs::set_permissions(path, permissions).expect("helper permissions");
- }
-
- #[cfg(unix)]
- fn write_excess_output_helper(path: &Path) {
- let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 17000 ]; do\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n";
- fs::write(path, script).expect("write helper");
- let mut permissions = fs::metadata(path).expect("helper metadata").permissions();
- permissions.set_mode(0o755);
- fs::set_permissions(path, permissions).expect("helper permissions");
- }
-
- #[cfg(unix)]
- fn process_exists(pid: u32) -> bool {
- Command::new("kill")
- .arg("-0")
- .arg(pid.to_string())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .expect("kill probe")
- .success()
- }
-
- #[derive(Debug)]
- struct FakeExternalCommandExecutor {
- identity: RadrootsIdentity,
- requests: Mutex<Vec<MycExternalCommandRequestWire>>,
- }
-
- impl FakeExternalCommandExecutor {
- fn new(secret_key: &str) -> Arc<Self> {
- Arc::new(Self {
- identity: RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"),
- requests: Mutex::new(Vec::new()),
- })
- }
- }
-
- impl MycExternalCommandExecutor for FakeExternalCommandExecutor {
- fn execute(
- &self,
- _command_path: &Path,
- request_json: &[u8],
- _timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- let request: MycExternalCommandRequestWire =
- serde_json::from_slice(request_json).expect("request");
- assert_eq!(request.version, 1);
- self.requests
- .lock()
- .expect("requests lock")
- .push(request.clone());
- let response = match request.operation {
- MycExternalCommandOperation::Describe => MycExternalCommandResponse {
- identity: Some(self.identity.to_public()),
- event: None,
- content: None,
- error: None,
- },
- MycExternalCommandOperation::SignEvent => {
- let unsigned_event = request.unsigned_event.expect("unsigned event");
- let event = unsigned_event
- .sign_with_keys(self.identity.keys())
- .expect("sign event");
- MycExternalCommandResponse {
- identity: None,
- event: Some(event),
- content: None,
- error: None,
- }
- }
- MycExternalCommandOperation::Nip04Encrypt => {
- let public_key = RadrootsNostrPublicKey::parse(
- request.public_key_hex.as_deref().expect("public key hex"),
- )
- .expect("public key");
- let ciphertext = nip04::encrypt(
- self.identity.keys().secret_key(),
- &public_key,
- request.content.expect("plaintext"),
- )
- .expect("encrypt");
- MycExternalCommandResponse {
- identity: None,
- event: None,
- content: Some(ciphertext),
- error: None,
- }
- }
- MycExternalCommandOperation::Nip04Decrypt => {
- let public_key = RadrootsNostrPublicKey::parse(
- request.public_key_hex.as_deref().expect("public key hex"),
- )
- .expect("public key");
- let plaintext = nip04::decrypt(
- self.identity.keys().secret_key(),
- &public_key,
- request.content.as_deref().expect("ciphertext"),
- )
- .expect("decrypt");
- MycExternalCommandResponse {
- identity: None,
- event: None,
- content: Some(plaintext),
- error: None,
- }
- }
- MycExternalCommandOperation::Nip44Encrypt => {
- let public_key = RadrootsNostrPublicKey::parse(
- request.public_key_hex.as_deref().expect("public key hex"),
- )
- .expect("public key");
- let ciphertext = nip44::encrypt(
- self.identity.keys().secret_key(),
- &public_key,
- request.content.expect("plaintext"),
- Version::V2,
- )
- .expect("encrypt");
- MycExternalCommandResponse {
- identity: None,
- event: None,
- content: Some(ciphertext),
- error: None,
- }
- }
- MycExternalCommandOperation::Nip44Decrypt => {
- let public_key = RadrootsNostrPublicKey::parse(
- request.public_key_hex.as_deref().expect("public key hex"),
- )
- .expect("public key");
- let plaintext = nip44::decrypt(
- self.identity.keys().secret_key(),
- &public_key,
- request.content.as_deref().expect("ciphertext"),
- )
- .expect("decrypt");
- MycExternalCommandResponse {
- identity: None,
- event: None,
- content: Some(plaintext),
- error: None,
- }
- }
- };
-
- Ok(MycExternalCommandOutput {
- success: true,
- status: Some(0),
- stdout: serde_json::to_vec(&response).expect("response"),
- stderr: Vec::new(),
- })
- }
- }
-
- fn managed_account_provider(
- role: &str,
- service_name: &str,
- ) -> (MycIdentityProvider, Arc<RadrootsNostrSecretVaultMemory>) {
- let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
- let manager = RadrootsNostrAccountsManager::new(
- Arc::new(RadrootsNostrMemoryAccountStore::new()),
- vault.clone() as Arc<dyn RadrootsSecretVault>,
- )
- .expect("manager");
- (
- MycIdentityProvider {
- role: role.to_owned(),
- source: MycIdentitySourceSpec {
- backend: MycIdentityBackend::ManagedAccount,
- path: Some(PathBuf::from(format!("/tmp/{role}-accounts.json"))),
- keyring_account_id: None,
- keyring_service_name: Some(service_name.to_owned()),
- profile_path: None,
- },
- backend: MycIdentityProviderBackend::ManagedAccount {
- account_store_path: PathBuf::from(format!("/tmp/{role}-accounts.json")),
- service_name: service_name.to_owned(),
- manager,
- },
- },
- vault,
- )
- }
-
- fn external_command_provider(
- role: &str,
- secret_key: &str,
- ) -> (MycIdentityProvider, Arc<FakeExternalCommandExecutor>) {
- let executor = FakeExternalCommandExecutor::new(secret_key);
- let command_path = PathBuf::from(format!("/tmp/{role}-identity-helper"));
- (
- MycIdentityProvider {
- role: role.to_owned(),
- source: MycIdentitySourceSpec {
- backend: MycIdentityBackend::ExternalCommand,
- path: Some(command_path.clone()),
- keyring_account_id: None,
- keyring_service_name: None,
- profile_path: None,
- },
- backend: MycIdentityProviderBackend::ExternalCommand {
- command_path,
- timeout: Duration::from_secs(10),
- executor: executor.clone(),
- },
- },
- executor,
- )
- }
-
- #[test]
- fn encrypted_file_provider_loads_identity() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("signer.json");
- write_identity(
- &path,
- "1111111111111111111111111111111111111111111111111111111111111111",
- );
-
- let provider = MycIdentityProvider::from_source(
- "signer",
- fixture_source(&path),
- Duration::from_secs(10),
- )
- .expect("provider");
- let identity = provider.load_identity().expect("identity");
-
- assert_eq!(
- identity.public_key_hex(),
- "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
- );
- }
-
- #[test]
- fn vault_provider_loads_identity_and_merges_profile() {
- let temp = tempfile::tempdir().expect("tempdir");
- let profile_path = temp.path().join("profile.json");
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- crate::identity_files::store_identity_profile(&profile_path, &identity)
- .expect("save profile");
-
- let account_id = identity.id();
- let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
- vault
- .store_secret(account_id.as_str(), identity.secret_key_hex().as_str())
- .expect("store");
-
- let provider = MycIdentityProvider {
- role: "signer".to_owned(),
- source: MycIdentitySourceSpec {
- backend: MycIdentityBackend::HostVault,
- path: None,
- keyring_account_id: Some(account_id.to_string()),
- keyring_service_name: Some("org.radroots.test".to_owned()),
- profile_path: Some(profile_path.clone()),
- },
- backend: MycIdentityProviderBackend::HostVault {
- account_id: account_id.clone(),
- service_name: "org.radroots.test".to_owned(),
- profile_path: Some(profile_path),
- vault,
- },
- };
-
- let loaded = provider.load_identity().expect("loaded");
- assert_eq!(loaded.id(), account_id);
- assert!(provider.probe_status().resolved);
- }
-
- #[test]
- fn vault_provider_reports_missing_secret() {
- let account_id = RadrootsIdentity::from_secret_key_str(
- "3333333333333333333333333333333333333333333333333333333333333333",
- )
- .expect("identity")
- .id();
- let provider = MycIdentityProvider {
- role: "user".to_owned(),
- source: MycIdentitySourceSpec {
- backend: MycIdentityBackend::HostVault,
- path: None,
- keyring_account_id: Some(account_id.to_string()),
- keyring_service_name: Some("org.radroots.test".to_owned()),
- profile_path: None,
- },
- backend: MycIdentityProviderBackend::HostVault {
- account_id: account_id.clone(),
- service_name: "org.radroots.test".to_owned(),
- profile_path: None,
- vault: Arc::new(RadrootsNostrSecretVaultMemory::new()),
- },
- };
-
- let err = provider.load_identity().expect_err("missing secret");
- assert!(matches!(err, MycError::CustodySecretNotFound { .. }));
- assert!(!provider.probe_status().resolved);
- }
-
- #[test]
- fn managed_account_provider_loads_selected_identity() {
- let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
- let generated = provider
- .generate_managed_account(Some("primary".to_owned()), true)
- .expect("generate");
-
- let identity = provider.load_identity().expect("identity");
- let identity_id = identity.id().to_string();
- assert_eq!(
- generated.state.selected_account_id.as_deref(),
- Some(identity_id.as_str())
- );
- let status = provider.probe_status();
- assert!(status.resolved);
- assert_eq!(
- status.selected_account_state,
- Some(MycManagedAccountSelectionState::Ready)
- );
- }
-
- #[test]
- fn managed_account_provider_supports_nip49_export_and_import() {
- let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
- let generated = provider
- .generate_managed_account(Some("primary".to_owned()), true)
- .expect("generate");
- let selected_account_id = generated
- .state
- .selected_account_id
- .clone()
- .expect("selected account id");
- let temp = tempfile::tempdir().expect("tempdir");
- let export_path = temp.path().join("managed-account.ncryptsec");
-
- let export = provider
- .export_nip49(&export_path, "test password")
- .expect("export nip49");
- assert_eq!(export.format, "nip49");
- assert_eq!(export.identity_id, selected_account_id);
-
- provider
- .remove_managed_account(selected_account_id.as_str())
- .expect("remove account");
- let removed_status = provider.probe_status();
- assert!(!removed_status.resolved);
-
- let imported = provider
- .import_nip49(&export_path, "test password", Some("restored".to_owned()))
- .expect("import nip49");
- assert_eq!(imported.account_id, export.identity_id);
- assert!(imported.status.resolved);
- assert_eq!(
- imported.status.selected_account_label.as_deref(),
- Some("restored")
- );
- }
-
- #[test]
- fn managed_account_provider_reports_not_configured() {
- let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user");
-
- let err = provider
- .load_identity()
- .expect_err("missing selected account");
- assert!(matches!(
- err,
- MycError::CustodyManagedAccountNotConfigured { .. }
- ));
- let status = provider.probe_status();
- assert!(!status.resolved);
- assert_eq!(
- status.selected_account_state,
- Some(MycManagedAccountSelectionState::NotConfigured)
- );
- }
-
- #[test]
- fn managed_account_provider_reports_public_only_selected_account() {
- let (provider, vault) = managed_account_provider("user", "org.radroots.test.user");
- let identity = RadrootsIdentity::from_secret_key_str(
- "3333333333333333333333333333333333333333333333333333333333333333",
- )
- .expect("identity");
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("managed-account.json");
- identity.save_json(&path).expect("save");
- let record = provider
- .import_managed_account_file(&path, Some("managed".to_owned()), true)
- .expect("import");
- let selected_account_id = record
- .state
- .selected_account_id
- .clone()
- .expect("selected account");
- vault
- .remove_secret(
- RadrootsIdentityId::parse(selected_account_id.as_str())
- .expect("account id")
- .as_str(),
- )
- .expect("remove secret");
-
- let err = provider.load_identity().expect_err("public only");
- assert!(matches!(
- err,
- MycError::CustodyManagedAccountPublicOnly { .. }
- ));
- let status = provider.probe_status();
- assert!(!status.resolved);
- assert_eq!(
- status.selected_account_state,
- Some(MycManagedAccountSelectionState::PublicOnly)
- );
- }
-
- #[test]
- fn external_command_provider_loads_identity_and_executes_signing_operations() {
- let (provider, executor) = external_command_provider(
- "signer",
- "1111111111111111111111111111111111111111111111111111111111111111",
- );
- let active = provider.load_active_identity().expect("active identity");
- let expected_identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- assert_eq!(active.id(), expected_identity.id());
- assert_eq!(active.public_key_hex(), expected_identity.public_key_hex());
-
- let peer_identity = RadrootsIdentity::from_secret_key_str(
- "2222222222222222222222222222222222222222222222222222222222222222",
- )
- .expect("peer identity");
- let signed_event = active
- .sign_protocol_event_builder(
- RadrootsNostrGenericEventBuilder::new(
- nostr::Kind::Custom(24_133),
- "hello from external command",
- ),
- "test event",
- )
- .expect("signed event");
- assert_eq!(signed_event.pubkey, expected_identity.public_key());
-
- let nip04_ciphertext = active
- .nip04_encrypt(&peer_identity.public_key(), "hello nip04")
- .expect("nip04 encrypt");
- assert_eq!(
- nip04::decrypt(
- peer_identity.keys().secret_key(),
- &expected_identity.public_key(),
- &nip04_ciphertext,
- )
- .expect("decrypt with peer"),
- "hello nip04"
- );
-
- let nip44_ciphertext = active
- .nip44_encrypt(&peer_identity.public_key(), "hello nip44")
- .expect("nip44 encrypt");
- assert_eq!(
- nip44::decrypt(
- peer_identity.keys().secret_key(),
- &expected_identity.public_key(),
- &nip44_ciphertext,
- )
- .expect("decrypt with peer"),
- "hello nip44"
- );
-
- let status = provider.probe_status();
- assert!(status.resolved);
- assert_eq!(
- status.path,
- Some(PathBuf::from("/tmp/signer-identity-helper"))
- );
- assert_eq!(status.identity_id, Some(expected_identity.id().to_string()));
-
- let operations = executor
- .requests
- .lock()
- .expect("requests lock")
- .iter()
- .map(|request| request.operation)
- .collect::<Vec<_>>();
- assert!(operations.contains(&MycExternalCommandOperation::Describe));
- assert!(operations.contains(&MycExternalCommandOperation::SignEvent));
- assert!(operations.contains(&MycExternalCommandOperation::Nip04Encrypt));
- assert!(operations.contains(&MycExternalCommandOperation::Nip44Encrypt));
-
- let requests = executor.requests.lock().expect("requests lock");
- let signing_request = requests
- .iter()
- .find(|request| request.operation == MycExternalCommandOperation::SignEvent)
- .and_then(|request| request.unsigned_event.as_ref())
- .expect("serialized unsigned event");
- assert!(signing_request.id.is_some());
- signing_request.verify_id().expect("canonical event id");
- assert_eq!(signing_request.kind, nostr::Kind::Custom(24_133));
- }
-
- #[derive(Debug)]
- struct StaticSigningResponseExecutor {
- event: RadrootsNostrEvent,
- }
-
- impl MycExternalCommandExecutor for StaticSigningResponseExecutor {
- fn execute(
- &self,
- _command_path: &Path,
- request_json: &[u8],
- _timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- let request: MycExternalCommandRequestWire =
- serde_json::from_slice(request_json).expect("request");
- assert_eq!(request.version, 1);
- assert_eq!(request.operation, MycExternalCommandOperation::SignEvent);
- assert!(request.unsigned_event.is_some());
- Ok(MycExternalCommandOutput {
- success: true,
- status: Some(0),
- stdout: serde_json::to_vec(&MycExternalCommandResponse {
- identity: None,
- event: Some(self.event.clone()),
- content: None,
- error: None,
- })
- .expect("response"),
- stderr: Vec::new(),
- })
- }
- }
-
- fn external_operations_with_event(
- identity: &RadrootsIdentity,
- event: RadrootsNostrEvent,
- ) -> MycExternalCommandIdentityOperations {
- MycExternalCommandIdentityOperations::new(
- "user".to_owned(),
- PathBuf::from("/tmp/user-helper"),
- Duration::from_secs(10),
- identity.public_key(),
- Arc::new(StaticSigningResponseExecutor { event }),
- )
- }
-
- fn caller_unsigned_event(identity: &RadrootsIdentity, content: &str) -> nostr::UnsignedEvent {
- nostr::UnsignedEvent::new(
- identity.public_key(),
- nostr::Timestamp::from_secs(1_234),
- nostr::Kind::Custom(30_001),
- [],
- content,
- )
- }
-
- #[test]
- fn external_command_rejects_invalid_caller_unsigned_events_before_execution() {
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- let valid_event = caller_unsigned_event(&identity, "valid")
- .sign_with_keys(identity.keys())
- .expect("event");
- let operations = external_operations_with_event(&identity, valid_event);
-
- let other_identity = RadrootsIdentity::from_secret_key_str(
- "2222222222222222222222222222222222222222222222222222222222222222",
- )
- .expect("other identity");
- let wrong_author = caller_unsigned_event(&other_identity, "wrong author");
- assert!(matches!(
- operations.sign_unsigned_event(wrong_author, "caller event"),
- Err(MycError::CustodyExternalCommandUnsignedEventInvalid { .. })
- ));
-
- let mut invalid_id = caller_unsigned_event(&identity, "invalid id");
- invalid_id.id = Some(nostr::EventId::all_zeros());
- assert!(matches!(
- operations.sign_unsigned_event(invalid_id, "caller event"),
- Err(MycError::CustodyExternalCommandUnsignedEventInvalid { .. })
- ));
- }
-
- #[test]
- fn external_command_accepts_only_the_exact_valid_caller_event() {
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- let unsigned_event = caller_unsigned_event(&identity, "expected");
- let valid_event = unsigned_event
- .clone()
- .sign_with_keys(identity.keys())
- .expect("event");
- let accepted = external_operations_with_event(&identity, valid_event.clone())
- .sign_unsigned_event(unsigned_event.clone(), "caller event")
- .expect("accepted event");
- assert_eq!(accepted, valid_event);
-
- let wrong_event = caller_unsigned_event(&identity, "different")
- .sign_with_keys(identity.keys())
- .expect("different event");
- assert!(matches!(
- external_operations_with_event(&identity, wrong_event)
- .sign_unsigned_event(unsigned_event.clone(), "caller event"),
- Err(MycError::CustodyExternalCommandSignedEventMismatch { .. })
- ));
-
- let other_identity = RadrootsIdentity::from_secret_key_str(
- "2222222222222222222222222222222222222222222222222222222222222222",
- )
- .expect("other identity");
- let wrong_author = caller_unsigned_event(&other_identity, "expected")
- .sign_with_keys(other_identity.keys())
- .expect("wrong author event");
- assert!(matches!(
- external_operations_with_event(&identity, wrong_author)
- .sign_unsigned_event(unsigned_event.clone(), "caller event"),
- Err(MycError::CustodyExternalCommandSignedEventMismatch { .. })
- ));
-
- let mut tampered_content = valid_event.clone();
- tampered_content.content.push_str(" tampered");
- assert!(matches!(
- external_operations_with_event(&identity, tampered_content)
- .sign_unsigned_event(unsigned_event.clone(), "caller event"),
- Err(MycError::CustodyExternalCommandSignedEventInvalid { .. })
- ));
-
- let mut invalid_signature = valid_event;
- invalid_signature.sig = caller_unsigned_event(&identity, "signature source")
- .sign_with_keys(identity.keys())
- .expect("signature source")
- .sig;
- assert!(matches!(
- external_operations_with_event(&identity, invalid_signature)
- .sign_unsigned_event(unsigned_event, "caller event"),
- Err(MycError::CustodyExternalCommandSignedEventInvalid { .. })
- ));
- }
-
- #[test]
- fn external_command_protocol_signing_uses_checked_library_completion() {
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- let wrong_event = RadrootsNostrGenericEventBuilder::new(
- nostr::Kind::Custom(24_133),
- "different response",
- )
- .sign_with_keys(identity.keys())
- .expect("different event");
- let error = external_operations_with_event(&identity, wrong_event)
- .sign_protocol_event_builder(
- RadrootsNostrGenericEventBuilder::new(
- nostr::Kind::Custom(24_133),
- "expected response",
- ),
- "protocol response",
- )
- .expect_err("different event");
-
- assert!(matches!(
- error,
- MycError::Nostr(
- crate::nostr_contract::RadrootsNostrError::ExternalSigningEventIdMismatch { .. }
- )
- ));
- }
-
- #[tokio::test]
- async fn external_command_provider_uses_signerless_relay_client() {
- let (provider, _executor) = external_command_provider(
- "signer",
- "1111111111111111111111111111111111111111111111111111111111111111",
- );
- let active = provider.load_active_identity().expect("active identity");
-
- assert!(!active.nostr_client().has_signer().await);
- assert!(!active.nostr_client_owned().has_signer().await);
- }
-
- #[derive(Debug, Default)]
- struct TimeoutExternalCommandExecutor;
-
- impl MycExternalCommandExecutor for TimeoutExternalCommandExecutor {
- fn execute(
- &self,
- _command_path: &Path,
- _request_json: &[u8],
- _timeout: Duration,
- ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
- Err(MycExternalCommandExecuteError::TimedOut)
- }
- }
-
- #[test]
- fn external_command_provider_maps_describe_timeout() {
- let provider = MycIdentityProvider {
- role: "signer".to_owned(),
- source: MycIdentitySourceSpec {
- backend: MycIdentityBackend::ExternalCommand,
- path: Some(PathBuf::from("/tmp/signer-helper")),
- keyring_account_id: None,
- keyring_service_name: None,
- profile_path: None,
- },
- backend: MycIdentityProviderBackend::ExternalCommand {
- command_path: PathBuf::from("/tmp/signer-helper"),
- timeout: Duration::from_secs(7),
- executor: Arc::new(TimeoutExternalCommandExecutor),
- },
- };
-
- let err = provider.load_active_identity().err().expect("timeout");
- assert!(matches!(
- err,
- MycError::CustodyExternalCommandTimedOut {
- ref role,
- ref path,
- timeout_secs: 7,
- } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper")
- ));
- }
-
- #[test]
- fn external_command_provider_maps_operation_timeout() {
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
- let public_identity = identity.to_public();
- let public_key = identity.public_key();
- let active = MycActiveIdentity::from_operations(
- public_identity.clone(),
- public_key,
- Arc::new(MycExternalCommandIdentityOperations::new(
- "signer".to_owned(),
- PathBuf::from("/tmp/signer-helper"),
- Duration::from_secs(11),
- public_key,
- Arc::new(TimeoutExternalCommandExecutor),
- )),
- );
-
- let err = active
- .sign_protocol_event_builder(
- RadrootsNostrGenericEventBuilder::new(nostr::Kind::Custom(24_133), "timeout"),
- "timeout event",
- )
- .expect_err("timeout");
- assert!(matches!(
- err,
- MycError::CustodyExternalCommandTimedOut {
- ref role,
- ref path,
- timeout_secs: 11,
- } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper")
- ));
- }
-
- #[tokio::test]
- async fn external_command_output_reader_enforces_its_byte_limit() {
- let (mut writer, reader) = tokio::io::duplex(128);
- let writer_task = tokio::spawn(async move {
- let _ = writer.write_all(&[b'x'; 65]).await;
- });
-
- let error = read_limited_external_command_output(reader, "stdout", 64)
- .await
- .expect_err("output limit");
- writer_task.await.expect("writer task");
-
- assert!(matches!(
- error,
- MycExternalCommandExecuteError::OutputLimitExceeded {
- stream: "stdout",
- limit_bytes: 64,
- }
- ));
- }
-
- #[cfg(unix)]
- #[test]
- fn process_executor_drains_stdout_and_stderr_while_the_helper_runs() {
- let temp = tempfile::tempdir().expect("tempdir");
- let helper_path = temp.path().join("output-helper.sh");
- write_output_helper(&helper_path);
-
- let output = MycProcessCommandExecutor
- .execute(&helper_path, b"{}", Duration::from_secs(5))
- .expect("helper output");
-
- assert!(output.success);
- assert_eq!(output.stdout.len(), 85_000);
- assert_eq!(output.stderr.len(), 20_000);
- }
-
- #[cfg(unix)]
- #[test]
- fn process_executor_reports_output_limits_without_pipe_deadlock() {
- let temp = tempfile::tempdir().expect("tempdir");
- let helper_path = temp.path().join("excess-output-helper.sh");
- write_excess_output_helper(&helper_path);
-
- let error = MycProcessCommandExecutor
- .execute(&helper_path, b"{}", Duration::from_secs(5))
- .expect_err("stderr output limit");
-
- assert!(matches!(
- error,
- MycExternalCommandExecuteError::OutputLimitExceeded {
- stream: "stderr",
- limit_bytes: MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES,
- }
- ));
- }
-
- #[cfg(unix)]
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn process_executor_releases_the_tokio_worker_while_waiting() {
- use std::sync::atomic::{AtomicBool, Ordering};
-
- let temp = tempfile::tempdir().expect("tempdir");
- let helper_path = temp.path().join("delayed-helper.sh");
- write_delayed_helper(&helper_path);
- let made_progress = Arc::new(AtomicBool::new(false));
- let task_progress = made_progress.clone();
- let progress_task = tokio::spawn(async move {
- tokio::time::sleep(Duration::from_millis(50)).await;
- task_progress.store(true, Ordering::SeqCst);
- });
-
- let output = MycProcessCommandExecutor
- .execute(&helper_path, b"{}", Duration::from_secs(5))
- .expect("helper output");
- progress_task.await.expect("progress task");
-
- assert!(output.success);
- assert!(
- made_progress.load(Ordering::SeqCst),
- "the Tokio worker must continue scheduling while custody waits"
- );
- }
-
- #[cfg(unix)]
- #[test]
- fn process_executor_times_out_and_kills_real_helper() {
- let timeout = Duration::from_secs(2);
- let temp = tempfile::tempdir().expect("tempdir");
- let helper_path = temp.path().join("timeout-helper.sh");
- let pid_path = temp.path().join("timeout-helper.pid");
- write_timeout_helper(&helper_path, &pid_path);
-
- let helper_path_for_thread = helper_path.clone();
- let handle = std::thread::spawn(move || {
- let executor = MycProcessCommandExecutor;
- let started_at = Instant::now();
- let err = executor
- .execute(
- &helper_path_for_thread,
- b"{\"operation\":\"describe\"}",
- timeout,
- )
- .expect_err("timeout");
- (started_at.elapsed(), err)
- });
-
- // Give the real helper a little slack to create its pid file under a busy full-test run
- // before we conclude the timeout path never launched it.
- let pid_deadline = Instant::now() + timeout + Duration::from_secs(20);
- let pid = loop {
- match fs::read_to_string(&pid_path) {
- Ok(value) => match value.trim().parse::<u32>() {
- Ok(pid) => break pid,
- Err(_) if Instant::now() < pid_deadline => {
- std::thread::sleep(Duration::from_millis(10));
- }
- Err(error) => panic!("helper pid: {error}"),
- },
- Err(error)
- if error.kind() == std::io::ErrorKind::NotFound
- && Instant::now() < pid_deadline =>
- {
- std::thread::sleep(Duration::from_millis(10));
- }
- Err(error) => panic!("helper pid: {error}"),
- }
- };
-
- let (elapsed, err) = handle.join().expect("executor thread");
-
- assert!(matches!(err, MycExternalCommandExecuteError::TimedOut));
- assert!(
- elapsed < timeout + Duration::from_secs(2),
- "timeout path should stay bounded"
- );
- assert!(!process_exists(pid), "helper process should be terminated");
- }
-}
diff --git a/src/error.rs b/src/error.rs
@@ -1,438 +0,0 @@
-use std::net::SocketAddr;
-use std::path::PathBuf;
-
-use crate::accounts::RadrootsNostrAccountsError;
-use crate::host_identity::IdentityError;
-use crate::nostr_contract::RadrootsNostrError;
-use crate::signer::prelude::RadrootsNostrSignerError;
-use crate::sql::error::SqlError;
-use radroots_nostr_connect::Error as NostrConnectError;
-use thiserror::Error;
-
-use crate::config::MycTransportDeliveryPolicy;
-
-#[derive(Debug, Error)]
-pub enum MycError {
- #[error("invalid config: {0}")]
- InvalidConfig(String),
- #[error("invalid operation: {0}")]
- InvalidOperation(String),
- #[error("invalid log filter `{filter}`: {source}")]
- InvalidLogFilter {
- filter: String,
- #[source]
- source: tracing_subscriber::filter::ParseError,
- },
- #[error("logging already initialized")]
- LoggingAlreadyInitialized,
- #[error("failed to create directory {path}: {source}")]
- CreateDir {
- path: PathBuf,
- #[source]
- source: std::io::Error,
- },
- #[error("persistence io error at {path}: {source}")]
- PersistenceIo {
- path: PathBuf,
- #[source]
- source: std::io::Error,
- },
- #[error("failed to serialize persistence data at {path}: {source}")]
- PersistenceSerialize {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("failed to parse persistence backup manifest at {path}: {source}")]
- PersistenceManifestParse {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("failed to bind observability server at {bind_addr}: {source}")]
- ObservabilityBind {
- bind_addr: SocketAddr,
- #[source]
- source: std::io::Error,
- },
- #[error("observability server failed at {bind_addr}: {source}")]
- ObservabilityServe {
- bind_addr: SocketAddr,
- #[source]
- source: std::io::Error,
- },
- #[error("audit io error at {path}: {source}")]
- AuditIo {
- path: PathBuf,
- #[source]
- source: std::io::Error,
- },
- #[error("audit parse error at {path}:{line_number}: {source}")]
- AuditParse {
- path: PathBuf,
- line_number: usize,
- #[source]
- source: serde_json::Error,
- },
- #[error("failed to serialize audit record at {path}: {source}")]
- AuditSerialize {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("audit sqlite error at {path}: {source}")]
- AuditSql {
- path: PathBuf,
- #[source]
- source: SqlError,
- },
- #[error("audit sqlite decode error at {path}: {source}")]
- AuditSqlDecode {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("delivery outbox sqlite error at {path}: {source}")]
- DeliveryOutboxSql {
- path: PathBuf,
- #[source]
- source: SqlError,
- },
- #[error("delivery outbox sqlite decode error at {path}: {source}")]
- DeliveryOutboxSqlDecode {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("failed to serialize delivery outbox record at {path}: {source}")]
- DeliveryOutboxSerialize {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("invalid delivery outbox job id `{0}`")]
- InvalidDeliveryOutboxJobId(String),
- #[error("delivery outbox job not found: {0}")]
- DeliveryOutboxJobNotFound(String),
- #[error("discovery io error at {path}: {source}")]
- DiscoveryIo {
- path: PathBuf,
- #[source]
- source: std::io::Error,
- },
- #[error("discovery parse error at {path}: {source}")]
- DiscoveryParse {
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error("invalid discovery bundle: {0}")]
- InvalidDiscoveryBundle(String),
- #[error("invalid discovery event: {0}")]
- InvalidDiscoveryEvent(String),
- #[error(
- "failed to fetch discovery state from all configured relays ({relay_count}): {details}"
- )]
- DiscoveryFetchUnavailable { relay_count: usize, details: String },
- #[error("discovery refresh attempt {attempt_id} failed: {source}")]
- DiscoveryRefreshFailed {
- attempt_id: String,
- #[source]
- source: Box<MycError>,
- },
- #[error("custody manager error for {role} identity: {source}")]
- CustodyManager {
- role: String,
- #[source]
- source: RadrootsNostrAccountsError,
- },
- #[error("custody vault error for {role} identity: {source}")]
- CustodyVault {
- role: String,
- #[source]
- source: RadrootsNostrAccountsError,
- },
- #[error(
- "no secret found in custody vault service `{service_name}` for {role} identity `{account_id}`"
- )]
- CustodySecretNotFound {
- role: String,
- service_name: String,
- account_id: String,
- },
- #[error(
- "custody vault service `{service_name}` resolved {role} identity `{resolved_identity_id}` but expected `{account_id}`"
- )]
- CustodySecretIdentityMismatch {
- role: String,
- service_name: String,
- account_id: String,
- resolved_identity_id: String,
- },
- #[error(
- "public identity file {path} resolved {role} identity `{profile_identity_id}` but expected `{account_id}`"
- )]
- CustodyProfileIdentityMismatch {
- role: String,
- path: PathBuf,
- account_id: String,
- profile_identity_id: String,
- },
- #[error("no selected managed account configured for {role} identity store {path}")]
- CustodyManagedAccountNotConfigured { role: String, path: PathBuf },
- #[error(
- "selected managed account `{account_id}` in {path} for {role} identity has no secret in keyring service `{service_name}`"
- )]
- CustodyManagedAccountPublicOnly {
- role: String,
- path: PathBuf,
- service_name: String,
- account_id: String,
- },
- #[error("external custody command io error for {role} identity at {path}: {source}")]
- CustodyExternalCommandIo {
- role: String,
- path: PathBuf,
- #[source]
- source: std::io::Error,
- },
- #[error(
- "external custody command for {role} identity at {path} timed out after {timeout_secs}s"
- )]
- CustodyExternalCommandTimedOut {
- role: String,
- path: PathBuf,
- timeout_secs: u64,
- },
- #[error(
- "external custody command for {role} identity at {path} exceeded the {stream} output limit of {limit_bytes} bytes"
- )]
- CustodyExternalCommandOutputLimit {
- role: String,
- path: PathBuf,
- stream: &'static str,
- limit_bytes: usize,
- },
- #[error(
- "external custody command for {role} identity at {path} failed with status {status}: {diagnostic}"
- )]
- CustodyExternalCommandFailed {
- role: String,
- path: PathBuf,
- status: String,
- diagnostic: &'static str,
- },
- #[error(
- "external custody command response parse error for {role} identity at {path}: {source}"
- )]
- CustodyExternalCommandParse {
- role: String,
- path: PathBuf,
- #[source]
- source: serde_json::Error,
- },
- #[error(
- "external custody command returned invalid public identity for {role} at {path}: {message}"
- )]
- CustodyExternalCommandInvalidIdentity {
- role: String,
- path: PathBuf,
- message: String,
- },
- #[error(
- "external custody command rejected invalid unsigned event for {role} identity at {path} while signing {operation}"
- )]
- CustodyExternalCommandUnsignedEventInvalid {
- role: String,
- path: PathBuf,
- operation: String,
- },
- #[error(
- "external custody command returned a different signed event for {role} identity at {path} while signing {operation}"
- )]
- CustodyExternalCommandSignedEventMismatch {
- role: String,
- path: PathBuf,
- operation: String,
- },
- #[error(
- "external custody command returned an invalid signed event for {role} identity at {path} while signing {operation}"
- )]
- CustodyExternalCommandSignedEventInvalid {
- role: String,
- path: PathBuf,
- operation: String,
- },
- #[error(transparent)]
- Identity(#[from] IdentityError),
- #[error(transparent)]
- Nostr(#[from] RadrootsNostrError),
- #[error(transparent)]
- NostrConnect(#[from] NostrConnectError),
- #[error(transparent)]
- SignerState(#[from] RadrootsNostrSignerError),
- #[error(transparent)]
- Json(#[from] serde_json::Error),
- #[error("NIP-46 decrypt failed: {0}")]
- Nip46Decrypt(String),
- #[error("NIP-46 encrypt failed: {0}")]
- Nip46Encrypt(String),
- #[error("NIP-46 listener notifications closed")]
- Nip46ListenerClosed,
- #[error(
- "Nostr publish failed for {operation} after {attempt_count} attempt(s) with delivery policy {} requiring {required_acknowledged_relay_count} acknowledgements: {details}",
- delivery_policy.as_str()
- )]
- PublishRejected {
- operation: String,
- relay_count: usize,
- acknowledged_relay_count: usize,
- required_acknowledged_relay_count: usize,
- delivery_policy: MycTransportDeliveryPolicy,
- attempt_count: usize,
- details: String,
- rejected_relays: Vec<String>,
- },
- #[error(
- "configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}"
- )]
- SignerIdentityMismatch {
- identity_path: PathBuf,
- state_path: PathBuf,
- configured_identity_id: String,
- persisted_identity_id: String,
- },
- #[error(
- "configured signer identity `{configured_identity_id}` does not match imported signer identity `{imported_identity_id}` from {state_path}"
- )]
- SignerIdentityImportMismatch {
- state_path: PathBuf,
- configured_identity_id: String,
- imported_identity_id: String,
- },
-}
-
-impl From<nostr_sdk::client::Error> for MycError {
- fn from(_: nostr_sdk::client::Error) -> Self {
- Self::InvalidOperation("Nostr client operation failed".to_owned())
- }
-}
-
-impl MycError {
- pub fn with_discovery_refresh_attempt_id(self, attempt_id: impl Into<String>) -> Self {
- match self {
- Self::DiscoveryRefreshFailed { .. } => self,
- source => Self::DiscoveryRefreshFailed {
- attempt_id: attempt_id.into(),
- source: Box::new(source),
- },
- }
- }
-
- pub fn discovery_refresh_attempt_id(&self) -> Option<&str> {
- match self {
- Self::DiscoveryRefreshFailed { attempt_id, .. } => Some(attempt_id.as_str()),
- _ => None,
- }
- }
-
- pub fn publish_rejection_details(&self) -> Option<&str> {
- match self {
- Self::PublishRejected { details, .. } => Some(details.as_str()),
- Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_details(),
- _ => None,
- }
- }
-
- pub fn publish_rejection_counts(&self) -> Option<(usize, usize)> {
- match self {
- Self::PublishRejected {
- relay_count,
- acknowledged_relay_count,
- ..
- } => Some((*relay_count, *acknowledged_relay_count)),
- Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_counts(),
- _ => None,
- }
- }
-
- pub fn publish_rejected_relays(&self) -> Option<&[String]> {
- match self {
- Self::PublishRejected {
- rejected_relays, ..
- } => Some(rejected_relays.as_slice()),
- Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejected_relays(),
- _ => None,
- }
- }
-
- pub fn publish_delivery_policy(&self) -> Option<MycTransportDeliveryPolicy> {
- match self {
- Self::PublishRejected {
- delivery_policy, ..
- } => Some(*delivery_policy),
- Self::DiscoveryRefreshFailed { source, .. } => source.publish_delivery_policy(),
- _ => None,
- }
- }
-
- pub fn publish_attempt_count(&self) -> Option<usize> {
- match self {
- Self::PublishRejected { attempt_count, .. } => Some(*attempt_count),
- Self::DiscoveryRefreshFailed { source, .. } => source.publish_attempt_count(),
- _ => None,
- }
- }
-
- pub fn publish_required_acknowledged_relay_count(&self) -> Option<usize> {
- match self {
- Self::PublishRejected {
- required_acknowledged_relay_count,
- ..
- } => Some(*required_acknowledged_relay_count),
- Self::DiscoveryRefreshFailed { source, .. } => {
- source.publish_required_acknowledged_relay_count()
- }
- _ => None,
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::config::MycTransportDeliveryPolicy;
-
- use super::MycError;
-
- #[test]
- fn discovery_refresh_wrapper_preserves_attempt_id_and_publish_details() {
- let wrapped = MycError::PublishRejected {
- operation: "discovery refresh".to_owned(),
- relay_count: 2,
- acknowledged_relay_count: 0,
- required_acknowledged_relay_count: 1,
- delivery_policy: MycTransportDeliveryPolicy::Any,
- attempt_count: 2,
- details: "relay-a: blocked".to_owned(),
- rejected_relays: vec!["wss://relay-a.example.com".to_owned()],
- }
- .with_discovery_refresh_attempt_id("attempt-1");
-
- assert_eq!(wrapped.discovery_refresh_attempt_id(), Some("attempt-1"));
- assert_eq!(
- wrapped.publish_rejection_details(),
- Some("relay-a: blocked")
- );
- assert_eq!(wrapped.publish_rejection_counts(), Some((2, 0)));
- assert_eq!(
- wrapped.publish_rejected_relays(),
- Some(["wss://relay-a.example.com".to_owned()].as_slice())
- );
- assert_eq!(
- wrapped.publish_delivery_policy(),
- Some(MycTransportDeliveryPolicy::Any)
- );
- assert_eq!(wrapped.publish_required_acknowledged_relay_count(), Some(1));
- assert_eq!(wrapped.publish_attempt_count(), Some(2));
- }
-}
diff --git a/src/host_identity.rs b/src/host_identity.rs
@@ -1,327 +0,0 @@
-//! Myc-owned secret identity container.
-//!
-//! `radroots_identity` deliberately exposes only public, transport-neutral
-//! values. This host-private type keeps service key custody and the legacy
-//! Nostr-facing profile payload inside Myc.
-
-use std::fs;
-use std::path::{Path, PathBuf};
-
-use nostr::nips::nip19::ToBech32;
-use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
-use nostr::{Keys, SecretKey};
-use serde::{Deserialize, Serialize};
-use thiserror::Error;
-
-#[derive(Debug, Error)]
-pub enum IdentityError {
- #[error("identity file missing at {0}")]
- NotFound(PathBuf),
- #[error("identity generation is not permitted for {0}")]
- GenerationNotAllowed(PathBuf),
- #[error("failed to read identity file at {0}")]
- Read(PathBuf, #[source] std::io::Error),
- #[error("failed to create identity directory {0}")]
- CreateDir(PathBuf, #[source] std::io::Error),
- #[error("failed to write identity file at {0}")]
- Write(PathBuf, #[source] std::io::Error),
- #[error("invalid identity JSON")]
- InvalidJson(#[from] serde_json::Error),
- #[error("invalid secret key")]
- InvalidSecretKey(#[from] nostr::key::Error),
- #[error("invalid public key")]
- InvalidPublicKey,
- #[error("public key does not match secret key")]
- PublicKeyMismatch,
- #[error("invalid encrypted secret key")]
- InvalidEncryptedSecretKey,
- #[error("failed to encrypt secret key")]
- EncryptSecretKey,
- #[error("failed to decrypt encrypted secret key")]
- DecryptEncryptedSecretKey,
- #[error("unsupported identity file format")]
- InvalidIdentityFormat,
- #[error("protected identity storage error at {path}: {message}")]
- ProtectedStorage { path: PathBuf, message: String },
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(transparent)]
-pub struct RadrootsIdentityId(String);
-
-impl RadrootsIdentityId {
- pub fn from_public_key(public_key: nostr::PublicKey) -> Result<Self, IdentityError> {
- let key = radroots_nostr::key::public_key_from_nostr(public_key)
- .map_err(|_| IdentityError::InvalidPublicKey)?;
- Ok(Self(
- radroots_identity::IdentityId::from_public_key(key).to_hex(),
- ))
- }
-
- pub fn parse(value: &str) -> Result<Self, IdentityError> {
- radroots_identity::IdentityId::from_hex(value)
- .map(|identity_id| Self(identity_id.to_hex()))
- .map_err(|_| IdentityError::InvalidPublicKey)
- }
-
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn into_string(self) -> String {
- self.0
- }
-
- pub fn to_final(&self) -> radroots_identity::IdentityId {
- radroots_identity::IdentityId::from_hex(self.0.as_str())
- .expect("host identity ids are constructed from validated keys")
- }
-}
-
-impl std::fmt::Display for RadrootsIdentityId {
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.0.fmt(formatter)
- }
-}
-
-impl From<radroots_identity::PublicKey> for RadrootsIdentityId {
- fn from(public_key: radroots_identity::PublicKey) -> Self {
- Self(radroots_identity::IdentityId::from_public_key(public_key).to_hex())
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityProfile {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub identifier: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub metadata: Option<nostr::Event>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub application_handler: Option<nostr::Event>,
-}
-
-impl RadrootsIdentityProfile {
- pub fn is_empty(&self) -> bool {
- self.identifier.is_none() && self.metadata.is_none() && self.application_handler.is_none()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityPublic {
- pub id: RadrootsIdentityId,
- pub public_key_hex: String,
- pub public_key_npub: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub profile: Option<RadrootsIdentityProfile>,
-}
-
-impl PartialEq for RadrootsIdentityPublic {
- fn eq(&self, other: &Self) -> bool {
- self.id == other.id
- && self.public_key_hex == other.public_key_hex
- && self.profile == other.profile
- }
-}
-
-impl Eq for RadrootsIdentityPublic {}
-
-impl RadrootsIdentityPublic {
- pub fn new(public_key: nostr::PublicKey) -> Result<Self, IdentityError> {
- Ok(Self {
- id: RadrootsIdentityId::from_public_key(public_key)?,
- public_key_hex: public_key.to_hex(),
- public_key_npub: public_key
- .to_bech32()
- .expect("validated Nostr public keys encode as npub"),
- profile: None,
- })
- }
-
- pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self {
- self.profile = (!profile.is_empty()).then_some(profile);
- self
- }
-
- pub fn from_final_public_key(
- public_key: radroots_identity::PublicKey,
- ) -> Result<Self, IdentityError> {
- let public_key = radroots_nostr::key::public_key_to_nostr(public_key)
- .map_err(|_| IdentityError::InvalidPublicKey)?;
- Self::new(public_key)
- }
-
- pub fn id(&self) -> &RadrootsIdentityId {
- &self.id
- }
-
- pub fn public_key(&self) -> radroots_identity::PublicKey {
- radroots_identity::PublicKey::from_hex(self.public_key_hex.as_str())
- .expect("host public identities are constructed from validated keys")
- }
-
- pub fn to_final(&self) -> radroots_identity::PublicIdentity {
- radroots_identity::PublicIdentity::new(self.public_key())
- }
-
- pub fn account_id(&self) -> radroots_identity::AccountId {
- self.id.to_final().into()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityFile {
- pub secret_key: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub public_key: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub identifier: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub metadata: Option<nostr::Event>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub application_handler: Option<nostr::Event>,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsIdentity {
- keys: Keys,
- profile: Option<RadrootsIdentityProfile>,
-}
-
-impl RadrootsIdentity {
- pub fn new(keys: Keys) -> Self {
- Self {
- keys,
- profile: None,
- }
- }
-
- pub fn generate() -> Self {
- Self::new(Keys::generate())
- }
-
- pub fn from_secret_key_str(value: &str) -> Result<Self, IdentityError> {
- let secret = SecretKey::parse(value)?;
- Ok(Self::new(Keys::new(secret)))
- }
-
- pub fn from_encrypted_secret_key_str(
- payload: &str,
- password: &str,
- ) -> Result<Self, IdentityError> {
- use nostr::nips::nip19::FromBech32;
- let encrypted = EncryptedSecretKey::from_bech32(payload)
- .map_err(|_| IdentityError::InvalidEncryptedSecretKey)?;
- let secret = encrypted
- .decrypt(password)
- .map_err(|_| IdentityError::DecryptEncryptedSecretKey)?;
- Ok(Self::new(Keys::new(secret)))
- }
-
- pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> {
- let encrypted =
- EncryptedSecretKey::new(self.keys.secret_key(), password, 16, KeySecurity::Unknown)
- .map_err(|_| IdentityError::EncryptSecretKey)?;
- encrypted
- .to_bech32()
- .map_err(|_| IdentityError::EncryptSecretKey)
- }
-
- pub fn keys(&self) -> &Keys {
- &self.keys
- }
-
- pub fn public_key(&self) -> nostr::PublicKey {
- self.keys.public_key()
- }
-
- pub fn final_public_key(&self) -> radroots_identity::PublicKey {
- radroots_nostr::key::public_key_from_nostr(self.public_key())
- .expect("identity keys always contain a valid public key")
- }
-
- pub fn id(&self) -> RadrootsIdentityId {
- RadrootsIdentityId::from_public_key(self.public_key())
- .expect("identity keys always contain a valid public key")
- }
-
- pub fn public_key_hex(&self) -> String {
- self.public_key().to_hex()
- }
-
- pub fn secret_key_hex(&self) -> String {
- self.keys.secret_key().to_secret_hex()
- }
-
- pub fn profile(&self) -> Option<&RadrootsIdentityProfile> {
- self.profile.as_ref()
- }
-
- pub fn set_profile(&mut self, profile: RadrootsIdentityProfile) {
- self.profile = (!profile.is_empty()).then_some(profile);
- }
-
- pub fn to_public(&self) -> RadrootsIdentityPublic {
- let mut public = RadrootsIdentityPublic::new(self.public_key())
- .expect("identity keys always contain a valid public key");
- public.profile = self.profile.clone();
- public
- }
-
- pub fn to_file(&self) -> RadrootsIdentityFile {
- let profile = self.profile.clone().unwrap_or_default();
- RadrootsIdentityFile {
- secret_key: self.secret_key_hex(),
- public_key: Some(self.public_key_hex()),
- identifier: profile.identifier,
- metadata: profile.metadata,
- application_handler: profile.application_handler,
- }
- }
-
- pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> {
- let path = path.as_ref();
- if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) {
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- }
- fs::write(path, serde_json::to_vec_pretty(&self.to_file())?)
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))
- }
-
- pub fn load_from_path_auto(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- let file: RadrootsIdentityFile = serde_json::from_slice(encoded.as_slice())?;
- Self::try_from(file)
- }
-}
-
-impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity {
- type Error = IdentityError;
-
- fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> {
- let mut identity = Self::from_secret_key_str(file.secret_key.as_str())?;
- if file
- .public_key
- .as_deref()
- .is_some_and(|public| public != identity.public_key_hex())
- {
- return Err(IdentityError::PublicKeyMismatch);
- }
- identity.set_profile(RadrootsIdentityProfile {
- identifier: file.identifier,
- metadata: file.metadata,
- application_handler: file.application_handler,
- });
- Ok(identity)
- }
-}
diff --git a/src/identity_files.rs b/src/identity_files.rs
@@ -1,563 +0,0 @@
-use std::ffi::OsString;
-use std::fs::{self, OpenOptions};
-use std::io::Write;
-use std::path::{Path, PathBuf};
-
-use chacha20poly1305::aead::{Aead, KeyInit, Payload};
-use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
-use radroots_secrets::context::{
- EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId,
-};
-use radroots_secrets::envelope::{
- ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION, LegacyV1ResealAuthority, Nonce, SealMaterial,
- SealRequest,
-};
-use radroots_secrets::error::Operation;
-use radroots_secrets::id::{BackendKind, KeyVersion};
-use radroots_secrets::wrapping::{
- BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
-};
-use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef};
-use zeroize::Zeroize;
-
-use crate::host_identity::{
- IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic,
-};
-
-const MYC_IDENTITY_KEY_SLOT: &str = "myc_identity";
-const WRAPPING_KEY_BYTES: usize = 32;
-const WRAPPING_NONCE_BYTES: usize = 24;
-const LEGACY_WRAPPED_KEY_VERSION: u8 = 1;
-const WRAPPED_KEY_VERSION: u8 = 2;
-const WRAPPING_AAD_DOMAIN: &[u8] = b"myc.wrapped_data_key.v2";
-
-struct MycFileKeyWrapping {
- key_path: PathBuf,
-}
-
-impl MycFileKeyWrapping {
- fn new(identity_path: &Path) -> Self {
- Self {
- key_path: encrypted_identity_wrapping_key_path(identity_path),
- }
- }
-
- fn load_or_create_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- if let Ok(raw) = fs::read(&self.key_path) {
- return key_from_bytes(raw.as_slice());
- }
- if let Some(parent) = self
- .key_path
- .parent()
- .filter(|path| !path.as_os_str().is_empty())
- {
- fs::create_dir_all(parent).map_err(|_| secret_backend_failure(Operation::Provision))?;
- }
- let key: [u8; WRAPPING_KEY_BYTES] = rand::random();
- match OpenOptions::new()
- .write(true)
- .create_new(true)
- .open(&self.key_path)
- {
- Ok(mut file) => {
- file.write_all(&key)
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- file.sync_all()
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- set_secret_permissions(&self.key_path)
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- Ok(key)
- }
- Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
- let raw = fs::read(&self.key_path)
- .map_err(|_| secret_backend_failure(Operation::Read))?;
- key_from_bytes(raw.as_slice())
- }
- Err(_) => Err(secret_backend_failure(Operation::Provision)),
- }
- }
-
- fn load_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- let raw = fs::read(&self.key_path).map_err(|_| secret_backend_failure(Operation::Read))?;
- key_from_bytes(raw.as_slice())
- }
-}
-
-impl KeyWrapping for MycFileKeyWrapping {
- fn wrap<'a>(
- &'a self,
- request: WrapRequest<'a>,
- ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Wrap)?;
- let mut key = self.load_or_create_key()?;
- let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random();
- let aad = wrapping_aad(request.reference(), request.context());
- let ciphertext = request.plaintext().expose_secret(|plaintext| {
- XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt(
- XNonce::from_slice(&nonce),
- Payload {
- msg: plaintext,
- aad: aad.as_slice(),
- },
- )
- });
- key.zeroize();
- let ciphertext = ciphertext.map_err(|_| secret_backend_failure(Operation::Wrap))?;
- let mut wrapped = Vec::with_capacity(1 + nonce.len() + ciphertext.len());
- wrapped.push(WRAPPED_KEY_VERSION);
- wrapped.extend_from_slice(&nonce);
- wrapped.extend_from_slice(ciphertext.as_slice());
- WrappedSecret::from_bytes(wrapped)
- })
- }
-
- fn unwrap<'a>(
- &'a self,
- request: UnwrapRequest<'a>,
- ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Unwrap)?;
- let aad = wrapping_aad(request.reference(), request.context());
- self.unwrap_with_aad(request.wrapped(), WRAPPED_KEY_VERSION, aad.as_slice())
- })
- }
-
- fn unwrap_legacy_v1<'a>(
- &'a self,
- request: LegacyV1UnwrapRequest<'a>,
- ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Unwrap)?;
- self.unwrap_with_aad(
- request.wrapped(),
- LEGACY_WRAPPED_KEY_VERSION,
- request.reference().id().as_str().as_bytes(),
- )
- })
- }
-}
-
-impl MycFileKeyWrapping {
- fn unwrap_with_aad(
- &self,
- wrapped: &WrappedSecret,
- expected_version: u8,
- aad: &[u8],
- ) -> Result<SecretMaterial, radroots_secrets::Error> {
- let wrapped = wrapped.as_bytes();
- if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != expected_version {
- return Err(secret_backend_failure(Operation::Unwrap));
- }
- let mut key = self.load_key()?;
- let plaintext = XChaCha20Poly1305::new(Key::from_slice(&key)).decrypt(
- XNonce::from_slice(&wrapped[1..1 + WRAPPING_NONCE_BYTES]),
- Payload {
- msg: &wrapped[1 + WRAPPING_NONCE_BYTES..],
- aad,
- },
- );
- key.zeroize();
- SecretMaterial::from_slice(
- &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?,
- )
- }
-}
-
-fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> {
- let id = reference.id().as_str().as_bytes();
- let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32);
- aad.extend_from_slice(WRAPPING_AAD_DOMAIN);
- aad.extend_from_slice(
- &u16::try_from(id.len())
- .expect("validated secret identifier length fits u16")
- .to_be_bytes(),
- );
- aad.extend_from_slice(id);
- aad.extend_from_slice(&reference.key_version().get().to_be_bytes());
- aad.extend_from_slice(&context.authentication_digest());
- aad
-}
-
-fn validate_identity_reference(
- reference: &SecretRef,
- operation: Operation,
-) -> Result<(), radroots_secrets::Error> {
- if reference.backend() != BackendKind::External
- || reference.key_version().get() != 1
- || reference.id().as_str() != MYC_IDENTITY_KEY_SLOT
- {
- return Err(secret_backend_failure(operation));
- }
- Ok(())
-}
-
-fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> {
- Ok(SecretRef::new(
- SecretId::parse(MYC_IDENTITY_KEY_SLOT)?,
- BackendKind::External,
- KeyVersion::new(1)?,
- ))
-}
-
-fn identity_envelope_context() -> Result<EnvelopeContext, radroots_secrets::Error> {
- Ok(EnvelopeContext::new(
- EnvelopePurpose::parse("radroots.service_identity")?,
- EnvelopeSubject::parse("service", "myc")?,
- PayloadSchemaId::parse("radroots.myc_identity.v1")?,
- ))
-}
-
-fn secret_backend_failure(operation: Operation) -> radroots_secrets::Error {
- radroots_secrets::Error::BackendFailure {
- backend: BackendKind::External,
- operation,
- }
-}
-
-fn key_from_bytes(raw: &[u8]) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- raw.try_into()
- .map_err(|_| secret_backend_failure(Operation::Read))
-}
-
-fn storage_error(path: &Path, operation: &str) -> IdentityError {
- IdentityError::ProtectedStorage {
- path: path.to_path_buf(),
- message: operation.to_owned(),
- }
-}
-
-pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf {
- let mut value = OsString::from(path.as_ref().as_os_str());
- value.push(".key");
- PathBuf::from(value)
-}
-
-pub fn store_encrypted_identity(
- path: impl AsRef<Path>,
- identity: &RadrootsIdentity,
-) -> Result<(), IdentityError> {
- let path = path.as_ref();
- if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) {
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- }
- let payload = serde_json::to_vec(&identity.to_file())?;
- let plaintext = SecretMaterial::from_slice(payload.as_slice())
- .map_err(|_| storage_error(path, "validate identity secret material"))?;
- let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())
- .map_err(|_| storage_error(path, "validate identity data key"))?;
- let wrapping = MycFileKeyWrapping::new(path);
- let context =
- identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?;
- let envelope = futures_executor::block_on(EncryptedEnvelope::seal(
- &wrapping,
- SealRequest::new(
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?,
- context,
- &plaintext,
- SealMaterial::new(data_key, Nonce::new(rand::random())),
- ),
- ))
- .map_err(|_| storage_error(path, "seal encrypted identity"))?;
- let encoded = envelope
- .encode()
- .map_err(|_| storage_error(path, "encode encrypted identity"))?;
- atomic_write(path, encoded.as_slice())
-}
-
-pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- let envelope = EncryptedEnvelope::decode(encoded.as_slice())
- .map_err(|_| storage_error(path, "decode encrypted identity"))?;
- let wrapping = MycFileKeyWrapping::new(path);
- let context =
- identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?;
- if envelope.version() == LEGACY_ENVELOPE_VERSION {
- return migrate_legacy_identity(path, envelope, &wrapping, context);
- }
- if envelope.version() != ENVELOPE_VERSION {
- return Err(storage_error(
- path,
- "unsupported encrypted identity version",
- ));
- }
- open_identity(path, &envelope, &wrapping, &context)
-}
-
-fn open_identity(
- path: &Path,
- envelope: &EncryptedEnvelope,
- wrapping: &MycFileKeyWrapping,
- context: &EnvelopeContext,
-) -> Result<RadrootsIdentity, IdentityError> {
- let payload = futures_executor::block_on(envelope.open(wrapping, context))
- .map_err(|_| storage_error(path, "open encrypted identity"))?;
- let file: RadrootsIdentityFile = payload
- .expose_secret(|bytes| serde_json::from_slice(bytes))
- .map_err(IdentityError::from)?;
- RadrootsIdentity::try_from(file)
-}
-
-fn migrate_legacy_identity(
- path: &Path,
- envelope: EncryptedEnvelope,
- wrapping: &MycFileKeyWrapping,
- context: EnvelopeContext,
-) -> Result<RadrootsIdentity, IdentityError> {
- let expected_reference =
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?;
- let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())
- .map_err(|_| storage_error(path, "validate identity data key"))?;
- let resealed = futures_executor::block_on(envelope.reseal_legacy_v1(
- wrapping,
- &LegacyV1ResealAuthority::new(),
- &expected_reference,
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?,
- context.clone(),
- &valid_identity_payload,
- SealMaterial::new(data_key, Nonce::new(rand::random())),
- ))
- .map_err(|_| storage_error(path, "migrate legacy encrypted identity"))?;
- let envelope = resealed.into_envelope();
- let identity = open_identity(path, &envelope, wrapping, &context)?;
- let encoded = envelope
- .encode()
- .map_err(|_| storage_error(path, "encode migrated identity"))?;
- atomic_write(path, encoded.as_slice())?;
- Ok(identity)
-}
-
-fn valid_identity_payload(bytes: &[u8]) -> bool {
- serde_json::from_slice::<RadrootsIdentityFile>(bytes)
- .ok()
- .and_then(|file| RadrootsIdentity::try_from(file).ok())
- .is_some()
-}
-
-pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> {
- let path = path.as_ref();
- let identity = load_encrypted_identity(path)?;
- let key_path = encrypted_identity_wrapping_key_path(path);
- let old_key =
- fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?;
- fs::remove_file(&key_path).map_err(|source| IdentityError::Write(key_path.clone(), source))?;
- if let Err(error) = store_encrypted_identity(path, &identity) {
- fs::write(&key_path, old_key)
- .map_err(|source| IdentityError::Write(key_path.clone(), source))?;
- set_secret_permissions(&key_path)
- .map_err(|source| IdentityError::Write(key_path, source))?;
- return Err(error);
- }
- Ok(())
-}
-
-pub fn load_identity_profile(
- path: impl AsRef<Path>,
-) -> Result<RadrootsIdentityPublic, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- serde_json::from_slice(encoded.as_slice()).map_err(IdentityError::from)
-}
-
-pub fn store_identity_profile(
- path: impl AsRef<Path>,
- identity: &RadrootsIdentity,
-) -> Result<(), IdentityError> {
- let encoded = serde_json::to_vec_pretty(&identity.to_public())?;
- atomic_write(path.as_ref(), encoded.as_slice())
-}
-
-fn atomic_write(path: &Path, encoded: &[u8]) -> Result<(), IdentityError> {
- let parent = path
- .parent()
- .filter(|value| !value.as_os_str().is_empty())
- .unwrap_or_else(|| Path::new("."));
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- let mut temporary = tempfile::NamedTempFile::new_in(parent)
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- temporary
- .write_all(encoded)
- .and_then(|()| temporary.as_file().sync_all())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- set_file_permissions(temporary.as_file())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- temporary
- .persist(path)
- .map_err(|error| IdentityError::Write(path.to_path_buf(), error.error))?;
- fs::File::open(parent)
- .and_then(|directory| directory.sync_all())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))
-}
-
-#[cfg(unix)]
-fn set_secret_permissions(path: &Path) -> std::io::Result<()> {
- use std::os::unix::fs::PermissionsExt;
- fs::set_permissions(path, fs::Permissions::from_mode(0o600))
-}
-
-#[cfg(not(unix))]
-fn set_secret_permissions(_path: &Path) -> std::io::Result<()> {
- Ok(())
-}
-
-fn set_file_permissions(file: &fs::File) -> std::io::Result<()> {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- file.set_permissions(fs::Permissions::from_mode(0o600))
- }
- #[cfg(not(unix))]
- {
- let _ = file;
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn identity() -> RadrootsIdentity {
- RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity")
- }
-
- #[test]
- fn encrypted_identity_round_trips_and_rotates_wrapping_key() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc");
- let identity = identity();
- store_encrypted_identity(&path, &identity).expect("store");
- let key_path = encrypted_identity_wrapping_key_path(&path);
- let before = fs::read(&key_path).expect("key before");
- assert_eq!(
- load_encrypted_identity(&path).expect("load").id(),
- identity.id()
- );
- rotate_encrypted_identity(&path).expect("rotate");
- assert_ne!(before, fs::read(key_path).expect("key after"));
- assert_eq!(
- load_encrypted_identity(&path).expect("load").id(),
- identity.id()
- );
- let envelope =
- EncryptedEnvelope::decode(&fs::read(&path).expect("read encrypted identity"))
- .expect("decode encrypted identity");
- assert_eq!(envelope.version(), ENVELOPE_VERSION);
- assert_eq!(
- envelope.context(),
- Some(&identity_envelope_context().expect("identity context"))
- );
- }
-
- #[test]
- fn encrypted_identity_migrates_legacy_v1() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc");
- let identity = identity();
- store_legacy_identity(&path, &identity);
-
- assert_eq!(
- load_encrypted_identity(&path)
- .expect("migrate legacy identity")
- .id(),
- identity.id()
- );
- let envelope = EncryptedEnvelope::decode(&fs::read(&path).expect("read migrated identity"))
- .expect("decode migrated identity");
- assert_eq!(envelope.version(), ENVELOPE_VERSION);
- assert_eq!(
- envelope.context(),
- Some(&identity_envelope_context().expect("identity context"))
- );
- }
-
- #[test]
- fn public_profile_round_trips() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.json");
- let identity = identity();
- store_identity_profile(&path, &identity).expect("store profile");
- assert_eq!(
- load_identity_profile(path).expect("load profile").id,
- identity.id()
- );
- }
-
- fn store_legacy_identity(path: &Path, identity: &RadrootsIdentity) {
- const NONCE_BYTES: usize = 24;
- const TAG_BYTES: usize = 16;
-
- let wrapping_key = [0x11; 32];
- let data_key = [0x22; 32];
- let wrapping_nonce = [0x33; NONCE_BYTES];
- let envelope_nonce = [0x44; NONCE_BYTES];
- let payload = serde_json::to_vec(&identity.to_file()).expect("encode identity payload");
- let wrapped_ciphertext = XChaCha20Poly1305::new(Key::from_slice(&wrapping_key))
- .encrypt(
- XNonce::from_slice(&wrapping_nonce),
- Payload {
- msg: &data_key,
- aad: MYC_IDENTITY_KEY_SLOT.as_bytes(),
- },
- )
- .expect("wrap legacy data key");
- let mut wrapped = Vec::with_capacity(1 + NONCE_BYTES + wrapped_ciphertext.len());
- wrapped.push(LEGACY_WRAPPED_KEY_VERSION);
- wrapped.extend_from_slice(&wrapping_nonce);
- wrapped.extend_from_slice(&wrapped_ciphertext);
-
- let id = MYC_IDENTITY_KEY_SLOT.as_bytes();
- let mut encoded = Vec::new();
- encoded.extend_from_slice(b"RRS1");
- encoded.extend_from_slice(&LEGACY_ENVELOPE_VERSION.to_be_bytes());
- encoded.extend_from_slice(&[1, 1, 4]);
- encoded.extend_from_slice(&1_u32.to_be_bytes());
- encoded.extend_from_slice(&u16::try_from(id.len()).expect("id length").to_be_bytes());
- encoded.extend_from_slice(id);
- encoded.extend_from_slice(&envelope_nonce);
- encoded.extend_from_slice(
- &u32::try_from(wrapped.len())
- .expect("wrapped length")
- .to_be_bytes(),
- );
- encoded.extend_from_slice(&wrapped);
- encoded.extend_from_slice(
- &u32::try_from(payload.len() + TAG_BYTES)
- .expect("ciphertext length")
- .to_be_bytes(),
- );
- let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&data_key))
- .encrypt(
- XNonce::from_slice(&envelope_nonce),
- Payload {
- msg: &payload,
- aad: &encoded,
- },
- )
- .expect("encrypt legacy payload");
- encoded.extend_from_slice(&ciphertext);
-
- fs::write(path, encoded).expect("write legacy envelope");
- let key_path = encrypted_identity_wrapping_key_path(path);
- fs::write(&key_path, wrapping_key).expect("write wrapping key");
- set_secret_permissions(&key_path).expect("secure wrapping key");
- }
-}
diff --git a/src/logging.rs b/src/logging.rs
@@ -1,86 +0,0 @@
-use crate::config::MycLoggingConfig;
-use crate::error::MycError;
-use tracing_subscriber::fmt::writer::MakeWriterExt;
-use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
-
-static LOG_GUARD: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
- std::sync::OnceLock::new();
-
-pub fn init_logging(config: &MycLoggingConfig) -> Result<(), MycError> {
- let filter =
- EnvFilter::try_new(config.filter.clone()).map_err(|source| MycError::InvalidLogFilter {
- filter: config.filter.clone(),
- source,
- })?;
- let registry = tracing_subscriber::registry().with(filter);
-
- match config.output_dir.as_deref() {
- Some(directory) => {
- std::fs::create_dir_all(directory).map_err(|source| MycError::CreateDir {
- path: directory.to_path_buf(),
- source,
- })?;
- let appender = tracing_appender::rolling::never(directory, "myc.log");
- let (file_writer, guard) = tracing_appender::non_blocking(appender);
- if config.stdout {
- registry
- .with(
- tracing_subscriber::fmt::layer()
- .with_writer(std::io::stdout.and(file_writer)),
- )
- .try_init()
- .map_err(|_| MycError::LoggingAlreadyInitialized)?;
- } else {
- registry
- .with(tracing_subscriber::fmt::layer().with_writer(file_writer))
- .try_init()
- .map_err(|_| MycError::LoggingAlreadyInitialized)?;
- }
- LOG_GUARD
- .set(guard)
- .map_err(|_| MycError::LoggingAlreadyInitialized)?;
- }
- None if config.stdout => {
- registry
- .with(tracing_subscriber::fmt::layer().with_writer(std::io::stdout))
- .try_init()
- .map_err(|_| MycError::LoggingAlreadyInitialized)?;
- }
- None => {
- return Err(MycError::InvalidOperation(
- "logging requires stdout or an output directory".to_owned(),
- ));
- }
- }
-
- tracing::info!("logging initialized");
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use std::path::PathBuf;
-
- use crate::config::MycLoggingConfig;
-
- #[test]
- fn explicit_logging_configuration_preserves_values() {
- let config = MycLoggingConfig {
- filter: "info,myc=debug".to_owned(),
- output_dir: Some(PathBuf::from("/tmp/myc-logs")),
- stdout: false,
- };
-
- assert_eq!(config.output_dir, Some(PathBuf::from("/tmp/myc-logs")));
- assert!(!config.stdout);
- }
-
- #[test]
- fn stable_log_path_is_host_owned() {
- let directory = PathBuf::from("/tmp/myc-logs");
- assert_eq!(
- directory.join("myc.log"),
- PathBuf::from("/tmp/myc-logs/myc.log")
- );
- }
-}
diff --git a/src/nostr_contract.rs b/src/nostr_contract.rs
@@ -1,114 +0,0 @@
-//! Myc-owned relay client and explicit aliases at the final Nostr boundary.
-
-use std::time::Duration;
-
-pub use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl};
-pub use nostr_sdk::prelude::Output as RadrootsNostrOutput;
-pub use nostr_sdk::{
- RelayPoolNotification as RadrootsNostrRelayPoolNotification,
- RelayStatus as RadrootsNostrRelayStatus,
-};
-pub use radroots_nostr::Error as RadrootsNostrError;
-pub use radroots_nostr::event::{
- ApplicationHandlerSpec as RadrootsNostrApplicationHandlerSpec, Event as RadrootsNostrEvent,
- EventId as RadrootsNostrEventId, ExternalSigningRequest as RadrootsNostrExternalSigningRequest,
- GenericBuilder as RadrootsNostrGenericEventBuilder, Kind as RadrootsNostrKind,
- Metadata as RadrootsNostrMetadata, Timestamp as RadrootsNostrTimestamp,
- build_application_handler as radroots_nostr_build_application_handler_event,
- metadata_has_fields as radroots_nostr_metadata_has_fields,
-};
-pub use radroots_nostr::filter::Filter as RadrootsNostrFilter;
-pub use radroots_nostr::tag::{Tag as RadrootsNostrTag, TagKind as RadrootsNostrTagKind};
-
-pub fn radroots_nostr_filter_tag(
- filter: RadrootsNostrFilter,
- tag: &str,
- values: Vec<String>,
-) -> Result<RadrootsNostrFilter, RadrootsNostrError> {
- radroots_nostr::filter::with_tag(filter, tag, values)
-}
-
-pub fn radroots_nostr_tag_first_value(tag: &RadrootsNostrTag, key: &str) -> Option<String> {
- radroots_nostr::tag::first_value(tag, key)
-}
-
-pub fn radroots_nostr_kind(kind: u16) -> RadrootsNostrKind {
- radroots_nostr::filter::kind(kind)
-}
-
-#[derive(Clone)]
-pub struct RadrootsNostrClient {
- inner: nostr_sdk::Client,
-}
-
-impl RadrootsNostrClient {
- pub fn with_keys(keys: nostr::Keys) -> Self {
- let inner = nostr_sdk::Client::new(keys);
- inner.automatic_authentication(false);
- Self { inner }
- }
-
- pub fn from_identity(identity: &crate::host_identity::RadrootsIdentity) -> Self {
- Self::with_keys(identity.keys().clone())
- }
-
- pub fn from_identity_owned(identity: crate::host_identity::RadrootsIdentity) -> Self {
- Self::with_keys(identity.keys().clone())
- }
-
- pub fn new_signerless() -> Self {
- let inner = nostr_sdk::Client::default();
- inner.automatic_authentication(false);
- Self { inner }
- }
-
- pub fn into_inner(self) -> nostr_sdk::Client {
- self.inner
- }
-
- pub async fn connect(&self) {
- self.inner.connect().await;
- }
-
- pub async fn wait_for_connection(&self, timeout: Duration) {
- self.inner.wait_for_connection(timeout).await;
- }
-
- pub async fn add_relay(&self, url: &str) -> Result<bool, nostr_sdk::client::Error> {
- self.inner.add_relay(url).await
- }
-
- pub async fn relays(&self) -> std::collections::HashMap<nostr::RelayUrl, nostr_sdk::Relay> {
- self.inner.relays().await
- }
-
- pub async fn has_signer(&self) -> bool {
- self.inner.has_signer().await
- }
-
- pub async fn fetch_events(
- &self,
- filter: RadrootsNostrFilter,
- timeout: Duration,
- ) -> Result<Vec<RadrootsNostrEvent>, nostr_sdk::client::Error> {
- self.inner
- .fetch_events(filter, timeout)
- .await
- .map(|events| events.to_vec())
- }
-
- pub async fn subscribe(
- &self,
- filter: RadrootsNostrFilter,
- options: Option<nostr_sdk::SubscribeAutoCloseOptions>,
- ) -> Result<RadrootsNostrOutput<nostr::SubscriptionId>, nostr_sdk::client::Error> {
- self.inner.subscribe(filter, options).await
- }
-
- pub async fn send_event(
- &self,
- event: &RadrootsNostrEvent,
- ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, nostr_sdk::client::Error> {
- self.inner.send_event(event).await
- }
-}
diff --git a/src/policy.rs b/src/policy.rs
@@ -1,924 +0,0 @@
-use std::collections::{BTreeSet, HashMap, VecDeque};
-use std::sync::{Arc, Mutex};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use crate::signer::prelude::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager,
- RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Policy,
-};
-use nostr::PublicKey;
-use radroots_nostr_connect::{
- Method, Permission, Request, message::RequestMessage, permission::Permissions,
-};
-
-use crate::config::{MycConnectionApproval, MycPolicyConfig};
-use crate::error::MycError;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum MycConnectDecision {
- Allow,
- RequireApproval,
- Deny,
-}
-
-#[derive(Debug, Clone)]
-pub struct MycPolicyContext {
- default_connect_decision: MycConnectDecision,
- trusted_client_pubkeys: BTreeSet<String>,
- denied_client_pubkeys: BTreeSet<String>,
- permission_ceiling: Permissions,
- allowed_sign_event_kinds: BTreeSet<u16>,
- auth_url: Option<String>,
- auth_pending_ttl_secs: u64,
- auth_authorized_ttl_secs: Option<u64>,
- reauth_after_inactivity_secs: Option<u64>,
- connect_rate_limiter: Option<MycPolicyRateLimiter>,
- auth_challenge_rate_limiter: Option<MycPolicyRateLimiter>,
-}
-
-#[derive(Debug, Clone)]
-struct MycPolicyRateLimiter {
- window_secs: u64,
- max_attempts: usize,
- entries: Arc<Mutex<HashMap<String, VecDeque<u64>>>>,
-}
-
-impl MycPolicyContext {
- pub fn from_config(config: &MycPolicyConfig) -> Result<Self, MycError> {
- Ok(Self {
- default_connect_decision: match config.connection_approval {
- MycConnectionApproval::NotRequired => MycConnectDecision::Allow,
- MycConnectionApproval::ExplicitUser => MycConnectDecision::RequireApproval,
- MycConnectionApproval::Deny => MycConnectDecision::Deny,
- },
- trusted_client_pubkeys: normalize_public_key_set(&config.trusted_client_pubkeys)?,
- denied_client_pubkeys: normalize_public_key_set(&config.denied_client_pubkeys)?,
- permission_ceiling: normalize_permissions(config.permission_ceiling.clone()),
- allowed_sign_event_kinds: config.allowed_sign_event_kinds.iter().copied().collect(),
- auth_url: config.auth_url.clone(),
- auth_pending_ttl_secs: config.auth_pending_ttl_secs,
- auth_authorized_ttl_secs: config.auth_authorized_ttl_secs,
- reauth_after_inactivity_secs: config.reauth_after_inactivity_secs,
- connect_rate_limiter: build_rate_limiter(
- config.connect_rate_limit_window_secs,
- config.connect_rate_limit_max_attempts,
- ),
- auth_challenge_rate_limiter: build_rate_limiter(
- config.auth_challenge_rate_limit_window_secs,
- config.auth_challenge_rate_limit_max_attempts,
- ),
- })
- }
-
- pub fn default_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement {
- match self.default_connect_decision {
- MycConnectDecision::Allow => RadrootsNostrSignerApprovalRequirement::NotRequired,
- MycConnectDecision::RequireApproval | MycConnectDecision::Deny => {
- RadrootsNostrSignerApprovalRequirement::ExplicitUser
- }
- }
- }
-
- pub fn connect_decision(&self, client_public_key: &PublicKey) -> MycConnectDecision {
- let client_public_key_hex = client_public_key.to_hex();
- if self.denied_client_pubkeys.contains(&client_public_key_hex) {
- return MycConnectDecision::Deny;
- }
- if self.trusted_client_pubkeys.contains(&client_public_key_hex) {
- return MycConnectDecision::Allow;
- }
- self.default_connect_decision
- }
-
- pub fn approval_requirement_for_client(
- &self,
- client_public_key: &PublicKey,
- ) -> Option<RadrootsNostrSignerApprovalRequirement> {
- match self.connect_decision(client_public_key) {
- MycConnectDecision::Allow => Some(RadrootsNostrSignerApprovalRequirement::NotRequired),
- MycConnectDecision::RequireApproval => {
- Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser)
- }
- MycConnectDecision::Deny => None,
- }
- }
-
- pub fn connect_rate_limit_denied_reason(
- &self,
- client_public_key: &PublicKey,
- ) -> Option<String> {
- self.connect_rate_limiter.as_ref().and_then(|limiter| {
- limiter
- .check_and_record(&client_public_key.to_hex())
- .map(|retry_after_secs| throttled_reason("connect attempts", retry_after_secs))
- })
- }
-
- pub fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions {
- self.filtered_requested_permissions(requested_permissions)
- }
-
- pub fn filtered_requested_permissions(
- &self,
- requested_permissions: &Permissions,
- ) -> Permissions {
- let mut filtered = Vec::new();
-
- for permission in requested_permissions.as_slice() {
- if permission.method == Method::SignEvent
- && permission.parameter.is_none()
- && !self.allowed_sign_event_kinds.is_empty()
- {
- for kind in &self.allowed_sign_event_kinds {
- let candidate =
- Permission::with_parameter(Method::SignEvent, format!("kind:{kind}"));
- if self.permission_within_policy(&candidate) {
- filtered.push(candidate);
- }
- }
- continue;
- }
-
- if self.permission_within_policy(permission) {
- filtered.push(permission.clone());
- }
- }
-
- normalize_permissions(filtered.into())
- }
-
- pub fn validate_operator_grants(
- &self,
- granted_permissions: Permissions,
- ) -> Result<Permissions, MycError> {
- let granted_permissions = normalize_permissions(granted_permissions);
- let invalid_permissions = granted_permissions
- .as_slice()
- .iter()
- .filter(|permission| !self.permission_within_policy(permission))
- .map(ToString::to_string)
- .collect::<Vec<_>>();
-
- if invalid_permissions.is_empty() {
- Ok(granted_permissions)
- } else {
- Err(MycError::InvalidOperation(format!(
- "granted permissions exceed the configured policy ceiling: {}",
- invalid_permissions.join(", ")
- )))
- }
- }
-
- pub fn prepare_request<B: RadrootsNostrSignerBackend>(
- &self,
- backend: &B,
- connection: &RadrootsNostrSignerConnectionRecord,
- request_message: &RequestMessage,
- ) -> Result<Option<String>, MycError> {
- if self.client_is_denied(&connection.client_public_key) {
- return Ok(Some("client public key denied by policy".to_owned()));
- }
-
- if let Some(reason) = self.request_denied_reason(&request_message.request) {
- return Ok(Some(reason));
- }
-
- if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending
- && self.auth_challenge_is_expired(connection)
- {
- if self.request_uses_automatic_auth(connection, &request_message.request) {
- if let Some(reason) =
- self.require_auth_challenge_with_guardrails(backend, connection)?
- {
- return Ok(Some(reason));
- }
- } else {
- return Ok(Some(
- "auth challenge expired; require a new auth challenge".to_owned(),
- ));
- }
- } else if self.should_require_fresh_auth(connection, &request_message.request)
- && let Some(reason) =
- self.require_auth_challenge_with_guardrails(backend, connection)?
- {
- return Ok(Some(reason));
- }
-
- Ok(None)
- }
-
- pub fn ensure_authorize_auth_challenge_allowed(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> Result<(), MycError> {
- if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending
- && self.auth_challenge_is_expired(connection)
- {
- return Err(MycError::InvalidOperation(
- "auth challenge expired; require a new auth challenge".to_owned(),
- ));
- }
- Ok(())
- }
-
- pub fn cleanup_stale_sessions(
- &self,
- manager: &RadrootsNostrSignerManager,
- ) -> Result<usize, MycError> {
- let mut cleaned = 0usize;
- for connection in manager.list_connections()? {
- if !self.stale_session_requires_cleanup(&connection) {
- continue;
- }
- self.require_auth_challenge_with_manager(manager, &connection)?;
- cleaned += 1;
- }
- Ok(cleaned)
- }
-
- fn client_is_denied(&self, client_public_key: &PublicKey) -> bool {
- self.denied_client_pubkeys
- .contains(&client_public_key.to_hex())
- }
-
- fn client_is_trusted(&self, client_public_key: &PublicKey) -> bool {
- self.trusted_client_pubkeys
- .contains(&client_public_key.to_hex())
- }
-
- fn permission_within_policy(&self, permission: &Permission) -> bool {
- if permission.method == Method::SignEvent && !self.allowed_sign_event_kinds.is_empty() {
- let Some(kind) = permission
- .parameter
- .as_deref()
- .and_then(parse_sign_event_kind_parameter)
- else {
- return false;
- };
- if !self.allowed_sign_event_kinds.contains(&kind) {
- return false;
- }
- }
-
- if self.permission_ceiling.is_empty() {
- return true;
- }
-
- self.permission_ceiling
- .as_slice()
- .iter()
- .any(|ceiling| permission_within_ceiling(permission, ceiling))
- }
-
- fn request_denied_reason(&self, request: &Request) -> Option<String> {
- if self.permission_ceiling.is_empty()
- && (self.allowed_sign_event_kinds.is_empty()
- || !matches!(request, Request::SignEvent(_)))
- {
- return None;
- }
-
- let required_permission = required_permission_for_request(request)?;
- if self.permission_within_policy(&required_permission) {
- None
- } else {
- Some(format!(
- "request {} is outside the configured policy ceiling",
- request.method()
- ))
- }
- }
-
- fn request_uses_automatic_auth(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- request: &Request,
- ) -> bool {
- self.automatic_auth_enabled_for_connection(connection) && request_requires_auth(request)
- }
-
- fn should_require_fresh_auth(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- request: &Request,
- ) -> bool {
- if !self.request_uses_automatic_auth(connection, request) {
- return false;
- }
-
- if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending {
- return false;
- }
-
- let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else {
- return true;
- };
- let now_unix = now_unix_secs();
-
- if self
- .auth_authorized_ttl_secs
- .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl))
- {
- return true;
- }
-
- self.reauth_after_inactivity_secs.is_some_and(|ttl| {
- let Some(last_request_at_unix) = connection.last_request_at_unix else {
- return false;
- };
- now_unix > last_request_at_unix.saturating_add(ttl)
- })
- }
-
- fn auth_challenge_is_expired(&self, connection: &RadrootsNostrSignerConnectionRecord) -> bool {
- let Some(auth_challenge) = connection.auth_challenge.as_ref() else {
- return false;
- };
- now_unix_secs()
- > auth_challenge
- .required_at_unix
- .saturating_add(self.auth_pending_ttl_secs)
- }
-
- fn auth_url(&self) -> Result<&str, MycError> {
- self.auth_url.as_deref().ok_or_else(|| {
- MycError::InvalidOperation(
- "automatic auth policy requires policy.auth_url to be configured".to_owned(),
- )
- })
- }
-
- fn automatic_auth_enabled_for_connection(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> bool {
- self.auth_url.is_some() && self.client_is_trusted(&connection.client_public_key)
- }
-
- fn require_auth_challenge_with_guardrails<B: RadrootsNostrSignerBackend>(
- &self,
- backend: &B,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> Result<Option<String>, MycError> {
- if let Some(retry_after_secs) = self
- .auth_challenge_rate_limiter
- .as_ref()
- .and_then(|limiter| limiter.check_and_record(&connection.client_public_key.to_hex()))
- {
- return Ok(Some(throttled_reason(
- "auth challenge issuance",
- retry_after_secs,
- )));
- }
- self.require_auth_challenge_with_backend(backend, connection)?;
- Ok(None)
- }
-
- fn require_auth_challenge_with_backend<B: RadrootsNostrSignerBackend>(
- &self,
- backend: &B,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> Result<(), MycError> {
- backend.require_auth_challenge(&connection.connection_id, self.auth_url()?)?;
- Ok(())
- }
-
- fn require_auth_challenge_with_manager(
- &self,
- manager: &RadrootsNostrSignerManager,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> Result<(), MycError> {
- manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?;
- Ok(())
- }
-
- fn stale_session_requires_cleanup(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- ) -> bool {
- if connection.is_terminal()
- || connection.auth_state
- != crate::signer::prelude::RadrootsNostrSignerAuthState::Authorized
- || !self.automatic_auth_enabled_for_connection(connection)
- {
- return false;
- }
-
- let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else {
- return true;
- };
- let now_unix = now_unix_secs();
-
- if self
- .auth_authorized_ttl_secs
- .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl))
- {
- return true;
- }
-
- self.reauth_after_inactivity_secs.is_some_and(|ttl| {
- connection
- .last_request_at_unix
- .is_some_and(|last_request_at_unix| {
- now_unix > last_request_at_unix.saturating_add(ttl)
- })
- })
- }
-}
-
-impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for MycPolicyContext {
- fn connect_decision(
- &self,
- client_public_key: &PublicKey,
- ) -> RadrootsNostrSignerNip46ConnectDecision {
- match self.connect_decision(client_public_key) {
- MycConnectDecision::Allow => RadrootsNostrSignerNip46ConnectDecision::Allow,
- MycConnectDecision::RequireApproval => {
- RadrootsNostrSignerNip46ConnectDecision::RequireApproval
- }
- MycConnectDecision::Deny => RadrootsNostrSignerNip46ConnectDecision::Deny,
- }
- }
-
- fn connect_rate_limit_denied_reason(&self, client_public_key: &PublicKey) -> Option<String> {
- self.connect_rate_limit_denied_reason(client_public_key)
- }
-
- fn approval_requirement_for_client(
- &self,
- client_public_key: &PublicKey,
- ) -> Option<RadrootsNostrSignerApprovalRequirement> {
- self.approval_requirement_for_client(client_public_key)
- }
-
- fn filtered_requested_permissions(&self, requested_permissions: &Permissions) -> Permissions {
- self.filtered_requested_permissions(requested_permissions)
- }
-
- fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions {
- self.auto_granted_permissions(requested_permissions)
- }
-
- fn prepare_request(
- &self,
- backend: &B,
- connection: &RadrootsNostrSignerConnectionRecord,
- request_message: &RequestMessage,
- ) -> Result<Option<String>, crate::signer::prelude::RadrootsNostrSignerError> {
- self.prepare_request(backend, connection, request_message)
- .map_err(myc_policy_signer_error)
- }
-}
-
-impl MycPolicyRateLimiter {
- fn check_and_record(&self, key: &str) -> Option<u64> {
- let now_unix = now_unix_secs();
- let mut guard = self
- .entries
- .lock()
- .unwrap_or_else(|poisoned| poisoned.into_inner());
- let attempts = guard.entry(key.to_owned()).or_default();
- prune_attempts(attempts, now_unix, self.window_secs);
- if attempts.len() >= self.max_attempts {
- return Some(
- attempts
- .front()
- .copied()
- .map(|oldest_attempt_unix| {
- oldest_attempt_unix
- .saturating_add(self.window_secs)
- .saturating_sub(now_unix)
- .max(1)
- })
- .unwrap_or(1),
- );
- }
- attempts.push_back(now_unix);
- None
- }
-}
-
-fn normalize_permissions(permissions: Permissions) -> Permissions {
- let mut permissions = permissions.into_vec();
- permissions.sort();
- permissions.dedup();
- permissions.into()
-}
-
-fn normalize_public_key_set(values: &[String]) -> Result<BTreeSet<String>, MycError> {
- values
- .iter()
- .map(|value| normalize_public_key_hex(value))
- .collect()
-}
-
-fn normalize_public_key_hex(value: &str) -> Result<String, MycError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(MycError::InvalidConfig(
- "policy client pubkeys must not contain empty values".to_owned(),
- ));
- }
- let public_key = PublicKey::parse(trimmed)
- .or_else(|_| PublicKey::from_hex(trimmed))
- .map_err(|_| {
- MycError::InvalidConfig(format!(
- "policy client pubkey `{trimmed}` is not a valid nostr public key"
- ))
- })?;
- Ok(public_key.to_hex())
-}
-
-fn required_permission_for_request(request: &Request) -> Option<Permission> {
- match request {
- Request::Connect { .. }
- | Request::GetPublicKey
- | Request::GetSessionCapability
- | Request::Ping
- | Request::Logout => None,
- Request::SignEvent(unsigned_event) => Some(Permission::with_parameter(
- Method::SignEvent,
- format!("kind:{}", unsigned_event.kind()),
- )),
- Request::Nip04Encrypt { .. } => Some(Permission::new(Method::Nip04Encrypt)),
- Request::Nip04Decrypt { .. } => Some(Permission::new(Method::Nip04Decrypt)),
- Request::Nip44Encrypt { .. } => Some(Permission::new(Method::Nip44Encrypt)),
- Request::Nip44Decrypt { .. } => Some(Permission::new(Method::Nip44Decrypt)),
- Request::SwitchRelays => Some(Permission::new(Method::SwitchRelays)),
- Request::Custom { method, .. } => Some(Permission::new(method.clone())),
- }
-}
-
-fn permission_within_ceiling(permission: &Permission, ceiling: &Permission) -> bool {
- if permission.method != ceiling.method {
- return false;
- }
-
- match (
- &permission.method,
- permission.parameter.as_deref(),
- ceiling.parameter.as_deref(),
- ) {
- (Method::SignEvent, _, None) => true,
- (Method::SignEvent, Some(parameter), Some(ceiling_parameter)) => {
- sign_event_parameter_eq(parameter, ceiling_parameter)
- }
- (Method::SignEvent, None, Some(_)) => false,
- (_, _, None) => true,
- (_, Some(parameter), Some(ceiling_parameter)) => parameter == ceiling_parameter,
- (_, None, Some(_)) => false,
- }
-}
-
-fn sign_event_parameter_eq(left: &str, right: &str) -> bool {
- parse_sign_event_kind_parameter(left) == parse_sign_event_kind_parameter(right)
-}
-
-fn parse_sign_event_kind_parameter(value: &str) -> Option<u16> {
- value
- .strip_prefix("kind:")
- .unwrap_or(value)
- .parse::<u16>()
- .ok()
-}
-
-fn request_requires_auth(request: &Request) -> bool {
- !matches!(
- request,
- Request::Connect { .. }
- | Request::GetPublicKey
- | Request::GetSessionCapability
- | Request::Ping
- | Request::Logout
- )
-}
-
-fn build_rate_limiter(
- window_secs: Option<u64>,
- max_attempts: Option<usize>,
-) -> Option<MycPolicyRateLimiter> {
- match (window_secs, max_attempts) {
- (Some(window_secs), Some(max_attempts)) => Some(MycPolicyRateLimiter {
- window_secs,
- max_attempts,
- entries: Arc::new(Mutex::new(HashMap::new())),
- }),
- _ => None,
- }
-}
-
-fn prune_attempts(attempts: &mut VecDeque<u64>, now_unix: u64, window_secs: u64) {
- while attempts
- .front()
- .copied()
- .is_some_and(|attempt_unix| now_unix > attempt_unix.saturating_add(window_secs))
- {
- let _ = attempts.pop_front();
- }
-}
-
-fn throttled_reason(label: &str, retry_after_secs: u64) -> String {
- format!("{label} throttled by policy; retry after {retry_after_secs}s")
-}
-
-fn now_unix_secs() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_secs())
- .unwrap_or_default()
-}
-
-fn myc_policy_signer_error(error: MycError) -> crate::signer::prelude::RadrootsNostrSignerError {
- crate::signer::prelude::RadrootsNostrSignerError::InvalidState(error.to_string())
-}
-
-#[cfg(test)]
-mod tests {
- use super::{MycConnectDecision, MycPolicyContext};
- use crate::config::{MycConnectionApproval, MycPolicyConfig};
- use crate::host_identity::RadrootsIdentity;
- use crate::signer::prelude::{
- RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerApprovalRequirement,
- RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerManager,
- };
- use nostr::PublicKey;
- use radroots_nostr_connect::{
- Method, Permission, Request, message::RequestMessage, permission::Permissions,
- };
- use serde_json::json;
- use std::thread;
- use std::time::Duration;
-
- fn public_key(hex: &str) -> PublicKey {
- PublicKey::parse(hex).expect("public key")
- }
-
- fn identity(secret_key: &str) -> RadrootsIdentity {
- RadrootsIdentity::from_secret_key_str(secret_key).expect("identity")
- }
-
- fn in_memory_manager() -> RadrootsNostrSignerManager {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(
- identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
- .to_public(),
- )
- .expect("set signer identity");
- manager
- }
-
- fn backend_for(manager: &RadrootsNostrSignerManager) -> RadrootsNostrEmbeddedSignerBackend {
- RadrootsNostrEmbeddedSignerBackend::new(
- manager.clone(),
- identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
- .keys()
- .clone(),
- )
- .expect("backend")
- }
-
- fn register_connection(
- manager: &RadrootsNostrSignerManager,
- client_public_key: PublicKey,
- ) -> crate::signer::prelude::RadrootsNostrSignerConnectionRecord {
- manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- client_public_key,
- identity("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
- .to_public(),
- )
- .with_requested_permissions(
- vec![Permission::with_parameter(Method::SignEvent, "kind:1")].into(),
- )
- .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired),
- )
- .expect("register connection")
- }
-
- fn unsigned_event(kind: u16) -> radroots_nostr_connect::message::UnsignedEvent {
- radroots_nostr_connect::message::UnsignedEvent::from_json(&json!({
- "pubkey": public_key("1111111111111111111111111111111111111111111111111111111111111111").to_hex(),
- "created_at": 1,
- "kind": kind,
- "tags": [],
- "content": "hello"
- }).to_string())
- .expect("unsigned event")
- }
-
- #[test]
- fn connect_decision_prefers_deny_then_trust_then_default() {
- let config = MycPolicyConfig {
- connection_approval: MycConnectionApproval::ExplicitUser,
- trusted_client_pubkeys: vec![
- "2222222222222222222222222222222222222222222222222222222222222222".to_owned(),
- ],
- denied_client_pubkeys: vec![
- "3333333333333333333333333333333333333333333333333333333333333333".to_owned(),
- ],
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
-
- assert_eq!(
- policy.connect_decision(&public_key(
- "2222222222222222222222222222222222222222222222222222222222222222"
- )),
- MycConnectDecision::Allow
- );
- assert_eq!(
- policy.connect_decision(&public_key(
- "3333333333333333333333333333333333333333333333333333333333333333"
- )),
- MycConnectDecision::Deny
- );
- assert_eq!(
- policy.connect_decision(&public_key(
- "4444444444444444444444444444444444444444444444444444444444444444"
- )),
- MycConnectDecision::RequireApproval
- );
- }
-
- #[test]
- fn auto_granted_permissions_apply_policy_ceiling_and_kind_limits() {
- let config = MycPolicyConfig {
- permission_ceiling: vec![
- Permission::new(Method::Nip04Encrypt),
- Permission::with_parameter(Method::SignEvent, "kind:1"),
- ]
- .into(),
- allowed_sign_event_kinds: vec![1],
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
-
- let requested_permissions: Permissions = vec![
- Permission::new(Method::Nip04Encrypt),
- Permission::new(Method::SignEvent),
- Permission::with_parameter(Method::SignEvent, "kind:2"),
- ]
- .into();
- let filtered = policy.auto_granted_permissions(&requested_permissions);
-
- assert_eq!(filtered.to_string(), "nip04_encrypt,sign_event:kind:1");
- }
-
- #[test]
- fn request_denied_reason_applies_sign_event_kind_limits() {
- let config = MycPolicyConfig {
- allowed_sign_event_kinds: vec![1],
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
- let manager = in_memory_manager();
- let backend = backend_for(&manager);
- let connection = register_connection(
- &manager,
- public_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
- );
-
- let denied = policy
- .prepare_request(
- &backend,
- &connection,
- &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(2))),
- )
- .expect("prepare request");
-
- assert_eq!(
- denied,
- Some("request sign_event is outside the configured policy ceiling".to_owned())
- );
- }
-
- #[test]
- fn validate_operator_grants_rejects_out_of_policy_permissions() {
- let config = MycPolicyConfig {
- permission_ceiling: Permissions::from(vec![Permission::new(Method::Nip04Encrypt)]),
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
-
- let error = policy
- .validate_operator_grants(vec![Permission::new(Method::Nip44Encrypt)].into())
- .expect_err("grant outside ceiling");
- assert!(
- error
- .to_string()
- .contains("granted permissions exceed the configured policy ceiling")
- );
- }
-
- #[test]
- fn prepare_request_requires_fresh_auth_after_authorized_ttl() {
- let client_public_key =
- public_key("2222222222222222222222222222222222222222222222222222222222222222");
- let config = MycPolicyConfig {
- trusted_client_pubkeys: vec![client_public_key.to_hex()],
- auth_url: Some("https://auth.example".to_owned()),
- auth_authorized_ttl_secs: Some(1),
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
- let manager = in_memory_manager();
- let backend = backend_for(&manager);
- let connection = register_connection(&manager, client_public_key);
-
- manager
- .require_auth_challenge(&connection.connection_id, "https://auth.example")
- .expect("require auth challenge");
- manager
- .authorize_auth_challenge(&connection.connection_id)
- .expect("authorize auth challenge");
- thread::sleep(Duration::from_secs(2));
-
- let connection = manager
- .get_connection(&connection.connection_id)
- .expect("connection lookup")
- .expect("connection");
- let denied = policy
- .prepare_request(
- &backend,
- &connection,
- &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(1))),
- )
- .expect("prepare request");
-
- assert_eq!(denied, None);
- let updated_connection = manager
- .get_connection(&connection.connection_id)
- .expect("connection lookup")
- .expect("connection");
- assert_eq!(
- updated_connection.auth_state,
- RadrootsNostrSignerAuthState::Pending
- );
- assert_eq!(
- updated_connection
- .auth_challenge
- .expect("auth challenge")
- .auth_url,
- "https://auth.example/"
- );
- }
-
- #[test]
- fn prepare_request_requires_fresh_auth_after_inactivity() {
- let client_public_key =
- public_key("2323232323232323232323232323232323232323232323232323232323232323");
- let config = MycPolicyConfig {
- trusted_client_pubkeys: vec![client_public_key.to_hex()],
- auth_url: Some("https://auth.example".to_owned()),
- reauth_after_inactivity_secs: Some(1),
- ..MycPolicyConfig::default()
- };
- let policy = MycPolicyContext::from_config(&config).expect("policy");
- let manager = in_memory_manager();
- let backend = backend_for(&manager);
- let connection = register_connection(&manager, client_public_key);
-
- manager
- .require_auth_challenge(&connection.connection_id, "https://auth.example")
- .expect("require auth challenge");
- manager
- .authorize_auth_challenge(&connection.connection_id)
- .expect("authorize auth challenge");
- manager
- .record_request(
- &connection.connection_id,
- "request-0",
- Method::SignEvent,
- crate::signer::prelude::RadrootsNostrSignerRequestDecision::Allowed,
- None,
- )
- .expect("record request");
- thread::sleep(Duration::from_secs(2));
-
- let connection = manager
- .get_connection(&connection.connection_id)
- .expect("connection lookup")
- .expect("connection");
- let denied = policy
- .prepare_request(
- &backend,
- &connection,
- &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(1))),
- )
- .expect("prepare request");
-
- assert_eq!(denied, None);
- let updated_connection = manager
- .get_connection(&connection.connection_id)
- .expect("connection lookup")
- .expect("connection");
- assert_eq!(
- updated_connection.auth_state,
- RadrootsNostrSignerAuthState::Pending
- );
- }
-}
diff --git a/src/signer/backend.rs b/src/signer/backend.rs
@@ -1,1753 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::capability::{
- RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
- RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerCapability,
-};
-use crate::signer::error::RadrootsNostrSignerError;
-use crate::signer::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerSessionLookup,
-};
-use crate::signer::manager::RadrootsNostrSignerManager;
-use crate::signer::model::{
- RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
- RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision, RadrootsNostrSignerWorkflowId,
-};
-use nostr::{Event, Keys, PublicKey, RelayUrl, UnsignedEvent};
-use radroots_identity::PublicKey as IdentityPublicKey;
-use radroots_nostr_connect::{Method, Request, message::RequestMessage, permission::Permissions};
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerBackendCapabilities {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub local_signer: Option<RadrootsNostrLocalSignerCapability>,
- #[serde(default)]
- pub remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>,
-}
-
-/// Result of signing an externally supplied unsigned Nostr event.
-///
-/// This low-level protocol result does not establish Radroots typed-authoring
-/// validity for the event.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerSignOutput {
- pub signer: RadrootsNostrSignerCapability,
- pub event: Event,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case", tag = "state", content = "value")]
-pub enum RadrootsNostrSignerPublishTransition {
- Begun(RadrootsNostrSignerPublishWorkflowRecord),
- MarkedPublished(RadrootsNostrSignerPublishWorkflowRecord),
- Finalized {
- workflow_id: RadrootsNostrSignerWorkflowId,
- connection: Box<RadrootsNostrSignerConnectionRecord>,
- },
- Cancelled(RadrootsNostrSignerPublishWorkflowRecord),
-}
-
-pub trait RadrootsNostrSignerBackend: Send + Sync {
- fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError>;
-
- fn set_signer_identity(
- &self,
- signer_identity: PublicIdentity,
- ) -> Result<(), RadrootsNostrSignerError>;
-
- fn capabilities(
- &self,
- ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError>;
-
- fn list_connections(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>;
-
- fn get_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>;
-
- fn list_publish_workflows(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>;
-
- fn get_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>;
-
- fn find_connections_by_client_public_key(
- &self,
- client_public_key: &PublicKey,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>;
-
- fn find_connection_by_connect_secret(
- &self,
- connect_secret: &str,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>;
-
- fn lookup_session(
- &self,
- client_public_key: &PublicKey,
- connect_secret: Option<&str>,
- ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError>;
-
- fn evaluate_connect_request(
- &self,
- client_public_key: PublicKey,
- request: Request,
- ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError>;
-
- fn register_connection(
- &self,
- draft: RadrootsNostrSignerConnectionDraft,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn set_granted_permissions(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn approve_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn reject_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn revoke_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn update_relays(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- relays: Vec<RelayUrl>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn require_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- auth_url: &str,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn set_pending_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn authorize_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError>;
-
- fn restore_pending_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- pending_request: RadrootsNostrSignerPendingRequest,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn begin_connect_secret_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>;
-
- fn begin_auth_replay_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>;
-
- fn mark_publish_workflow_published(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>;
-
- fn finalize_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>;
-
- fn cancel_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>;
-
- fn mark_authenticated(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn mark_connect_secret_consumed(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>;
-
- fn evaluate_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>;
-
- fn evaluate_auth_replay_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>;
-
- fn record_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_id: &str,
- method: Method,
- decision: RadrootsNostrSignerRequestDecision,
- message: Option<String>,
- ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError>;
-
- /// Signs an externally supplied unsigned Nostr event.
- ///
- /// This is a low-level interoperability boundary used by generic signer
- /// protocols. It does not validate or confer a Radroots product-authoring
- /// contract; product events must use their typed authoring boundary.
- fn sign_unsigned_event(
- &self,
- unsigned_event: UnsignedEvent,
- ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError>;
-}
-
-#[derive(Clone)]
-pub struct RadrootsNostrEmbeddedSignerBackend {
- manager: RadrootsNostrSignerManager,
- signer_keys: Keys,
- signer_identity: PublicIdentity,
-}
-
-impl RadrootsNostrSignerBackendCapabilities {
- pub fn new(
- local_signer: Option<RadrootsNostrLocalSignerCapability>,
- remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>,
- ) -> Self {
- Self {
- local_signer,
- remote_sessions,
- }
- }
-
- pub fn all_signers(&self) -> Vec<RadrootsNostrSignerCapability> {
- let mut signers = Vec::new();
- if let Some(local_signer) = self.local_signer.clone() {
- signers.push(RadrootsNostrSignerCapability::LocalAccount(Box::new(
- local_signer,
- )));
- }
- signers.extend(
- self.remote_sessions
- .iter()
- .cloned()
- .map(Box::new)
- .map(RadrootsNostrSignerCapability::RemoteSession),
- );
- signers
- }
-}
-
-impl RadrootsNostrSignerSignOutput {
- pub fn new(signer: RadrootsNostrSignerCapability, event: Event) -> Self {
- Self { signer, event }
- }
-}
-
-impl RadrootsNostrSignerPublishTransition {
- pub fn begun(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self {
- Self::Begun(workflow)
- }
-
- pub fn marked_published(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self {
- Self::MarkedPublished(workflow)
- }
-
- pub fn finalized(
- workflow_id: RadrootsNostrSignerWorkflowId,
- connection: RadrootsNostrSignerConnectionRecord,
- ) -> Self {
- Self::Finalized {
- workflow_id,
- connection: Box::new(connection),
- }
- }
-
- pub fn cancelled(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self {
- Self::Cancelled(workflow)
- }
-
- pub fn workflow(&self) -> Option<&RadrootsNostrSignerPublishWorkflowRecord> {
- match self {
- Self::Begun(workflow) | Self::MarkedPublished(workflow) | Self::Cancelled(workflow) => {
- Some(workflow)
- }
- Self::Finalized { .. } => None,
- }
- }
-
- pub fn finalized_connection(&self) -> Option<&RadrootsNostrSignerConnectionRecord> {
- match self {
- Self::Finalized { connection, .. } => Some(connection.as_ref()),
- _ => None,
- }
- }
-}
-
-impl RadrootsNostrEmbeddedSignerBackend {
- pub fn new(
- manager: RadrootsNostrSignerManager,
- signer_keys: Keys,
- ) -> Result<Self, RadrootsNostrSignerError> {
- let signer_identity = public_identity_from_keys(&signer_keys)?;
- let existing_identity = manager.signer_identity()?;
- if let Some(existing_identity) = existing_identity {
- if !same_public_identity_key(&existing_identity, &signer_identity) {
- return Err(RadrootsNostrSignerError::InvalidState(
- "embedded signer identity does not match signer manager identity".into(),
- ));
- }
- } else {
- manager.set_signer_identity(signer_identity.clone())?;
- }
-
- Ok(Self {
- manager,
- signer_keys,
- signer_identity,
- })
- }
-
- pub fn new_in_memory(signer_keys: Keys) -> Result<Self, RadrootsNostrSignerError> {
- Self::new(RadrootsNostrSignerManager::new_in_memory(), signer_keys)
- }
-
- pub fn manager(&self) -> &RadrootsNostrSignerManager {
- &self.manager
- }
-
- pub fn local_keys(&self) -> &Keys {
- &self.signer_keys
- }
-
- fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability {
- let public_identity = self.signer_identity.clone();
- RadrootsNostrLocalSignerCapability::new(
- public_identity.id.to_final().into(),
- public_identity,
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- )
- }
-}
-
-impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
- fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> {
- self.manager.signer_identity()
- }
-
- fn set_signer_identity(
- &self,
- signer_identity: PublicIdentity,
- ) -> Result<(), RadrootsNostrSignerError> {
- self.manager.set_signer_identity(signer_identity)
- }
-
- fn capabilities(
- &self,
- ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> {
- let mut remote_sessions = Vec::new();
- for record in self.manager.list_connections()? {
- if record.status == RadrootsNostrSignerConnectionStatus::Active {
- remote_sessions.push(RadrootsNostrRemoteSessionSignerCapability::from(&record));
- }
- }
- Ok(RadrootsNostrSignerBackendCapabilities::new(
- Some(self.local_signer_capability()),
- remote_sessions,
- ))
- }
-
- fn list_connections(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- self.manager.list_connections()
- }
-
- fn get_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- self.manager.get_connection(connection_id)
- }
-
- fn list_publish_workflows(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
- self.manager.list_publish_workflows()
- }
-
- fn get_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
- self.manager.get_publish_workflow(workflow_id)
- }
-
- fn find_connections_by_client_public_key(
- &self,
- client_public_key: &PublicKey,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- self.manager
- .find_connections_by_client_public_key(client_public_key)
- }
-
- fn find_connection_by_connect_secret(
- &self,
- connect_secret: &str,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- self.manager
- .find_connection_by_connect_secret(connect_secret)
- }
-
- fn lookup_session(
- &self,
- client_public_key: &PublicKey,
- connect_secret: Option<&str>,
- ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> {
- self.manager
- .lookup_session(client_public_key, connect_secret)
- }
-
- fn evaluate_connect_request(
- &self,
- client_public_key: PublicKey,
- request: Request,
- ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> {
- self.manager
- .evaluate_connect_request(client_public_key, request)
- }
-
- fn register_connection(
- &self,
- draft: RadrootsNostrSignerConnectionDraft,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.register_connection(draft)
- }
-
- fn set_granted_permissions(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager
- .set_granted_permissions(connection_id, granted_permissions)
- }
-
- fn approve_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager
- .approve_connection(connection_id, granted_permissions)
- }
-
- fn reject_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.reject_connection(connection_id, reason)
- }
-
- fn revoke_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.revoke_connection(connection_id, reason)
- }
-
- fn update_relays(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- relays: Vec<RelayUrl>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.update_relays(connection_id, relays)
- }
-
- fn require_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- auth_url: &str,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.require_auth_challenge(connection_id, auth_url)
- }
-
- fn set_pending_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager
- .set_pending_request(connection_id, request_message)
- }
-
- fn authorize_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> {
- self.manager.authorize_auth_challenge(connection_id)
- }
-
- fn restore_pending_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- pending_request: RadrootsNostrSignerPendingRequest,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager
- .restore_pending_auth_challenge(connection_id, pending_request)
- }
-
- fn begin_connect_secret_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- let workflow = self
- .manager
- .begin_connect_secret_publish_finalization(connection_id)?;
- Ok(RadrootsNostrSignerPublishTransition::begun(workflow))
- }
-
- fn begin_auth_replay_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- let workflow = self
- .manager
- .begin_auth_replay_publish_finalization(connection_id)?;
- Ok(RadrootsNostrSignerPublishTransition::begun(workflow))
- }
-
- fn mark_publish_workflow_published(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- let workflow = self.manager.mark_publish_workflow_published(workflow_id)?;
- Ok(RadrootsNostrSignerPublishTransition::marked_published(
- workflow,
- ))
- }
-
- fn finalize_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- let connection = self.manager.finalize_publish_workflow(workflow_id)?;
- Ok(RadrootsNostrSignerPublishTransition::finalized(
- workflow_id.clone(),
- connection,
- ))
- }
-
- fn cancel_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- let workflow = self.manager.cancel_publish_workflow(workflow_id)?;
- Ok(RadrootsNostrSignerPublishTransition::cancelled(workflow))
- }
-
- fn mark_authenticated(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.mark_authenticated(connection_id)
- }
-
- fn mark_connect_secret_consumed(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.manager.mark_connect_secret_consumed(connection_id)
- }
-
- fn evaluate_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> {
- self.manager
- .evaluate_request(connection_id, request_message)
- }
-
- fn evaluate_auth_replay_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> {
- self.manager
- .evaluate_auth_replay_publish_workflow(workflow_id)
- }
-
- fn record_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_id: &str,
- method: Method,
- decision: RadrootsNostrSignerRequestDecision,
- message: Option<String>,
- ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> {
- self.manager
- .record_request(connection_id, request_id, method, decision, message)
- }
-
- fn sign_unsigned_event(
- &self,
- unsigned_event: UnsignedEvent,
- ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> {
- let event = unsigned_event.sign_with_keys(&self.signer_keys)?;
- Ok(RadrootsNostrSignerSignOutput::new(
- RadrootsNostrSignerCapability::LocalAccount(Box::new(self.local_signer_capability())),
- event,
- ))
- }
-}
-
-fn same_public_identity_key(left: &PublicIdentity, right: &PublicIdentity) -> bool {
- left.id() == right.id() && left.public_key() == right.public_key()
-}
-
-fn public_identity_from_keys(keys: &Keys) -> Result<PublicIdentity, RadrootsNostrSignerError> {
- let public_key = IdentityPublicKey::from_hex(&keys.public_key().to_hex()).map_err(|error| {
- RadrootsNostrSignerError::InvalidState(format!(
- "embedded signer public key is invalid: {error}"
- ))
- })?;
- PublicIdentity::from_final_public_key(public_key).map_err(|error| {
- RadrootsNostrSignerError::InvalidState(format!(
- "embedded signer public key is invalid: {error}"
- ))
- })
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::{
- RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend,
- RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition,
- same_public_identity_key,
- };
- use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
- use crate::signer::error::RadrootsNostrSignerError;
- use crate::signer::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal,
- RadrootsNostrSignerRequestAction, RadrootsNostrSignerSessionLookup,
- };
- use crate::signer::manager::RadrootsNostrSignerManager;
- use crate::signer::model::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus,
- RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestDecision,
- RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
- };
- use crate::signer::store::RadrootsNostrSignerStore;
- use crate::signer::test_support::{
- fixture_bob_identity, primary_relay, secondary_relay, synthetic_keys,
- synthetic_public_identity, synthetic_public_key,
- };
- use nostr::{EventBuilder, EventId, Keys, Kind};
- use radroots_nostr_connect::{Method, Permission, Request, message::RequestMessage};
- use std::panic::{AssertUnwindSafe, catch_unwind};
- use std::sync::Arc;
- use std::sync::RwLock;
- use std::sync::atomic::{AtomicU8, Ordering};
-
- fn embedded_identity(index: u32) -> Keys {
- synthetic_keys(index)
- }
-
- fn embedded_public_identity(keys: &Keys) -> PublicIdentity {
- PublicIdentity::new(keys.public_key()).expect("identity public key")
- }
-
- fn expect_registration_required(
- evaluation: RadrootsNostrSignerConnectEvaluation,
- ) -> RadrootsNostrSignerConnectProposal {
- match evaluation {
- RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal,
- other => panic!("unexpected connect evaluation: {other:?}"),
- }
- }
-
- fn expect_lookup_connection(
- lookup: RadrootsNostrSignerSessionLookup,
- ) -> RadrootsNostrSignerConnectionRecord {
- match lookup {
- RadrootsNostrSignerSessionLookup::Connection(found) => *found,
- other => panic!("unexpected session lookup: {other:?}"),
- }
- }
-
- fn expect_begun_workflow_id(
- transition: RadrootsNostrSignerPublishTransition,
- ) -> RadrootsNostrSignerWorkflowId {
- match transition {
- RadrootsNostrSignerPublishTransition::Begun(workflow) => workflow.workflow_id,
- other => panic!("unexpected begin transition: {other:?}"),
- }
- }
-
- fn expect_finalized_transition(
- transition: RadrootsNostrSignerPublishTransition,
- ) -> (
- RadrootsNostrSignerWorkflowId,
- RadrootsNostrSignerConnectionRecord,
- ) {
- match transition {
- RadrootsNostrSignerPublishTransition::Finalized {
- workflow_id,
- connection,
- } => (workflow_id, *connection),
- other => panic!("unexpected finalize transition: {other:?}"),
- }
- }
-
- struct StubBackend {
- signer_identity: Option<PublicIdentity>,
- signer_identity_error: Option<&'static str>,
- sign_error_message: Option<&'static str>,
- }
-
- #[derive(Default)]
- struct ToggleSaveStore {
- state: RwLock<RadrootsNostrSignerStoreState>,
- mode: AtomicU8,
- }
-
- impl ToggleSaveStore {
- fn set_mode(&self, mode: u8) {
- self.mode.store(mode, Ordering::SeqCst);
- }
- }
-
- impl RadrootsNostrSignerStore for ToggleSaveStore {
- fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
- let guard = self.state.read().map_err(|_| {
- RadrootsNostrSignerError::Store("toggle store lock poisoned".into())
- })?;
- Ok(guard.clone())
- }
-
- fn save(
- &self,
- state: &RadrootsNostrSignerStoreState,
- ) -> Result<(), RadrootsNostrSignerError> {
- match self.mode.load(Ordering::SeqCst) {
- 1 => Err(RadrootsNostrSignerError::Store("save failed".into())),
- 2 => panic!("toggle save panic"),
- _ => {
- let mut guard = self.state.write().map_err(|_| {
- RadrootsNostrSignerError::Store("toggle store lock poisoned".into())
- })?;
- *guard = state.clone();
- Ok(())
- }
- }
- }
- }
-
- impl RadrootsNostrSignerBackend for StubBackend {
- fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> {
- if let Some(message) = self.signer_identity_error {
- return Err(RadrootsNostrSignerError::InvalidState(message.into()));
- }
- Ok(self.signer_identity.clone())
- }
-
- fn set_signer_identity(
- &self,
- _signer_identity: PublicIdentity,
- ) -> Result<(), RadrootsNostrSignerError> {
- unreachable!("set_signer_identity not used in tests")
- }
-
- fn capabilities(
- &self,
- ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> {
- unreachable!("capabilities not used in tests")
- }
-
- fn list_connections(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- unreachable!("list_connections not used in tests")
- }
-
- fn get_connection(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- unreachable!("get_connection not used in tests")
- }
-
- fn list_publish_workflows(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>
- {
- unreachable!("list_publish_workflows not used in tests")
- }
-
- fn get_publish_workflow(
- &self,
- _workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>
- {
- unreachable!("get_publish_workflow not used in tests")
- }
-
- fn find_connections_by_client_public_key(
- &self,
- _client_public_key: &nostr::PublicKey,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- unreachable!("find_connections_by_client_public_key not used in tests")
- }
-
- fn find_connection_by_connect_secret(
- &self,
- _connect_secret: &str,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- unreachable!("find_connection_by_connect_secret not used in tests")
- }
-
- fn lookup_session(
- &self,
- _client_public_key: &nostr::PublicKey,
- _connect_secret: Option<&str>,
- ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> {
- unreachable!("lookup_session not used in tests")
- }
-
- fn evaluate_connect_request(
- &self,
- _client_public_key: nostr::PublicKey,
- _request: Request,
- ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> {
- unreachable!("evaluate_connect_request not used in tests")
- }
-
- fn register_connection(
- &self,
- _draft: RadrootsNostrSignerConnectionDraft,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("register_connection not used in tests")
- }
-
- fn set_granted_permissions(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _granted_permissions: radroots_nostr_connect::permission::Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("set_granted_permissions not used in tests")
- }
-
- fn approve_connection(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _granted_permissions: radroots_nostr_connect::permission::Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("approve_connection not used in tests")
- }
-
- fn reject_connection(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("reject_connection not used in tests")
- }
-
- fn revoke_connection(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("revoke_connection not used in tests")
- }
-
- fn update_relays(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _relays: Vec<nostr::RelayUrl>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("update_relays not used in tests")
- }
-
- fn require_auth_challenge(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _auth_url: &str,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("require_auth_challenge not used in tests")
- }
-
- fn set_pending_request(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("set_pending_request not used in tests")
- }
-
- fn authorize_auth_challenge(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<
- crate::signer::model::RadrootsNostrSignerAuthorizationOutcome,
- RadrootsNostrSignerError,
- > {
- unreachable!("authorize_auth_challenge not used in tests")
- }
-
- fn restore_pending_auth_challenge(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _pending_request: crate::signer::model::RadrootsNostrSignerPendingRequest,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("restore_pending_auth_challenge not used in tests")
- }
-
- fn begin_connect_secret_publish_finalization(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- unreachable!("begin_connect_secret_publish_finalization not used in tests")
- }
-
- fn begin_auth_replay_publish_finalization(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- unreachable!("begin_auth_replay_publish_finalization not used in tests")
- }
-
- fn mark_publish_workflow_published(
- &self,
- _workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- unreachable!("mark_publish_workflow_published not used in tests")
- }
-
- fn finalize_publish_workflow(
- &self,
- _workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- unreachable!("finalize_publish_workflow not used in tests")
- }
-
- fn cancel_publish_workflow(
- &self,
- _workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- unreachable!("cancel_publish_workflow not used in tests")
- }
-
- fn mark_authenticated(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("mark_authenticated not used in tests")
- }
-
- fn mark_connect_secret_consumed(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- unreachable!("mark_connect_secret_consumed not used in tests")
- }
-
- fn evaluate_request(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _request_message: RequestMessage,
- ) -> Result<
- crate::signer::evaluation::RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerError,
- > {
- unreachable!("evaluate_request not used in tests")
- }
-
- fn evaluate_auth_replay_publish_workflow(
- &self,
- _workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<
- crate::signer::evaluation::RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerError,
- > {
- unreachable!("evaluate_auth_replay_publish_workflow not used in tests")
- }
-
- fn record_request(
- &self,
- _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId,
- _request_id: &str,
- _method: Method,
- _decision: RadrootsNostrSignerRequestDecision,
- _message: Option<String>,
- ) -> Result<
- crate::signer::model::RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerError,
- > {
- unreachable!("record_request not used in tests")
- }
-
- fn sign_unsigned_event(
- &self,
- _unsigned_event: nostr::UnsignedEvent,
- ) -> Result<super::RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> {
- match self.sign_error_message {
- Some(message) => Err(RadrootsNostrSignerError::InvalidState(message.into())),
- None => unreachable!("sign_unsigned_event success path not used in tests"),
- }
- }
- }
-
- #[test]
- fn embedded_backend_bootstraps_signer_identity_and_capabilities() {
- let identity = embedded_identity(0x90);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
-
- let signer_identity = backend
- .signer_identity()
- .expect("signer identity")
- .expect("present");
- assert_eq!(signer_identity, embedded_public_identity(&identity));
-
- let capabilities = backend.capabilities().expect("capabilities");
- let local = capabilities.local_signer.clone().expect("local signer");
- assert_eq!(local.public_identity, embedded_public_identity(&identity));
- assert!(local.is_secret_backed());
- assert!(capabilities.remote_sessions.is_empty());
- assert_eq!(capabilities.all_signers().len(), 1);
- let manager_identity = backend
- .manager()
- .signer_identity()
- .expect("manager signer identity")
- .expect("stored signer identity");
- assert!(same_public_identity_key(
- &manager_identity,
- &embedded_public_identity(&identity)
- ));
- assert_eq!(backend.local_keys().public_key(), identity.public_key());
- }
-
- #[test]
- fn embedded_backend_rejects_mismatched_manager_identity() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(fixture_bob_identity())
- .expect("set signer identity");
-
- let error = match RadrootsNostrEmbeddedSignerBackend::new(manager, embedded_identity(0x91))
- {
- Ok(_) => panic!("mismatched identity"),
- Err(error) => error,
- };
- assert!(
- error
- .to_string()
- .contains("embedded signer identity does not match")
- );
- }
-
- #[test]
- fn embedded_backend_accepts_matching_manager_identity_and_setter_delegate() {
- let identity = embedded_identity(0x97);
- let manager = RadrootsNostrSignerManager::new_in_memory();
- let public_identity = embedded_public_identity(&identity);
- manager
- .set_signer_identity(public_identity.clone())
- .expect("prime manager identity");
-
- let backend = RadrootsNostrEmbeddedSignerBackend::new(manager, identity.clone())
- .expect("matching embedded backend");
- let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
-
- assert_eq!(backend.local_keys().public_key(), identity.public_key());
- assert!(same_public_identity_key(
- backend_trait
- .signer_identity()
- .expect("signer identity")
- .as_ref()
- .expect("present"),
- &public_identity
- ));
-
- backend_trait
- .set_signer_identity(public_identity.clone())
- .expect("delegate set signer identity");
- let manager_identity = backend
- .manager()
- .signer_identity()
- .expect("manager signer identity")
- .expect("stored signer identity");
- assert!(same_public_identity_key(
- &manager_identity,
- &public_identity
- ));
- }
-
- #[test]
- fn backend_source_does_not_accept_raw_event_builders() {
- let production_source = include_str!("backend.rs")
- .split("\n#[cfg(test)]")
- .next()
- .expect("production backend source");
-
- assert!(!production_source.contains(concat!("fn sign_event_", "builder")));
- }
-
- #[test]
- fn external_unsigned_signing_propagates_backend_errors() {
- let backend = StubBackend {
- signer_identity: None,
- signer_identity_error: None,
- sign_error_message: Some("stub interop signing failure"),
- };
- let unsigned_event =
- EventBuilder::new(Kind::TextNote, "external interop").build(synthetic_public_key(0xaa));
-
- let error = backend
- .sign_unsigned_event(unsigned_event)
- .expect_err("external unsigned signing failure");
-
- assert!(error.to_string().contains("stub interop signing failure"));
- }
-
- #[test]
- fn capabilities_only_include_active_remote_sessions() {
- let identity = embedded_identity(0xac);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
-
- let active = backend_trait
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xad),
- synthetic_public_identity(0xae),
- ))
- .expect("register active");
-
- let pending = backend_trait
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xaf),
- synthetic_public_identity(0xb0),
- )
- .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
- )
- .expect("register pending");
- let rejected = backend_trait
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xb1),
- synthetic_public_identity(0xb2),
- ))
- .expect("register rejected");
- backend_trait
- .reject_connection(&rejected.connection_id, Some("rejected".into()))
- .expect("reject connection");
-
- let capabilities = backend_trait.capabilities().expect("capabilities");
- assert_eq!(capabilities.remote_sessions.len(), 1);
- assert_eq!(
- capabilities.remote_sessions[0].connection_id,
- active.connection_id
- );
- assert_ne!(
- capabilities.remote_sessions[0].connection_id,
- pending.connection_id
- );
- }
-
- #[test]
- fn embedded_backend_propagates_missing_publish_targets() {
- let identity = embedded_identity(0xb3);
- let backend =
- RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity).expect("embedded backend");
- let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
-
- let missing_connection_id =
- crate::signer::model::RadrootsNostrSignerConnectionId::parse("conn-backend-missing")
- .expect("connection id");
- let missing_workflow_id =
- RadrootsNostrSignerWorkflowId::parse("wf-backend-missing").expect("workflow id");
-
- assert!(
- backend_trait
- .begin_connect_secret_publish_finalization(&missing_connection_id)
- .expect_err("missing connect workflow")
- .to_string()
- .contains("connection not found")
- );
- assert!(
- backend_trait
- .begin_auth_replay_publish_finalization(&missing_connection_id)
- .expect_err("missing auth workflow")
- .to_string()
- .contains("connection not found")
- );
- assert!(
- backend_trait
- .mark_publish_workflow_published(&missing_workflow_id)
- .expect_err("missing published workflow")
- .to_string()
- .contains("publish workflow not found")
- );
- assert!(
- backend_trait
- .finalize_publish_workflow(&missing_workflow_id)
- .expect_err("missing finalized workflow")
- .to_string()
- .contains("publish workflow not found")
- );
- assert!(
- backend_trait
- .cancel_publish_workflow(&missing_workflow_id)
- .expect_err("missing cancelled workflow")
- .to_string()
- .contains("publish workflow not found")
- );
- }
-
- #[test]
- fn embedded_backend_reports_manager_read_and_save_failures() {
- let save_fail_store = Arc::new(ToggleSaveStore::default());
- save_fail_store.set_mode(1);
- let save_fail_manager =
- RadrootsNostrSignerManager::new(save_fail_store).expect("save-fail manager");
- let err = match RadrootsNostrEmbeddedSignerBackend::new(
- save_fail_manager,
- embedded_identity(0xb4),
- ) {
- Ok(_) => panic!("expected save failure"),
- Err(err) => err,
- };
- assert!(err.to_string().contains("save failed"));
-
- let poisoned_store = Arc::new(ToggleSaveStore::default());
- let poisoned_manager =
- RadrootsNostrSignerManager::new(poisoned_store.clone()).expect("poison manager");
- let backend = RadrootsNostrEmbeddedSignerBackend::new(
- poisoned_manager.clone(),
- embedded_identity(0xb5),
- )
- .expect("embedded backend");
- poisoned_store.set_mode(2);
- assert!(
- catch_unwind(AssertUnwindSafe(|| {
- let _ = backend
- .manager()
- .set_signer_identity(fixture_bob_identity());
- }))
- .is_err()
- );
-
- let err = backend.capabilities().expect_err("poisoned capabilities");
- assert!(err.to_string().contains("signer state lock poisoned"));
-
- let err = match RadrootsNostrEmbeddedSignerBackend::new(
- poisoned_manager,
- embedded_identity(0xb5),
- ) {
- Ok(_) => panic!("expected poisoned new failure"),
- Err(err) => err,
- };
- assert!(err.to_string().contains("signer state lock poisoned"));
- }
-
- #[test]
- fn embedded_backend_sign_unsigned_event_rejects_invalid_precomputed_id() {
- let identity = embedded_identity(0xb6);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
-
- let mut unsigned_event =
- EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key());
- unsigned_event.id = Some(EventId::all_zeros());
- let err = backend_trait
- .sign_unsigned_event(unsigned_event)
- .expect_err("invalid precomputed id");
- assert!(err.to_string().starts_with("sign error:"));
- }
-
- #[test]
- fn embedded_backend_trait_delegates_connect_and_publish_workflow_methods() {
- let identity = embedded_identity(0x92);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let backend: &dyn RadrootsNostrSignerBackend = &backend;
-
- let evaluation = backend
- .evaluate_connect_request(
- synthetic_public_key(0x93),
- Request::Connect {
- remote_signer_public_key: embedded_public_identity(&identity).public_key(),
- secret: Some("connect-secret".into()),
- requested_permissions: vec![Permission::new(Method::Ping)].into(),
- client_metadata: None,
- },
- )
- .expect("connect evaluation");
- let proposal = expect_registration_required(evaluation);
- let connection = backend
- .register_connection(
- proposal
- .into_connection_draft(synthetic_public_identity(0x94))
- .with_relays(vec![primary_relay()]),
- )
- .expect("register connection");
-
- let capabilities = backend.capabilities().expect("capabilities");
- assert_eq!(capabilities.remote_sessions.len(), 1);
-
- let begun = backend
- .begin_connect_secret_publish_finalization(&connection.connection_id)
- .expect("begin workflow");
- let workflow_id = expect_begun_workflow_id(begun.clone());
- assert_eq!(
- begun.workflow().expect("begun workflow").connection_id,
- connection.connection_id
- );
-
- let published = backend
- .mark_publish_workflow_published(&workflow_id)
- .expect("mark published");
- assert!(matches!(
- published,
- RadrootsNostrSignerPublishTransition::MarkedPublished(_)
- ));
-
- let finalized = backend
- .finalize_publish_workflow(&workflow_id)
- .expect("finalize workflow");
- let (finalized_workflow_id, finalized_connection) = expect_finalized_transition(finalized);
- assert_eq!(finalized_workflow_id, workflow_id);
- assert!(finalized_connection.connect_secret_is_consumed());
-
- let audit = backend
- .record_request(
- &connection.connection_id,
- "req-1",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Allowed,
- None,
- )
- .expect("record request");
- assert_eq!(audit.method, Method::Ping);
- }
-
- #[test]
- fn embedded_backend_delegates_lookup_state_and_auth_workflow_methods() {
- let identity = embedded_identity(0xa0);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
-
- let connect_evaluation = backend_trait
- .evaluate_connect_request(
- synthetic_public_key(0xa1),
- Request::Connect {
- remote_signer_public_key: embedded_public_identity(&identity).public_key(),
- secret: Some("connect-secret-2".into()),
- requested_permissions: vec![Permission::new(Method::Ping)].into(),
- client_metadata: None,
- },
- )
- .expect("connect evaluation");
- let connect_proposal = expect_registration_required(connect_evaluation);
- let connection = backend_trait
- .register_connection(
- connect_proposal
- .into_connection_draft(synthetic_public_identity(0xa2))
- .with_relays(vec![primary_relay()]),
- )
- .expect("register connect-secret connection");
-
- assert_eq!(backend_trait.list_connections().expect("list").len(), 1);
- assert_eq!(
- backend_trait
- .get_connection(&connection.connection_id)
- .expect("get connection")
- .expect("stored connection")
- .connection_id,
- connection.connection_id
- );
- assert_eq!(
- backend_trait
- .find_connections_by_client_public_key(&connection.client_public_key)
- .expect("find by client key")
- .len(),
- 1
- );
- assert_eq!(
- backend_trait
- .find_connection_by_connect_secret("connect-secret-2")
- .expect("find by secret")
- .expect("stored by secret")
- .connection_id,
- connection.connection_id
- );
- let looked_up = expect_lookup_connection(
- backend_trait
- .lookup_session(&connection.client_public_key, Some("connect-secret-2"))
- .expect("lookup session"),
- );
- assert_eq!(looked_up.connection_id, connection.connection_id);
-
- let with_relays = backend_trait
- .update_relays(
- &connection.connection_id,
- vec![primary_relay(), secondary_relay()],
- )
- .expect("update relays");
- assert_eq!(with_relays.relays.len(), 2);
-
- let evaluation = backend_trait
- .evaluate_request(
- &connection.connection_id,
- RequestMessage::new("req-ping", Request::Ping),
- )
- .expect("evaluate request");
- assert!(matches!(
- evaluation.action,
- RadrootsNostrSignerRequestAction::Allowed { .. }
- ));
-
- let authenticated = backend_trait
- .mark_authenticated(&connection.connection_id)
- .expect("mark authenticated");
- assert!(authenticated.last_authenticated_at_unix.is_some());
-
- let pending_connection = backend_trait
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xab),
- synthetic_public_identity(0xac),
- )
- .with_requested_permissions(vec![Permission::new(Method::Ping)].into())
- .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
- )
- .expect("register pending connection");
- let granted_permissions: radroots_nostr_connect::permission::Permissions =
- vec![Permission::new(Method::Ping)].into();
- let granted = backend_trait
- .set_granted_permissions(
- &pending_connection.connection_id,
- granted_permissions.clone(),
- )
- .expect("set granted permissions");
- assert_eq!(granted.connection_id, pending_connection.connection_id);
-
- let approved = backend_trait
- .approve_connection(&pending_connection.connection_id, granted_permissions)
- .expect("approve connection");
- assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active);
-
- let begun = backend_trait
- .begin_connect_secret_publish_finalization(&connection.connection_id)
- .expect("begin connect workflow");
- let workflow = begun.workflow().expect("begun workflow").clone();
- assert!(begun.finalized_connection().is_none());
- assert_eq!(
- backend_trait
- .list_publish_workflows()
- .expect("list publish workflows")
- .len(),
- 1
- );
- assert_eq!(
- backend_trait
- .get_publish_workflow(&workflow.workflow_id)
- .expect("get publish workflow")
- .expect("stored workflow")
- .workflow_id,
- workflow.workflow_id
- );
-
- let published = backend_trait
- .mark_publish_workflow_published(&workflow.workflow_id)
- .expect("mark publish workflow");
- assert_eq!(
- published
- .workflow()
- .expect("published workflow")
- .workflow_id,
- workflow.workflow_id
- );
-
- let finalized = backend_trait
- .finalize_publish_workflow(&workflow.workflow_id)
- .expect("finalize workflow");
- assert!(finalized.workflow().is_none());
- assert_eq!(
- finalized
- .finalized_connection()
- .expect("finalized connection")
- .connection_id,
- connection.connection_id
- );
-
- let audit = backend_trait
- .record_request(
- &connection.connection_id,
- "req-audit",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Allowed,
- None,
- )
- .expect("record request");
- assert_eq!(audit.connection_id, connection.connection_id);
-
- let consumed_connection = backend_trait
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xa3),
- synthetic_public_identity(0xa4),
- )
- .with_connect_secret("manual-secret"),
- )
- .expect("register consumed connection");
- let consumed = backend_trait
- .mark_connect_secret_consumed(&consumed_connection.connection_id)
- .expect("mark connect secret consumed");
- assert!(consumed.connect_secret_is_consumed());
-
- let rejected = backend_trait
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xa5),
- synthetic_public_identity(0xa6),
- ))
- .expect("register rejected connection");
- let rejected = backend_trait
- .reject_connection(&rejected.connection_id, Some("rejected".into()))
- .expect("reject connection");
- assert_eq!(
- rejected.status,
- RadrootsNostrSignerConnectionStatus::Rejected
- );
-
- let auth_connection = backend_trait
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xa7),
- synthetic_public_identity(0xa8),
- )
- .with_requested_permissions(vec![Permission::new(Method::Ping)].into()),
- )
- .expect("register auth connection");
- backend_trait
- .require_auth_challenge(
- &auth_connection.connection_id,
- "https://api.example.com/auth",
- )
- .expect("require auth challenge");
- let pending = backend_trait
- .set_pending_request(
- &auth_connection.connection_id,
- RequestMessage::new("req-auth-replay", Request::Ping),
- )
- .expect("set pending request");
- assert!(pending.pending_request.is_some());
- let authorized = backend_trait
- .authorize_auth_challenge(&auth_connection.connection_id)
- .expect("authorize auth challenge");
- let pending_request = authorized.pending_request.expect("pending request");
- let restored = backend_trait
- .restore_pending_auth_challenge(&auth_connection.connection_id, pending_request.clone())
- .expect("restore pending auth challenge");
- assert_eq!(restored.pending_request.as_ref(), Some(&pending_request));
-
- let auth_workflow = backend_trait
- .begin_auth_replay_publish_finalization(&auth_connection.connection_id)
- .expect("begin auth replay")
- .workflow()
- .expect("auth replay workflow")
- .clone();
- let replay_evaluation = backend_trait
- .evaluate_auth_replay_publish_workflow(&auth_workflow.workflow_id)
- .expect("evaluate auth replay workflow");
- assert_eq!(
- replay_evaluation.connection.connection_id,
- auth_connection.connection_id
- );
- let cancelled = backend_trait
- .cancel_publish_workflow(&auth_workflow.workflow_id)
- .expect("cancel auth workflow");
- assert_eq!(
- cancelled
- .workflow()
- .expect("cancelled workflow")
- .workflow_id,
- auth_workflow.workflow_id
- );
-
- let revoked = backend_trait
- .revoke_connection(&auth_connection.connection_id, Some("revoked".into()))
- .expect("revoke connection");
- assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked);
- }
-
- #[test]
- fn embedded_backend_signs_external_unsigned_event_with_local_capability() {
- let identity = embedded_identity(0x95);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let output =
- <RadrootsNostrEmbeddedSignerBackend as RadrootsNostrSignerBackend>::sign_unsigned_event(
- &backend,
- EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key()),
- )
- .expect("sign external unsigned event");
-
- assert_eq!(output.event.pubkey, identity.public_key());
- let local = output.signer.local_account().expect("local signer");
- assert_eq!(local.public_identity, embedded_public_identity(&identity));
- assert!(local.is_secret_backed());
- }
-
- #[test]
- fn embedded_backend_can_prepare_and_cancel_auth_replay_workflow() {
- let identity = embedded_identity(0x96);
- let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
- .expect("embedded backend");
- let backend: &dyn RadrootsNostrSignerBackend = &backend;
-
- let connection = backend
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0x97),
- synthetic_public_identity(0x98),
- )
- .with_requested_permissions(vec![Permission::new(Method::Ping)].into()),
- )
- .expect("register connection");
- backend
- .require_auth_challenge(&connection.connection_id, "https://api.example.com/auth")
- .expect("require auth");
- backend
- .set_pending_request(
- &connection.connection_id,
- RequestMessage::new("req-auth", Request::Ping),
- )
- .expect("set pending request");
-
- let begun = backend
- .begin_auth_replay_publish_finalization(&connection.connection_id)
- .expect("begin auth replay");
- let workflow_id = begun
- .workflow()
- .expect("begun auth replay workflow")
- .workflow_id
- .clone();
-
- let cancelled = backend
- .cancel_publish_workflow(&workflow_id)
- .expect("cancel workflow");
- assert!(matches!(
- cancelled,
- RadrootsNostrSignerPublishTransition::Cancelled(_)
- ));
- }
-
- #[test]
- fn backend_capabilities_all_signers_supports_remote_only_and_identity_comparison() {
- let remote = crate::signer::capability::RadrootsNostrRemoteSessionSignerCapability::new(
- crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(),
- synthetic_public_identity(0xb0),
- synthetic_public_identity(0xb1),
- );
- let capabilities = RadrootsNostrSignerBackendCapabilities::new(None, vec![remote.clone()]);
-
- assert_eq!(
- capabilities.all_signers(),
- vec![
- crate::signer::capability::RadrootsNostrSignerCapability::RemoteSession(Box::new(
- remote,
- ))
- ]
- );
-
- let valid_identity = synthetic_public_identity(0xb2);
- assert!(same_public_identity_key(&valid_identity, &valid_identity));
- let valid_identity_with_different_hex = synthetic_public_identity(0xb3);
- assert!(!same_public_identity_key(
- &valid_identity,
- &valid_identity_with_different_hex
- ));
- }
-
- #[test]
- fn backend_test_helpers_reject_unexpected_variants() {
- let connection = RadrootsNostrSignerConnectionRecord::new(
- crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(),
- synthetic_public_identity(0xb4),
- RadrootsNostrSignerConnectionDraft::new(
- synthetic_public_key(0xb5),
- synthetic_public_identity(0xb6),
- ),
- 1,
- );
- let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
- connection.connection_id.clone(),
- 1,
- );
-
- assert!(
- std::panic::catch_unwind(|| {
- expect_registration_required(
- RadrootsNostrSignerConnectEvaluation::ExistingConnection(Box::new(
- connection.clone(),
- )),
- )
- })
- .is_err()
- );
- assert!(
- std::panic::catch_unwind(|| {
- expect_lookup_connection(RadrootsNostrSignerSessionLookup::None)
- })
- .is_err()
- );
- assert!(
- std::panic::catch_unwind(|| {
- expect_begun_workflow_id(RadrootsNostrSignerPublishTransition::cancelled(
- workflow.clone(),
- ))
- })
- .is_err()
- );
- assert!(
- std::panic::catch_unwind(|| {
- expect_finalized_transition(RadrootsNostrSignerPublishTransition::begun(workflow))
- })
- .is_err()
- );
- }
-}
diff --git a/src/signer/capability.rs b/src/signer/capability.rs
@@ -1,330 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::model::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord};
-use nostr::RelayUrl;
-use radroots_identity::AccountId;
-use radroots_nostr_connect::permission::Permissions;
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrLocalSignerAvailability {
- PublicOnly,
- SecretBacked,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrLocalSignerCapability {
- pub account_id: AccountId,
- pub public_identity: PublicIdentity,
- pub availability: RadrootsNostrLocalSignerAvailability,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrRemoteSessionSignerCapability {
- pub connection_id: RadrootsNostrSignerConnectionId,
- pub signer_identity: PublicIdentity,
- pub user_identity: PublicIdentity,
- pub relays: Vec<RelayUrl>,
- pub permissions: Permissions,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerCapability {
- LocalAccount(Box<RadrootsNostrLocalSignerCapability>),
- RemoteSession(Box<RadrootsNostrRemoteSessionSignerCapability>),
-}
-
-fn public_identity_eq(left: &PublicIdentity, right: &PublicIdentity) -> bool {
- left == right
-}
-
-impl RadrootsNostrLocalSignerCapability {
- pub fn new(
- account_id: AccountId,
- public_identity: PublicIdentity,
- availability: RadrootsNostrLocalSignerAvailability,
- ) -> Self {
- Self {
- account_id,
- public_identity,
- availability,
- }
- }
-
- pub fn is_secret_backed(&self) -> bool {
- self.availability == RadrootsNostrLocalSignerAvailability::SecretBacked
- }
-}
-
-impl RadrootsNostrRemoteSessionSignerCapability {
- pub fn new(
- connection_id: RadrootsNostrSignerConnectionId,
- signer_identity: PublicIdentity,
- user_identity: PublicIdentity,
- ) -> Self {
- Self {
- connection_id,
- signer_identity,
- user_identity,
- relays: Vec::new(),
- permissions: Permissions::default(),
- }
- }
-
- pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self {
- self.relays = relays;
- self
- }
-
- pub fn with_permissions(mut self, permissions: Permissions) -> Self {
- self.permissions = permissions;
- self
- }
-}
-
-impl RadrootsNostrSignerCapability {
- pub fn public_identity(&self) -> &PublicIdentity {
- match self {
- Self::LocalAccount(capability) => &capability.public_identity,
- Self::RemoteSession(capability) => &capability.user_identity,
- }
- }
-
- pub fn local_account(&self) -> Option<&RadrootsNostrLocalSignerCapability> {
- match self {
- Self::LocalAccount(capability) => Some(capability.as_ref()),
- Self::RemoteSession(_) => None,
- }
- }
-
- pub fn remote_session(&self) -> Option<&RadrootsNostrRemoteSessionSignerCapability> {
- match self {
- Self::RemoteSession(capability) => Some(capability.as_ref()),
- Self::LocalAccount(_) => None,
- }
- }
-}
-
-impl PartialEq for RadrootsNostrLocalSignerCapability {
- fn eq(&self, other: &Self) -> bool {
- self.account_id == other.account_id
- && self.availability == other.availability
- && public_identity_eq(&self.public_identity, &other.public_identity)
- }
-}
-
-impl Eq for RadrootsNostrLocalSignerCapability {}
-
-impl PartialEq for RadrootsNostrRemoteSessionSignerCapability {
- fn eq(&self, other: &Self) -> bool {
- self.connection_id == other.connection_id
- && self.relays == other.relays
- && self.permissions == other.permissions
- && public_identity_eq(&self.signer_identity, &other.signer_identity)
- && public_identity_eq(&self.user_identity, &other.user_identity)
- }
-}
-
-impl Eq for RadrootsNostrRemoteSessionSignerCapability {}
-
-impl PartialEq for RadrootsNostrSignerCapability {
- fn eq(&self, other: &Self) -> bool {
- match (self, other) {
- (Self::LocalAccount(left), Self::LocalAccount(right)) => {
- left.as_ref() == right.as_ref()
- }
- (Self::RemoteSession(left), Self::RemoteSession(right)) => {
- left.as_ref() == right.as_ref()
- }
- _ => false,
- }
- }
-}
-
-impl Eq for RadrootsNostrSignerCapability {}
-
-impl From<&RadrootsNostrSignerConnectionRecord> for RadrootsNostrRemoteSessionSignerCapability {
- fn from(value: &RadrootsNostrSignerConnectionRecord) -> Self {
- Self {
- connection_id: value.connection_id.clone(),
- signer_identity: value.signer_identity.clone(),
- user_identity: value.user_identity.clone(),
- relays: value.relays.clone(),
- permissions: value.effective_permissions(),
- }
- }
-}
-
-impl RadrootsNostrSignerConnectionRecord {
- pub fn remote_session_capability(&self) -> RadrootsNostrSignerCapability {
- RadrootsNostrSignerCapability::RemoteSession(Box::new(
- RadrootsNostrRemoteSessionSignerCapability::from(self),
- ))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
- use crate::signer::model::{
- RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionRecord,
- };
- use crate::signer::test_support::{
- fixture_alice_identity, fixture_bob_identity, fixture_carol_identity,
- fixture_diego_public_key, primary_relay, secondary_relay,
- };
- use radroots_nostr_connect::{Method, Permission};
-
- fn assert_public_identity_matches(actual: &PublicIdentity, expected: &PublicIdentity) {
- assert_eq!(actual, expected);
- }
-
- #[test]
- fn local_capability_reports_secret_backing_and_public_identity() {
- let public_identity = fixture_alice_identity();
- let capability = RadrootsNostrSignerCapability::LocalAccount(Box::new(
- RadrootsNostrLocalSignerCapability::new(
- public_identity.account_id(),
- public_identity.clone(),
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- ),
- ));
-
- assert_public_identity_matches(capability.public_identity(), &public_identity);
- assert!(
- capability
- .local_account()
- .expect("local capability")
- .is_secret_backed()
- );
- assert!(capability.remote_session().is_none());
- }
-
- #[test]
- fn remote_session_capability_reflects_connection_effective_permissions() {
- let signer_identity = fixture_bob_identity();
- let user_identity = fixture_carol_identity();
- let record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- signer_identity.clone(),
- RadrootsNostrSignerConnectionDraft::new(
- fixture_diego_public_key(),
- user_identity.clone(),
- )
- .with_requested_permissions(vec![Permission::new(Method::Ping)].into())
- .with_relays(vec![primary_relay()]),
- 1,
- );
-
- let capability = record.remote_session_capability();
- assert_public_identity_matches(capability.public_identity(), &user_identity);
- assert!(capability.local_account().is_none());
- let remote = capability.remote_session().expect("remote capability");
- assert_eq!(remote.connection_id, record.connection_id);
- assert_public_identity_matches(&remote.signer_identity, &signer_identity);
- assert_public_identity_matches(&remote.user_identity, &user_identity);
- assert_eq!(remote.permissions, record.effective_permissions());
- assert_eq!(remote.relays, record.relays);
- }
-
- #[test]
- fn remote_session_builder_helpers_replace_default_fields() {
- let capability = RadrootsNostrRemoteSessionSignerCapability::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- fixture_alice_identity(),
- fixture_bob_identity(),
- )
- .with_permissions(vec![Permission::new(Method::SwitchRelays)].into())
- .with_relays(vec![primary_relay()]);
-
- assert_eq!(capability.permissions.as_slice().len(), 1);
- assert_eq!(capability.relays.len(), 1);
- }
-
- #[test]
- fn capability_equality_accounts_for_identity_fields_and_variant_kind() {
- let alice = fixture_alice_identity();
- let bob = fixture_bob_identity();
-
- let local = RadrootsNostrLocalSignerCapability::new(
- alice.account_id(),
- alice.clone(),
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- );
- let local_same = RadrootsNostrLocalSignerCapability::new(
- alice.account_id(),
- alice.clone(),
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- );
- let local_changed_account = RadrootsNostrLocalSignerCapability::new(
- bob.account_id(),
- alice.clone(),
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- );
- let local_changed_availability = RadrootsNostrLocalSignerCapability::new(
- alice.account_id(),
- alice.clone(),
- RadrootsNostrLocalSignerAvailability::PublicOnly,
- );
- let local_changed_identity = RadrootsNostrLocalSignerCapability::new(
- alice.account_id(),
- bob,
- RadrootsNostrLocalSignerAvailability::SecretBacked,
- );
- assert_eq!(local, local_same);
- assert_ne!(local, local_changed_account);
- assert_ne!(local, local_changed_availability);
- assert_ne!(local, local_changed_identity);
-
- let remote = RadrootsNostrRemoteSessionSignerCapability::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- fixture_bob_identity(),
- fixture_carol_identity(),
- )
- .with_relays(vec![primary_relay()]);
- let remote_same = remote.clone();
- let remote_changed_connection = RadrootsNostrRemoteSessionSignerCapability::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- remote.signer_identity.clone(),
- remote.user_identity.clone(),
- )
- .with_relays(remote.relays.clone())
- .with_permissions(remote.permissions.clone());
- let remote_changed_relays = remote.clone().with_relays(vec![secondary_relay()]);
- let remote_changed_permissions = remote
- .clone()
- .with_permissions(vec![Permission::new(Method::Ping)].into());
- let mut remote_changed_signer = remote.clone();
- remote_changed_signer.signer_identity = fixture_alice_identity();
- let mut remote_changed_user = remote.clone();
- remote_changed_user.user_identity = fixture_alice_identity();
- assert_eq!(remote, remote_same);
- assert_ne!(remote, remote_changed_connection);
- assert_ne!(remote, remote_changed_relays);
- assert_ne!(remote, remote_changed_permissions);
- assert_ne!(remote, remote_changed_signer);
- assert_ne!(remote, remote_changed_user);
-
- assert_eq!(
- RadrootsNostrSignerCapability::LocalAccount(Box::new(local.clone())),
- RadrootsNostrSignerCapability::LocalAccount(Box::new(local_same))
- );
- assert_eq!(
- RadrootsNostrSignerCapability::RemoteSession(Box::new(remote.clone())),
- RadrootsNostrSignerCapability::RemoteSession(Box::new(remote))
- );
- assert_ne!(
- RadrootsNostrSignerCapability::LocalAccount(Box::new(local)),
- RadrootsNostrSignerCapability::RemoteSession(Box::new(remote_changed_user))
- );
- }
-
- #[test]
- fn public_identity_eq_compares_invariant_checked_values() {
- let alice = fixture_alice_identity();
- let bob = fixture_bob_identity();
-
- assert!(!public_identity_eq(&alice, &bob));
- assert!(public_identity_eq(&alice, &alice));
- }
-}
diff --git a/src/signer/error.rs b/src/signer/error.rs
@@ -1,127 +0,0 @@
-use thiserror::Error;
-
-#[derive(Debug, Error)]
-pub enum RadrootsNostrSignerError {
- #[error("store error: {0}")]
- Store(String),
-
- #[error("sign error: {0}")]
- Sign(String),
-
- #[error("missing signer identity")]
- MissingSignerIdentity,
-
- #[error("connection not found: {0}")]
- ConnectionNotFound(String),
-
- #[error(
- "connection already exists for client `{client_public_key}` and user `{user_identity_id}`"
- )]
- ConnectionAlreadyExists {
- client_public_key: String,
- user_identity_id: String,
- },
-
- #[error("connect secret already in use")]
- ConnectSecretAlreadyInUse,
-
- #[error("invalid auth url `{0}`")]
- InvalidAuthUrl(String),
-
- #[error("invalid signer state: {0}")]
- InvalidState(String),
-
- #[error("invalid granted permission `{0}`")]
- InvalidGrantedPermission(String),
-
- #[error("invalid connection id `{0}`")]
- InvalidConnectionId(String),
-
- #[error("invalid request id `{0}`")]
- InvalidRequestId(String),
-
- #[error("invalid workflow id `{0}`")]
- InvalidWorkflowId(String),
-
- #[error("publish workflow not found: {0}")]
- PublishWorkflowNotFound(String),
-
- #[error("SQLite signer journal-mode query returned {actual_rows} rows; expected exactly one")]
- SqliteJournalModeResultCardinality { actual_rows: usize },
-
- #[error(
- "SQLite signer connection did not enter `{expected}` journal mode; reported `{actual}`"
- )]
- SqliteJournalModeMismatch {
- expected: &'static str,
- actual: String,
- },
-}
-
-impl From<serde_json::Error> for RadrootsNostrSignerError {
- fn from(value: serde_json::Error) -> Self {
- Self::Store(value.to_string())
- }
-}
-
-impl From<nostr::event::Error> for RadrootsNostrSignerError {
- fn from(value: nostr::event::Error) -> Self {
- Self::Sign(value.to_string())
- }
-}
-
-impl From<radroots_nostr::Error> for RadrootsNostrSignerError {
- fn from(value: radroots_nostr::Error) -> Self {
- Self::InvalidState(value.to_string())
- }
-}
-
-impl From<radroots_nostr_connect::Error> for RadrootsNostrSignerError {
- fn from(value: radroots_nostr_connect::Error) -> Self {
- Self::InvalidState(value.to_string())
- }
-}
-
-impl From<crate::sql::SqlError> for RadrootsNostrSignerError {
- fn from(value: crate::sql::SqlError) -> Self {
- Self::Store(value.to_string())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- #[test]
- fn converts_serde_json_error() {
- let source =
- serde_json::from_str::<serde_json::Value>("{not-json").expect_err("serde error");
- let converted: RadrootsNostrSignerError = source.into();
- assert!(converted.to_string().starts_with("store error:"));
- }
-
- #[test]
- fn converts_nostr_event_error() {
- let converted: RadrootsNostrSignerError = nostr::event::Error::InvalidId.into();
- assert!(converted.to_string().starts_with("sign error:"));
- }
-
- #[test]
- fn converts_nostr_filter_error() {
- let converted: RadrootsNostrSignerError =
- radroots_nostr::Error::FilterTagError("bad tag".to_string()).into();
- assert!(converted.to_string().starts_with("invalid signer state:"));
- }
-
- #[test]
- fn converts_nostr_connect_error() {
- let converted: RadrootsNostrSignerError =
- radroots_nostr_connect::Error::InvalidMethod("bad".to_string()).into();
- assert!(converted.to_string().starts_with("invalid signer state:"));
- }
-
- #[test]
- fn converts_sql_error() {
- let converted: RadrootsNostrSignerError = crate::sql::SqlError::Internal.into();
- assert!(converted.to_string().starts_with("store error:"));
- }
-}
diff --git a/src/signer/evaluation.rs b/src/signer/evaluation.rs
@@ -1,519 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::error::RadrootsNostrSignerError;
-use crate::signer::model::{
- RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId,
-};
-use nostr::PublicKey;
-use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl;
-use radroots_nostr_connect::{
- Method, Permission, Request, message::RemoteSessionCapability, permission::Permissions,
- uri::ClientMetadata,
-};
-
-#[derive(Debug, Clone)]
-pub enum RadrootsNostrSignerSessionLookup {
- None,
- Connection(Box<RadrootsNostrSignerConnectionRecord>),
- Ambiguous(Vec<RadrootsNostrSignerConnectionRecord>),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RadrootsNostrSignerConnectProposal {
- pub client_public_key: PublicKey,
- pub connect_secret: Option<String>,
- pub client_metadata: Option<ClientMetadata>,
- pub requested_permissions: Permissions,
-}
-
-#[derive(Debug, Clone)]
-pub enum RadrootsNostrSignerConnectEvaluation {
- ExistingConnection(Box<RadrootsNostrSignerConnectionRecord>),
- RegistrationRequired(RadrootsNostrSignerConnectProposal),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum RadrootsNostrSignerRequestResponseHint {
- None,
- Pong,
- UserPublicKey(radroots_identity::PublicKey),
- RemoteSessionCapability(RemoteSessionCapability),
- RelayList(Vec<ConnectRelayUrl>),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum RadrootsNostrSignerRequestAction {
- Allowed {
- required_permission: Option<Permission>,
- response_hint: RadrootsNostrSignerRequestResponseHint,
- },
- Denied {
- reason: String,
- },
- Challenged {
- auth_challenge: RadrootsNostrSignerAuthChallenge,
- pending_request: RadrootsNostrSignerPendingRequest,
- },
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsNostrSignerRequestEvaluation {
- pub request_id: RadrootsNostrSignerRequestId,
- pub method: Method,
- pub connection: RadrootsNostrSignerConnectionRecord,
- pub audit: RadrootsNostrSignerRequestAuditRecord,
- pub action: RadrootsNostrSignerRequestAction,
-}
-
-impl RadrootsNostrSignerConnectProposal {
- pub fn into_connection_draft(
- self,
- user_identity: PublicIdentity,
- ) -> RadrootsNostrSignerConnectionDraft {
- let mut draft =
- RadrootsNostrSignerConnectionDraft::new(self.client_public_key, user_identity)
- .with_requested_permissions(self.requested_permissions);
- if let Some(connect_secret) = self.connect_secret {
- draft = draft.with_connect_secret(connect_secret);
- }
- if let Some(client_metadata) = self.client_metadata {
- draft = draft.with_client_metadata(client_metadata);
- }
- draft
- }
-}
-
-impl RadrootsNostrSignerRequestEvaluation {
- pub fn denied_reason(&self) -> Option<&str> {
- match &self.action {
- RadrootsNostrSignerRequestAction::Denied { reason } => Some(reason.as_str()),
- _ => None,
- }
- }
-}
-
-impl RadrootsNostrSignerRequestAction {
- pub fn audit_message(&self) -> Option<String> {
- match self {
- Self::Allowed { .. } => None,
- Self::Denied { reason } => Some(reason.clone()),
- Self::Challenged { .. } => Some("auth challenge required".into()),
- }
- }
-}
-
-pub(crate) fn required_permission_for_request(request: &Request) -> Option<Permission> {
- radroots_nostr_connect::server::required_permission(request)
-}
-
-pub(crate) fn request_allowed_by_permissions(
- granted_permissions: &Permissions,
- request: &Request,
-) -> bool {
- let Some(required_permission) = required_permission_for_request(request) else {
- return true;
- };
-
- granted_permissions
- .as_slice()
- .iter()
- .any(|permission| permission_matches(permission, &required_permission))
-}
-
-pub(crate) fn response_hint_for_request(
- connection: &RadrootsNostrSignerConnectionRecord,
- request: &Request,
-) -> Result<RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerError> {
- match request {
- Request::GetPublicKey => Ok(RadrootsNostrSignerRequestResponseHint::UserPublicKey(
- identity_public_key(&connection.user_identity)?,
- )),
- Request::GetSessionCapability => Ok(
- RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(
- RemoteSessionCapability {
- user_public_key: identity_public_key(&connection.user_identity)?,
- relays: connection
- .relays
- .iter()
- .map(|relay| ConnectRelayUrl::parse(&relay.to_string()))
- .collect::<Result<Vec<_>, _>>()?,
- permissions: connection.effective_permissions(),
- },
- ),
- ),
- Request::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong),
- Request::SwitchRelays => Ok(RadrootsNostrSignerRequestResponseHint::RelayList(
- connection
- .relays
- .iter()
- .map(|relay| ConnectRelayUrl::parse(&relay.to_string()))
- .collect::<Result<Vec<_>, _>>()?,
- )),
- _ => Ok(RadrootsNostrSignerRequestResponseHint::None),
- }
-}
-
-fn permission_matches(granted_permission: &Permission, required_permission: &Permission) -> bool {
- if granted_permission.method != required_permission.method {
- return false;
- }
-
- match (
- &granted_permission.method,
- granted_permission.parameter.as_deref(),
- required_permission.parameter.as_deref(),
- ) {
- (Method::SignEvent, None, _) => true,
- (Method::SignEvent, Some(parameter), Some(required)) => {
- parameter == required || parameter == sign_event_kind_suffix(required)
- }
- (_, None, _) => true,
- (_, Some(parameter), Some(required)) => parameter == required,
- (_, Some(_), None) => false,
- }
-}
-
-fn sign_event_kind_suffix(value: &str) -> &str {
- value.strip_prefix("kind:").unwrap_or(value)
-}
-
-fn identity_public_key(
- identity: &PublicIdentity,
-) -> Result<radroots_identity::PublicKey, RadrootsNostrSignerError> {
- Ok(identity.public_key())
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::*;
- use crate::signer::test_support::{
- api_primary_https, fixture_alice_identity, fixture_alice_public_key, fixture_bob_identity,
- fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_key,
- };
- use nostr::{PublicKey, Timestamp};
- use radroots_nostr_connect::message::UnsignedEvent as ConnectUnsignedEvent;
- use serde_json::json;
-
- fn public_key(index: u32) -> PublicKey {
- synthetic_public_key(index)
- }
-
- fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey {
- radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key")
- }
-
- fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl {
- ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay")
- }
-
- fn unsigned_event(kind: u16) -> ConnectUnsignedEvent {
- ConnectUnsignedEvent::from_json(
- &json!({
- "pubkey": fixture_alice_public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": kind,
- "tags": [],
- "content": "hello"
- })
- .to_string(),
- )
- .expect("unsigned event")
- }
-
- fn connection() -> RadrootsNostrSignerConnectionRecord {
- RadrootsNostrSignerConnectionRecord::new(
- crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(),
- fixture_bob_identity(),
- RadrootsNostrSignerConnectionDraft::new(
- fixture_carol_public_key(),
- fixture_diego_identity(),
- )
- .with_relays(vec![primary_relay()]),
- 1,
- )
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn assert_action_audit_message_none(action: &RadrootsNostrSignerRequestAction) {
- assert_eq!(action.audit_message(), None);
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn assert_response_hint_none(hint: RadrootsNostrSignerRequestResponseHint) {
- match hint {
- RadrootsNostrSignerRequestResponseHint::None => {}
- other => panic!("unexpected response hint: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn assert_response_hint_pong(hint: RadrootsNostrSignerRequestResponseHint) {
- match hint {
- RadrootsNostrSignerRequestResponseHint::Pong => {}
- other => panic!("unexpected response hint: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn assert_response_hint_user_public_key(hint: RadrootsNostrSignerRequestResponseHint) {
- match hint {
- RadrootsNostrSignerRequestResponseHint::UserPublicKey(_) => {}
- other => panic!("unexpected response hint: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn assert_response_hint_remote_session_capability(
- hint: RadrootsNostrSignerRequestResponseHint,
- expected_permissions: Permissions,
- ) {
- match hint {
- RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => {
- let expected_public_key = fixture_diego_identity().public_key();
- assert_eq!(capability.user_public_key, expected_public_key);
- assert_eq!(capability.relays, vec![connect_relay(primary_relay())]);
- assert_eq!(capability.permissions, expected_permissions);
- }
- other => panic!("unexpected response hint: {other:?}"),
- }
- }
-
- #[test]
- fn connect_proposal_builds_connection_draft() {
- let requested_permissions: Permissions = vec![Permission::new(Method::Nip04Encrypt)].into();
- let proposal = RadrootsNostrSignerConnectProposal {
- client_public_key: public_key(5),
- connect_secret: Some("secret".into()),
- client_metadata: Some(ClientMetadata {
- requested_permissions: Permissions::default(),
- name: Some("Example Client".into()),
- url: Some("https://client.example.com/".into()),
- image: None,
- }),
- requested_permissions: requested_permissions.clone(),
- };
-
- let draft = proposal.into_connection_draft(fixture_alice_identity());
-
- assert_eq!(draft.connect_secret.as_deref(), Some("secret"));
- assert_eq!(draft.requested_permissions, requested_permissions);
- assert_eq!(
- draft
- .client_metadata
- .as_ref()
- .and_then(|metadata| metadata.name.as_deref()),
- Some("Example Client")
- );
-
- let no_secret = RadrootsNostrSignerConnectProposal {
- client_public_key: public_key(7),
- connect_secret: None,
- client_metadata: None,
- requested_permissions: Permissions::default(),
- }
- .into_connection_draft(fixture_bob_identity());
- assert!(no_secret.connect_secret.is_none());
- }
-
- #[test]
- fn request_action_audit_message_and_denied_reason_cover_variants() {
- let denied = RadrootsNostrSignerRequestAction::Denied {
- reason: "unauthorized".into(),
- };
- let challenged = RadrootsNostrSignerRequestAction::Challenged {
- auth_challenge: crate::signer::model::RadrootsNostrSignerAuthChallenge::new(
- api_primary_https(),
- 1,
- )
- .expect("challenge"),
- pending_request: crate::signer::model::RadrootsNostrSignerPendingRequest::new(
- radroots_nostr_connect::message::RequestMessage::new("req-1", Request::Ping),
- 1,
- )
- .expect("pending"),
- };
- let evaluation = RadrootsNostrSignerRequestEvaluation {
- request_id: RadrootsNostrSignerRequestId::new_v7(),
- method: Method::Ping,
- connection: connection(),
- audit: crate::signer::model::RadrootsNostrSignerRequestAuditRecord::new(
- RadrootsNostrSignerRequestId::new_v7(),
- crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(),
- Method::Ping,
- crate::signer::model::RadrootsNostrSignerRequestDecision::Denied,
- Some("unauthorized".into()),
- 1,
- ),
- action: denied.clone(),
- };
-
- assert_eq!(denied.audit_message().as_deref(), Some("unauthorized"));
- assert_eq!(
- challenged.audit_message().as_deref(),
- Some("auth challenge required")
- );
- assert_eq!(evaluation.denied_reason(), Some("unauthorized"));
- assert_action_audit_message_none(&RadrootsNostrSignerRequestAction::Allowed {
- required_permission: None,
- response_hint: RadrootsNostrSignerRequestResponseHint::None,
- });
- }
-
- #[test]
- fn request_permission_matching_covers_generic_and_sign_event_forms() {
- let kind_one = unsigned_event(1);
- let kind_two = unsigned_event(2);
- let sign_kind = Permission::with_parameter(Method::SignEvent, "kind:1");
- let sign_numeric = Permission::with_parameter(Method::SignEvent, "1");
- let sign_all = Permission::new(Method::SignEvent);
- let nip44 = Permission::new(Method::Nip44Encrypt);
-
- assert!(request_allowed_by_permissions(
- &vec![sign_kind.clone()].into(),
- &Request::SignEvent(kind_one.clone()),
- ));
- assert!(request_allowed_by_permissions(
- &vec![sign_numeric].into(),
- &Request::SignEvent(kind_one),
- ));
- assert!(request_allowed_by_permissions(
- &vec![sign_all].into(),
- &Request::SignEvent(kind_two),
- ));
- assert!(!request_allowed_by_permissions(
- &vec![sign_kind, nip44].into(),
- &Request::Nip04Encrypt {
- public_key: connect_public_key(public_key(7)),
- plaintext: "hello".into(),
- },
- ));
- assert!(request_allowed_by_permissions(
- &Permissions::default(),
- &Request::Ping,
- ));
- assert!(!request_allowed_by_permissions(
- &vec![Permission::with_parameter(
- Method::custom("do_thing").expect("valid custom NIP-46 method"),
- "scoped",
- )]
- .into(),
- &Request::Custom {
- method: Method::custom("do_thing").expect("valid custom NIP-46 method"),
- params: vec!["value".into()],
- },
- ));
- assert!(permission_matches(
- &Permission::new(Method::Nip04Encrypt),
- &Permission::new(Method::Nip04Encrypt),
- ));
- assert!(permission_matches(
- &Permission::with_parameter(
- Method::custom("scoped").expect("valid custom NIP-46 method"),
- "alpha",
- ),
- &Permission::with_parameter(
- Method::custom("scoped").expect("valid custom NIP-46 method"),
- "alpha",
- ),
- ));
- }
-
- #[test]
- fn required_permission_and_response_hint_cover_request_variants() {
- let connection = connection();
- let public_key = public_key(8);
- let connect = Request::Connect {
- remote_signer_public_key: connect_public_key(public_key),
- secret: Some("secret".into()),
- requested_permissions: Permissions::default(),
- client_metadata: None,
- };
- let ping = Request::Ping;
- let get_public_key = Request::GetPublicKey;
- let get_session_capability = Request::GetSessionCapability;
- let switch_relays = Request::SwitchRelays;
- let sign_event = Request::SignEvent(unsigned_event(7));
- let custom = Request::Custom {
- method: Method::custom("do_thing").expect("valid custom NIP-46 method"),
- params: vec!["a".into()],
- };
-
- assert!(required_permission_for_request(&connect).is_none());
- assert!(required_permission_for_request(&ping).is_none());
- assert!(required_permission_for_request(&get_public_key).is_none());
- assert!(required_permission_for_request(&get_session_capability).is_none());
- assert_eq!(
- required_permission_for_request(&Request::Nip04Decrypt {
- public_key: connect_public_key(public_key),
- ciphertext: "cipher".into(),
- })
- .expect("nip04 decrypt permission")
- .to_string(),
- "nip04_decrypt"
- );
- assert_eq!(
- required_permission_for_request(&Request::Nip44Encrypt {
- public_key: connect_public_key(public_key),
- plaintext: "hello".into(),
- })
- .expect("nip44 encrypt permission")
- .to_string(),
- "nip44_encrypt"
- );
- assert_eq!(
- required_permission_for_request(&Request::Nip44Decrypt {
- public_key: connect_public_key(public_key),
- ciphertext: "cipher".into(),
- })
- .expect("nip44 decrypt permission")
- .to_string(),
- "nip44_decrypt"
- );
- assert_eq!(
- required_permission_for_request(&switch_relays)
- .expect("switch relays permission")
- .to_string(),
- "switch_relays"
- );
- assert_eq!(
- required_permission_for_request(&sign_event)
- .expect("sign_event permission")
- .to_string(),
- "sign_event:kind:7"
- );
- assert_eq!(
- required_permission_for_request(&custom)
- .expect("custom permission")
- .to_string(),
- "do_thing"
- );
-
- assert_response_hint_none(
- response_hint_for_request(
- &connection,
- &Request::Nip04Decrypt {
- public_key: connect_public_key(public_key),
- ciphertext: "cipher".into(),
- },
- )
- .expect("nip04 response hint"),
- );
- assert_response_hint_pong(
- response_hint_for_request(&connection, &ping).expect("ping hint"),
- );
- assert_response_hint_user_public_key(
- response_hint_for_request(&connection, &get_public_key).expect("pubkey hint"),
- );
- assert_response_hint_remote_session_capability(
- response_hint_for_request(&connection, &get_session_capability)
- .expect("capability hint"),
- connection.effective_permissions(),
- );
- assert_eq!(
- response_hint_for_request(&connection, &switch_relays).expect("relay hint"),
- RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay(primary_relay())])
- );
- }
-}
diff --git a/src/signer/manager.rs b/src/signer/manager.rs
@@ -1,4004 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::error::RadrootsNostrSignerError;
-use crate::signer::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal,
- RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerSessionLookup, request_allowed_by_permissions,
- required_permission_for_request, response_hint_for_request,
-};
-use crate::signer::model::{
- RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement,
- RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge,
- RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome,
- RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
- RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
- RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
- RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
- RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
-};
-use crate::signer::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore};
-use nostr::{PublicKey, RelayUrl};
-use radroots_nostr_connect::{
- Method, Request, message::RequestMessage, permission::Permissions, uri::ClientMetadata,
-};
-use std::sync::{Arc, RwLock};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-#[derive(Clone)]
-pub struct RadrootsNostrSignerManager {
- store: Arc<dyn RadrootsNostrSignerStore>,
- state: Arc<RwLock<RadrootsNostrSignerStoreState>>,
-}
-
-impl RadrootsNostrSignerManager {
- pub fn new_in_memory() -> Self {
- Self {
- store: Arc::new(RadrootsNostrMemorySignerStore::new()),
- state: Arc::new(RwLock::new(RadrootsNostrSignerStoreState::default())),
- }
- }
-
- pub fn new(store: Arc<dyn RadrootsNostrSignerStore>) -> Result<Self, RadrootsNostrSignerError> {
- let state = store.load()?;
- if state.version != RADROOTS_NOSTR_SIGNER_STORE_VERSION {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "unsupported signer schema version {}",
- state.version
- )));
- }
-
- Ok(Self {
- store,
- state: Arc::new(RwLock::new(state)),
- })
- }
-
- pub fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard.signer_identity.clone())
- }
-
- pub fn set_signer_identity(
- &self,
- signer_identity: PublicIdentity,
- ) -> Result<(), RadrootsNostrSignerError> {
- validate_public_identity(&signer_identity)?;
- self.update_state(|state| {
- state.signer_identity = Some(signer_identity);
- Ok(())
- })
- }
-
- pub fn list_connections(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard.connections.clone())
- }
-
- pub fn get_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard
- .connections
- .iter()
- .find(|record| &record.connection_id == connection_id)
- .cloned())
- }
-
- pub fn list_publish_workflows(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard.publish_workflows.clone())
- }
-
- pub fn get_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard
- .publish_workflows
- .iter()
- .find(|record| &record.workflow_id == workflow_id)
- .cloned())
- }
-
- pub fn find_connections_by_client_public_key(
- &self,
- client_public_key: &PublicKey,
- ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard
- .connections
- .iter()
- .filter(|record| &record.client_public_key == client_public_key)
- .cloned()
- .collect())
- }
-
- pub fn find_connection_by_connect_secret(
- &self,
- connect_secret: &str,
- ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
- let Some(connect_secret_hash) =
- RadrootsNostrSignerConnectSecretHash::from_secret(connect_secret)
- else {
- return Ok(None);
- };
-
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard
- .connections
- .iter()
- .find(|record| {
- record.connect_secret_hash.as_ref() == Some(&connect_secret_hash)
- && (!record.is_terminal() || record.connect_secret_is_consumed())
- })
- .cloned())
- }
-
- pub fn lookup_session(
- &self,
- client_public_key: &PublicKey,
- connect_secret: Option<&str>,
- ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> {
- if let Some(connect_secret) = connect_secret
- && let Some(connection) = self.find_connection_by_connect_secret(connect_secret)?
- {
- if &connection.client_public_key != client_public_key {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect secret is bound to a different client public key".into(),
- ));
- }
- return Ok(RadrootsNostrSignerSessionLookup::Connection(Box::new(
- connection,
- )));
- }
-
- let mut matches = self.find_connections_by_client_public_key(client_public_key)?;
- matches.retain(|record| !record.is_terminal());
- Ok(match matches.len() {
- 0 => RadrootsNostrSignerSessionLookup::None,
- 1 => RadrootsNostrSignerSessionLookup::Connection(Box::new(matches.remove(0))),
- _ => RadrootsNostrSignerSessionLookup::Ambiguous(matches),
- })
- }
-
- pub fn evaluate_connect_request(
- &self,
- client_public_key: PublicKey,
- request: Request,
- ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> {
- let Request::Connect {
- remote_signer_public_key,
- secret,
- requested_permissions,
- client_metadata,
- } = request
- else {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect evaluation requires a connect request".into(),
- ));
- };
-
- let remote_signer_public_key =
- radroots_nostr::key::public_key_to_nostr(remote_signer_public_key)?;
- let (connect_secret, existing_connection) =
- self.resolve_connect_request_context(remote_signer_public_key, secret)?;
- if let Some(connection) = existing_connection {
- if connection.client_public_key != client_public_key {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect secret is bound to a different client public key".into(),
- ));
- }
- return Ok(RadrootsNostrSignerConnectEvaluation::ExistingConnection(
- Box::new(connection),
- ));
- }
-
- Ok(RadrootsNostrSignerConnectEvaluation::RegistrationRequired(
- RadrootsNostrSignerConnectProposal {
- client_public_key,
- connect_secret,
- client_metadata: client_metadata.map(normalize_client_metadata).transpose()?,
- requested_permissions: normalize_permissions(requested_permissions),
- },
- ))
- }
-
- pub fn list_audit_records(
- &self,
- ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard.audit_records.clone())
- }
-
- pub fn audit_records_for_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> {
- let guard = self
- .state
- .read()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- Ok(guard
- .audit_records
- .iter()
- .filter(|record| &record.connection_id == connection_id)
- .cloned()
- .collect())
- }
-
- pub fn register_connection(
- &self,
- draft: RadrootsNostrSignerConnectionDraft,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let signer_identity = state
- .signer_identity
- .clone()
- .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?;
- validate_public_identity(&signer_identity)?;
- validate_public_identity(&draft.user_identity)?;
-
- let connect_secret_hash = draft
- .connect_secret
- .as_deref()
- .and_then(RadrootsNostrSignerConnectSecretHash::from_secret);
- if let Some(secret_hash) = connect_secret_hash.as_ref()
- && state.connections.iter().any(|record| {
- record.connect_secret_hash.as_ref() == Some(secret_hash)
- && (!record.is_terminal() || record.connect_secret_is_consumed())
- })
- {
- return Err(RadrootsNostrSignerError::ConnectSecretAlreadyInUse);
- }
-
- if state.connections.iter().any(|record| {
- !record.is_terminal()
- && record.client_public_key == draft.client_public_key
- && record.user_identity.id() == draft.user_identity.id()
- }) {
- return Err(RadrootsNostrSignerError::ConnectionAlreadyExists {
- client_public_key: draft.client_public_key.to_hex(),
- user_identity_id: draft.user_identity.id().to_string(),
- });
- }
-
- let created_at_unix = now_unix_secs();
- let record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- signer_identity,
- RadrootsNostrSignerConnectionDraft {
- client_public_key: draft.client_public_key,
- user_identity: draft.user_identity,
- connect_secret: draft.connect_secret,
- client_metadata: draft
- .client_metadata
- .map(normalize_client_metadata)
- .transpose()?,
- requested_permissions: normalize_permissions(draft.requested_permissions),
- relays: normalize_relays(draft.relays),
- approval_requirement: draft.approval_requirement,
- },
- created_at_unix,
- );
- state.connections.push(record.clone());
- Ok(record)
- })
- }
-
- pub fn set_granted_permissions(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let updated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot update granted permissions for {} connection",
- status_label(record.status)
- )));
- }
-
- let granted_permissions = normalize_permissions(granted_permissions);
- validate_granted_permissions(&record.requested_permissions, &granted_permissions)?;
- record.granted_permissions = granted_permissions
- .as_slice()
- .iter()
- .cloned()
- .map(|permission| {
- RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix)
- })
- .collect();
- record.touch_updated(updated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn approve_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- granted_permissions: Permissions,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let updated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.approval_requirement != RadrootsNostrSignerApprovalRequirement::ExplicitUser {
- return Err(RadrootsNostrSignerError::InvalidState(
- "approval not required for connection".into(),
- ));
- }
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot approve {} connection",
- status_label(record.status)
- )));
- }
-
- let granted_permissions = normalize_permissions(granted_permissions);
- validate_granted_permissions(&record.requested_permissions, &granted_permissions)?;
- record.granted_permissions = granted_permissions
- .as_slice()
- .iter()
- .cloned()
- .map(|permission| {
- RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix)
- })
- .collect();
- record.approval_state = RadrootsNostrSignerApprovalState::Approved;
- record.status = RadrootsNostrSignerConnectionStatus::Active;
- record.status_reason = None;
- record.touch_updated(updated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn reject_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let updated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot reject {} connection",
- status_label(record.status)
- )));
- }
-
- record.approval_state = RadrootsNostrSignerApprovalState::Rejected;
- record.status = RadrootsNostrSignerConnectionStatus::Rejected;
- record.status_reason = normalize_optional_string(reason);
- record.touch_updated(updated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn revoke_connection(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- reason: Option<String>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let updated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.status == RadrootsNostrSignerConnectionStatus::Revoked {
- return Ok(record.clone());
- }
-
- record.status = RadrootsNostrSignerConnectionStatus::Revoked;
- record.status_reason = normalize_optional_string(reason);
- record.touch_updated(updated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn update_relays(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- relays: Vec<RelayUrl>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let updated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot update relays for {} connection",
- status_label(record.status)
- )));
- }
-
- record.relays = normalize_relays(relays);
- record.touch_updated(updated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn require_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- auth_url: impl AsRef<str>,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let required_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot require auth for {} connection",
- status_label(record.status)
- )));
- }
-
- let challenge =
- RadrootsNostrSignerAuthChallenge::new(auth_url.as_ref(), required_at_unix)?;
- record.require_auth_challenge(challenge);
- Ok(record.clone())
- })
- }
-
- pub fn set_pending_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot set pending request for {} connection",
- status_label(record.status)
- )));
- }
- if record.auth_state != RadrootsNostrSignerAuthState::Pending {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not pending for connection".into(),
- ));
- }
-
- let pending_request =
- RadrootsNostrSignerPendingRequest::new(request_message, now_unix_secs())?;
- record.set_pending_request(pending_request);
- Ok(record.clone())
- })
- }
-
- pub fn authorize_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot authorize auth challenge for {} connection",
- status_label(record.status)
- )));
- }
- if record.auth_state != RadrootsNostrSignerAuthState::Pending {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not pending for connection".into(),
- ));
- }
-
- let pending_request = record.authorize_auth_challenge(now_unix_secs());
- Ok(RadrootsNostrSignerAuthorizationOutcome::new(
- record.clone(),
- pending_request,
- ))
- })
- }
-
- pub fn restore_pending_auth_challenge(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- pending_request: RadrootsNostrSignerPendingRequest,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let restored_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot restore auth challenge for {} connection",
- status_label(record.status)
- )));
- }
- if record.auth_state != RadrootsNostrSignerAuthState::Authorized {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not authorized for connection".into(),
- ));
- }
- if record.auth_challenge.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge missing for connection".into(),
- ));
- }
-
- record.restore_pending_auth_challenge(pending_request, restored_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn begin_connect_secret_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let connection_index = find_connection_index(state, connection_id)?;
- let record = &state.connections[connection_index];
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot begin connect secret finalization for {} connection",
- status_label(record.status)
- )));
- }
- if record.connect_secret_hash.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connection does not have a connect secret".into(),
- ));
- }
- if record.connect_secret_is_consumed() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect secret already consumed for connection".into(),
- ));
- }
- ensure_no_active_publish_workflow(
- state,
- connection_id,
- RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization,
- )?;
-
- let workflow =
- RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
- connection_id.clone(),
- now_unix_secs(),
- );
- state.publish_workflows.push(workflow.clone());
- Ok(workflow)
- })
- }
-
- pub fn begin_auth_replay_publish_finalization(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let authorized_at_unix = now_unix_secs();
- let connection_index = find_connection_index(state, connection_id)?;
- let record = &state.connections[connection_index];
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot begin auth replay finalization for {} connection",
- status_label(record.status)
- )));
- }
- if record.auth_state != RadrootsNostrSignerAuthState::Pending {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not pending for connection".into(),
- ));
- }
- if record.auth_challenge.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge missing for connection".into(),
- ));
- }
- let pending_request = record.pending_request.clone().ok_or_else(|| {
- RadrootsNostrSignerError::InvalidState(
- "pending request missing for auth replay finalization".into(),
- )
- })?;
- ensure_no_active_publish_workflow(
- state,
- connection_id,
- RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization,
- )?;
-
- let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
- connection_id.clone(),
- pending_request,
- authorized_at_unix,
- );
- state.publish_workflows.push(workflow.clone());
- Ok(workflow)
- })
- }
-
- pub fn mark_publish_workflow_published(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let workflow = find_publish_workflow_mut(state, workflow_id)?;
- workflow.mark_published(now_unix_secs());
- Ok(workflow.clone())
- })
- }
-
- pub fn finalize_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let workflow_index = find_publish_workflow_index(state, workflow_id)?;
- let workflow = state.publish_workflows[workflow_index].clone();
- if workflow.state != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize {
- return Err(RadrootsNostrSignerError::InvalidState(
- "publish workflow has not reached published state".into(),
- ));
- }
-
- let record = find_connection_mut(state, &workflow.connection_id)?;
- let finalized = match workflow.kind {
- RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
- if record.connect_secret_hash.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connection does not have a connect secret".into(),
- ));
- }
- if record.connect_secret_is_consumed() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect secret already consumed for connection".into(),
- ));
- }
- record.mark_connect_secret_consumed(now_unix_secs());
- record.clone()
- }
- RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
- if record.auth_state != RadrootsNostrSignerAuthState::Pending {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not pending for connection".into(),
- ));
- }
- if record.auth_challenge.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge missing for connection".into(),
- ));
- }
- let expected_pending_request =
- workflow.pending_request.clone().ok_or_else(|| {
- RadrootsNostrSignerError::InvalidState(
- "auth replay workflow missing pending request".into(),
- )
- })?;
- if record.pending_request.as_ref() != Some(&expected_pending_request) {
- return Err(RadrootsNostrSignerError::InvalidState(
- "pending request does not match auth replay workflow".into(),
- ));
- }
- let authorized_at_unix = workflow.authorized_at_unix.ok_or_else(|| {
- RadrootsNostrSignerError::InvalidState(
- "auth replay workflow missing authorized timestamp".into(),
- )
- })?;
- let replay = record.authorize_auth_challenge(authorized_at_unix);
- debug_assert_eq!(
- replay.as_ref(),
- Some(&expected_pending_request),
- "auth replay finalization returned unexpected pending request"
- );
- record.clone()
- }
- };
-
- state.publish_workflows.remove(workflow_index);
- Ok(finalized)
- })
- }
-
- pub fn cancel_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let workflow_index = find_publish_workflow_index(state, workflow_id)?;
- Ok(state.publish_workflows.remove(workflow_index))
- })
- }
-
- pub fn mark_authenticated(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let authenticated_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- record.mark_authenticated(authenticated_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn mark_connect_secret_consumed(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let consumed_at_unix = now_unix_secs();
- let record = find_connection_mut(state, connection_id)?;
- if record.connect_secret_hash.is_none() {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connection does not have a connect secret".into(),
- ));
- }
- record.mark_connect_secret_consumed(consumed_at_unix);
- Ok(record.clone())
- })
- }
-
- pub fn evaluate_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> {
- if matches!(request_message.request, Request::Connect { .. }) {
- return Err(RadrootsNostrSignerError::InvalidState(
- "connect requests must be evaluated via evaluate_connect_request".into(),
- ));
- }
-
- self.update_state_with(|state| {
- let request_at_unix = now_unix_secs();
- let request_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?;
- let record = find_connection_mut(state, connection_id)?;
- let method = request_message.request.method();
- let action = evaluate_request_action(record, &request_message, request_at_unix)?;
- record.mark_request(request_at_unix);
-
- let audit = RadrootsNostrSignerRequestAuditRecord::new(
- request_id.clone(),
- connection_id.clone(),
- method.clone(),
- request_decision(&action),
- action.audit_message(),
- request_at_unix,
- );
- let connection = record.clone();
- state.audit_records.push(audit.clone());
-
- Ok(RadrootsNostrSignerRequestEvaluation {
- request_id,
- method,
- connection,
- audit,
- action,
- })
- })
- }
-
- pub fn evaluate_auth_replay_publish_workflow(
- &self,
- workflow_id: &RadrootsNostrSignerWorkflowId,
- ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let request_at_unix = now_unix_secs();
- let workflow = state
- .publish_workflows
- .iter()
- .find(|record| &record.workflow_id == workflow_id)
- .cloned()
- .ok_or_else(|| {
- RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string())
- })?;
- if workflow.kind != RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization {
- return Err(RadrootsNostrSignerError::InvalidState(
- "publish workflow is not an auth replay finalization".into(),
- ));
- }
-
- let pending_request = workflow.pending_request.clone().ok_or_else(|| {
- RadrootsNostrSignerError::InvalidState(
- "auth replay workflow missing pending request".into(),
- )
- })?;
- let request_message = pending_request.request_message();
- let request_id = pending_request.request_id();
- let method = request_message.request.method();
-
- let record = find_connection_mut(state, &workflow.connection_id)?;
- if record.is_terminal() {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "cannot evaluate auth replay workflow for {} connection",
- status_label(record.status)
- )));
- }
- if record.auth_state != RadrootsNostrSignerAuthState::Pending {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth challenge not pending for connection".into(),
- ));
- }
- if record.pending_request.as_ref() != Some(&pending_request) {
- return Err(RadrootsNostrSignerError::InvalidState(
- "pending request does not match auth replay workflow".into(),
- ));
- }
-
- let mut effective_connection = record.clone();
- effective_connection.auth_state = RadrootsNostrSignerAuthState::Authorized;
- effective_connection.pending_request = None;
- if let Some(auth_challenge) = effective_connection.auth_challenge.as_mut() {
- auth_challenge.authorized_at_unix = workflow.authorized_at_unix;
- }
- let request = &request_message;
- let action =
- evaluate_request_action(&mut effective_connection, request, request_at_unix)?;
- effective_connection.mark_request(request_at_unix);
- record.mark_request(request_at_unix);
-
- let audit = RadrootsNostrSignerRequestAuditRecord::new(
- request_id.clone(),
- workflow.connection_id.clone(),
- method.clone(),
- request_decision(&action),
- action.audit_message(),
- request_at_unix,
- );
- replace_or_insert_auth_replay_audit(state, audit.clone())?;
-
- Ok(RadrootsNostrSignerRequestEvaluation {
- request_id,
- method,
- connection: effective_connection,
- audit,
- action,
- })
- })
- }
-
- pub fn record_request(
- &self,
- connection_id: &RadrootsNostrSignerConnectionId,
- request_id: impl AsRef<str>,
- method: Method,
- decision: RadrootsNostrSignerRequestDecision,
- message: Option<String>,
- ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- let created_at_unix = now_unix_secs();
- let request_id = RadrootsNostrSignerRequestId::parse(request_id.as_ref())?;
- let record = find_connection_mut(state, connection_id)?;
- record.mark_request(created_at_unix);
-
- let audit = RadrootsNostrSignerRequestAuditRecord::new(
- request_id,
- connection_id.clone(),
- method,
- decision,
- normalize_optional_string(message),
- created_at_unix,
- );
- state.audit_records.push(audit.clone());
- Ok(audit)
- })
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn update_state(
- &self,
- update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>,
- ) -> Result<(), RadrootsNostrSignerError> {
- self.update_state_with(|state| {
- update(state)?;
- Ok(())
- })
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn update_state_with<T>(
- &self,
- update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<T, RadrootsNostrSignerError>,
- ) -> Result<T, RadrootsNostrSignerError> {
- let mut guard = self
- .state
- .write()
- .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?;
- let mut next = guard.clone();
- let value = update(&mut next)?;
- self.store.save(&next)?;
- *guard = next;
- Ok(value)
- }
-
- fn resolve_connect_request_context(
- &self,
- remote_signer_public_key: PublicKey,
- secret: Option<String>,
- ) -> Result<
- (Option<String>, Option<RadrootsNostrSignerConnectionRecord>),
- RadrootsNostrSignerError,
- > {
- let signer_identity = self
- .signer_identity()?
- .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?;
- let signer_public_key = parse_identity_public_key(&signer_identity)?;
- if remote_signer_public_key != signer_public_key {
- return Err(RadrootsNostrSignerError::InvalidState(
- "remote signer public key mismatch".into(),
- ));
- }
-
- let connect_secret = normalize_optional_string(secret);
- let existing_connection =
- self.find_connection_by_connect_secret(connect_secret.as_deref().unwrap_or_default())?;
- Ok((connect_secret, existing_connection))
- }
-}
-
-fn find_connection_mut<'a>(
- state: &'a mut RadrootsNostrSignerStoreState,
- connection_id: &RadrootsNostrSignerConnectionId,
-) -> Result<&'a mut RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
- state
- .connections
- .iter_mut()
- .find(|record| &record.connection_id == connection_id)
- .ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string()))
-}
-
-fn find_connection_index(
- state: &RadrootsNostrSignerStoreState,
- connection_id: &RadrootsNostrSignerConnectionId,
-) -> Result<usize, RadrootsNostrSignerError> {
- for (index, record) in state.connections.iter().enumerate() {
- if &record.connection_id == connection_id {
- return Ok(index);
- }
- }
- Err(RadrootsNostrSignerError::ConnectionNotFound(
- connection_id.to_string(),
- ))
-}
-
-fn find_publish_workflow_index(
- state: &RadrootsNostrSignerStoreState,
- workflow_id: &RadrootsNostrSignerWorkflowId,
-) -> Result<usize, RadrootsNostrSignerError> {
- state
- .publish_workflows
- .iter()
- .position(|record| &record.workflow_id == workflow_id)
- .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string()))
-}
-
-fn find_publish_workflow_mut<'a>(
- state: &'a mut RadrootsNostrSignerStoreState,
- workflow_id: &RadrootsNostrSignerWorkflowId,
-) -> Result<&'a mut RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
- state
- .publish_workflows
- .iter_mut()
- .find(|record| &record.workflow_id == workflow_id)
- .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string()))
-}
-
-fn ensure_no_active_publish_workflow(
- state: &RadrootsNostrSignerStoreState,
- connection_id: &RadrootsNostrSignerConnectionId,
- kind: RadrootsNostrSignerPublishWorkflowKind,
-) -> Result<(), RadrootsNostrSignerError> {
- if state
- .publish_workflows
- .iter()
- .any(|record| &record.connection_id == connection_id && record.kind == kind)
- {
- return Err(RadrootsNostrSignerError::InvalidState(format!(
- "publish workflow already active for {}",
- publish_workflow_kind_label(kind)
- )));
- }
- Ok(())
-}
-
-fn validate_public_identity(_identity: &PublicIdentity) -> Result<(), RadrootsNostrSignerError> {
- Ok(())
-}
-
-fn validate_granted_permissions(
- requested_permissions: &Permissions,
- granted_permissions: &Permissions,
-) -> Result<(), RadrootsNostrSignerError> {
- if requested_permissions.is_empty() {
- return Ok(());
- }
-
- let requested = requested_permissions.as_slice();
- if let Some(permission) = granted_permissions
- .as_slice()
- .iter()
- .find(|permission| !requested.contains(permission))
- {
- return Err(RadrootsNostrSignerError::InvalidGrantedPermission(
- permission.to_string(),
- ));
- }
- Ok(())
-}
-
-fn evaluate_request_action(
- record: &mut RadrootsNostrSignerConnectionRecord,
- request_message: &RequestMessage,
- request_at_unix: u64,
-) -> Result<RadrootsNostrSignerRequestAction, RadrootsNostrSignerError> {
- if record.is_terminal() {
- return Ok(RadrootsNostrSignerRequestAction::Denied {
- reason: format!("connection is {}", status_label(record.status)),
- });
- }
- if record.status != RadrootsNostrSignerConnectionStatus::Active {
- return Ok(RadrootsNostrSignerRequestAction::Denied {
- reason: format!("connection is {}", status_label(record.status)),
- });
- }
- if record.auth_state == RadrootsNostrSignerAuthState::Pending {
- let auth_challenge =
- record
- .auth_challenge
- .clone()
- .ok_or(RadrootsNostrSignerError::InvalidState(
- "auth challenge missing for pending auth state".into(),
- ))?;
- let pending_request =
- RadrootsNostrSignerPendingRequest::new(request_message.clone(), request_at_unix)?;
- record.set_pending_request(pending_request.clone());
- return Ok(RadrootsNostrSignerRequestAction::Challenged {
- auth_challenge,
- pending_request,
- });
- }
-
- let effective_permissions = record.effective_permissions();
- if !request_allowed_by_permissions(&effective_permissions, &request_message.request) {
- return Ok(RadrootsNostrSignerRequestAction::Denied {
- reason: format!("unauthorized {}", request_message.request.method()),
- });
- }
-
- Ok(RadrootsNostrSignerRequestAction::Allowed {
- required_permission: required_permission_for_request(&request_message.request),
- response_hint: response_hint_for_request(record, &request_message.request)?,
- })
-}
-
-fn normalize_permissions(permissions: Permissions) -> Permissions {
- let mut permissions = permissions.into_vec();
- permissions.sort();
- permissions.dedup();
- permissions.into()
-}
-
-fn normalize_client_metadata(
- mut metadata: ClientMetadata,
-) -> Result<ClientMetadata, RadrootsNostrSignerError> {
- metadata.requested_permissions = Permissions::default();
- Ok(metadata.normalized()?)
-}
-
-fn normalize_relays(relays: Vec<RelayUrl>) -> Vec<RelayUrl> {
- let mut relays = relays;
- relays.sort_by(|left, right| left.as_str().cmp(right.as_str()));
- relays.dedup_by(|left, right| left.as_str() == right.as_str());
- relays
-}
-
-fn normalize_optional_string(value: Option<String>) -> Option<String> {
- value.and_then(|value| {
- let trimmed = value.trim().to_owned();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed)
- }
- })
-}
-
-fn status_label(status: RadrootsNostrSignerConnectionStatus) -> &'static str {
- match status {
- RadrootsNostrSignerConnectionStatus::Pending => "pending",
- RadrootsNostrSignerConnectionStatus::Active => "active",
- RadrootsNostrSignerConnectionStatus::Rejected => "rejected",
- RadrootsNostrSignerConnectionStatus::Revoked => "revoked",
- }
-}
-
-fn publish_workflow_kind_label(kind: RadrootsNostrSignerPublishWorkflowKind) -> &'static str {
- match kind {
- RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
- "connect_secret_finalization"
- }
- RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
- "auth_replay_finalization"
- }
- }
-}
-
-fn request_decision(
- action: &RadrootsNostrSignerRequestAction,
-) -> RadrootsNostrSignerRequestDecision {
- match action {
- RadrootsNostrSignerRequestAction::Allowed { .. } => {
- RadrootsNostrSignerRequestDecision::Allowed
- }
- RadrootsNostrSignerRequestAction::Denied { .. } => {
- RadrootsNostrSignerRequestDecision::Denied
- }
- RadrootsNostrSignerRequestAction::Challenged { .. } => {
- RadrootsNostrSignerRequestDecision::Challenged
- }
- }
-}
-
-fn parse_identity_public_key(
- identity: &PublicIdentity,
-) -> Result<PublicKey, RadrootsNostrSignerError> {
- PublicKey::from_hex(&identity.public_key().to_hex()).map_err(|_| {
- RadrootsNostrSignerError::InvalidState("identity public key is invalid".into())
- })
-}
-
-fn now_unix_secs() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_secs())
- .unwrap_or(0)
-}
-
-fn replace_or_insert_auth_replay_audit(
- state: &mut RadrootsNostrSignerStoreState,
- replacement: RadrootsNostrSignerRequestAuditRecord,
-) -> Result<(), RadrootsNostrSignerError> {
- let Some(existing) = state
- .audit_records
- .iter_mut()
- .find(|record| record.request_id == replacement.request_id)
- else {
- state.audit_records.push(replacement);
- return Ok(());
- };
- if existing.connection_id != replacement.connection_id || existing.method != replacement.method
- {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth replay audit does not match the original request".into(),
- ));
- }
- *existing = replacement;
- Ok(())
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::*;
- use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
- use crate::signer::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction,
- RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup,
- };
- use crate::signer::store::RadrootsNostrSignerStore;
- use crate::signer::test_support::{
- api_primary_https, fixture_alice_identity, primary_relay, secondary_relay,
- synthetic_public_identity, synthetic_public_key, tertiary_relay,
- };
- use nostr::{PublicKey, Timestamp};
- use radroots_nostr_connect::{Permission, message::UnsignedEvent as ConnectUnsignedEvent};
- use serde_json::json;
- use std::sync::Arc;
- use std::thread;
-
- fn public_identity(index: u32) -> PublicIdentity {
- synthetic_public_identity(index)
- }
-
- fn public_key(index: u32) -> PublicKey {
- synthetic_public_key(index)
- }
-
- fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey {
- radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key")
- }
-
- fn permission(method: Method, parameter: Option<&str>) -> Permission {
- match parameter {
- Some(parameter) => Permission::with_parameter(method, parameter),
- None => Permission::new(method),
- }
- }
-
- fn request_message(id: &str) -> RequestMessage {
- RequestMessage::new(id, radroots_nostr_connect::Request::Ping)
- }
-
- fn request_message_with_request(id: &str, request: Request) -> RequestMessage {
- RequestMessage::new(id, request)
- }
-
- fn unsigned_event(kind: u16) -> ConnectUnsignedEvent {
- ConnectUnsignedEvent::from_json(
- &json!({
- "pubkey": public_key(0xa1).to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": kind,
- "tags": [],
- "content": "hello"
- })
- .to_string(),
- )
- .expect("unsigned event")
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_connection_lookup(
- lookup: RadrootsNostrSignerSessionLookup,
- ) -> RadrootsNostrSignerConnectionRecord {
- match lookup {
- RadrootsNostrSignerSessionLookup::Connection(found) => *found,
- other => panic!("unexpected lookup result: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_ambiguous_lookup(
- lookup: RadrootsNostrSignerSessionLookup,
- ) -> Vec<RadrootsNostrSignerConnectionRecord> {
- match lookup {
- RadrootsNostrSignerSessionLookup::Ambiguous(found) => found,
- other => panic!("unexpected ambiguous lookup result: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_existing_connect(
- evaluation: RadrootsNostrSignerConnectEvaluation,
- ) -> RadrootsNostrSignerConnectionRecord {
- match evaluation {
- RadrootsNostrSignerConnectEvaluation::ExistingConnection(found) => *found,
- other => panic!("unexpected existing connect result: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_registration_connect(
- evaluation: RadrootsNostrSignerConnectEvaluation,
- ) -> crate::signer::evaluation::RadrootsNostrSignerConnectProposal {
- match evaluation {
- RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal,
- other => panic!("unexpected registration connect result: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_none_lookup(lookup: RadrootsNostrSignerSessionLookup) {
- match lookup {
- RadrootsNostrSignerSessionLookup::None => {}
- other => panic!("unexpected non-empty lookup result: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_allowed_user_public_key(action: &RadrootsNostrSignerRequestAction) {
- match action {
- RadrootsNostrSignerRequestAction::Allowed {
- required_permission: None,
- response_hint: RadrootsNostrSignerRequestResponseHint::UserPublicKey(_),
- } => {}
- other => panic!("unexpected allowed pubkey action: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_allowed_without_response_hint(action: &RadrootsNostrSignerRequestAction) {
- match action {
- RadrootsNostrSignerRequestAction::Allowed {
- required_permission: Some(_),
- response_hint: RadrootsNostrSignerRequestResponseHint::None,
- } => {}
- other => panic!("unexpected allowed no-hint action: {other:?}"),
- }
- }
-
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn expect_challenged_action(action: &RadrootsNostrSignerRequestAction) {
- match action {
- RadrootsNostrSignerRequestAction::Challenged { .. } => {}
- other => panic!("unexpected challenged action: {other:?}"),
- }
- }
-
- fn poison_manager_state(manager: &RadrootsNostrSignerManager) {
- let shared = manager.state.clone();
- let _ = thread::spawn(move || {
- let _guard = shared.write().expect("write");
- panic!("poison signer state");
- })
- .join();
- }
-
- fn assert_same_public_identity(left: &PublicIdentity, right: &PublicIdentity) {
- assert_eq!(left, right);
- }
-
- fn assert_same_connection(
- left: &RadrootsNostrSignerConnectionRecord,
- right: &RadrootsNostrSignerConnectionRecord,
- ) {
- assert_eq!(left.connection_id, right.connection_id);
- assert_eq!(left.client_public_key, right.client_public_key);
- assert_same_public_identity(&left.signer_identity, &right.signer_identity);
- assert_same_public_identity(&left.user_identity, &right.user_identity);
- assert_eq!(left.connect_secret_hash, right.connect_secret_hash);
- assert_eq!(
- left.connect_secret_consumed_at_unix,
- right.connect_secret_consumed_at_unix
- );
- assert_eq!(left.requested_permissions, right.requested_permissions);
- assert_eq!(left.granted_permissions, right.granted_permissions);
- assert_eq!(left.relays, right.relays);
- assert_eq!(left.approval_requirement, right.approval_requirement);
- assert_eq!(left.approval_state, right.approval_state);
- assert_eq!(left.auth_state, right.auth_state);
- assert_eq!(left.auth_challenge, right.auth_challenge);
- assert_eq!(left.pending_request, right.pending_request);
- assert_eq!(left.status, right.status);
- assert_eq!(left.status_reason, right.status_reason);
- assert_eq!(left.created_at_unix, right.created_at_unix);
- assert_eq!(left.updated_at_unix, right.updated_at_unix);
- assert_eq!(
- left.last_authenticated_at_unix,
- right.last_authenticated_at_unix
- );
- assert_eq!(left.last_request_at_unix, right.last_request_at_unix);
- }
-
- struct LoadErrorStore;
-
- impl RadrootsNostrSignerStore for LoadErrorStore {
- fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
- Err(RadrootsNostrSignerError::Store("store load failed".into()))
- }
-
- fn save(
- &self,
- _state: &RadrootsNostrSignerStoreState,
- ) -> Result<(), RadrootsNostrSignerError> {
- Ok(())
- }
- }
-
- struct SaveErrorStore {
- state: RwLock<RadrootsNostrSignerStoreState>,
- }
-
- impl SaveErrorStore {
- fn new(state: RadrootsNostrSignerStoreState) -> Self {
- Self {
- state: RwLock::new(state),
- }
- }
- }
-
- impl RadrootsNostrSignerStore for SaveErrorStore {
- fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
- self.state
- .read()
- .map(|guard| guard.clone())
- .map_err(|_| RadrootsNostrSignerError::Store("save error store poisoned".into()))
- }
-
- fn save(
- &self,
- _state: &RadrootsNostrSignerStoreState,
- ) -> Result<(), RadrootsNostrSignerError> {
- Err(RadrootsNostrSignerError::Store("store save failed".into()))
- }
- }
-
- #[test]
- fn auth_replay_audit_replacement_rejects_identity_mismatches() {
- let audit = |connection_id: &str, method: Method| {
- RadrootsNostrSignerRequestAuditRecord::new(
- RadrootsNostrSignerRequestId::parse("req-auth-replay").expect("request id"),
- RadrootsNostrSignerConnectionId::parse(connection_id).expect("connection id"),
- method,
- RadrootsNostrSignerRequestDecision::Allowed,
- None,
- 1,
- )
- };
- let mut state = RadrootsNostrSignerStoreState::default();
- replace_or_insert_auth_replay_audit(&mut state, audit("conn-auth-replay", Method::Ping))
- .expect("insert audit");
- replace_or_insert_auth_replay_audit(&mut state, audit("conn-auth-replay", Method::Ping))
- .expect("replace matching audit");
-
- for replacement in [
- audit("conn-other", Method::Ping),
- audit("conn-auth-replay", Method::Logout),
- ] {
- let error = replace_or_insert_auth_replay_audit(&mut state, replacement)
- .expect_err("reject mismatched audit");
- assert!(
- error
- .to_string()
- .contains("auth replay audit does not match the original request")
- );
- }
- }
-
- #[test]
- fn manager_new_in_memory_and_invalid_schema_paths() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- assert!(
- manager
- .signer_identity()
- .expect("signer identity")
- .is_none()
- );
-
- let load_error_store = Arc::new(LoadErrorStore);
- load_error_store
- .save(&RadrootsNostrSignerStoreState::default())
- .expect("load error store save");
- let load_result = RadrootsNostrSignerManager::new(load_error_store);
- assert!(load_result.is_err());
- let err = match load_result {
- Ok(_) => panic!("load error"),
- Err(err) => err,
- };
- assert!(err.to_string().contains("store load failed"));
-
- let store = Arc::new(RadrootsNostrMemorySignerStore::new());
- let state = RadrootsNostrSignerStoreState {
- version: 2,
- ..Default::default()
- };
- store.save(&state).expect("save");
- let version_result = RadrootsNostrSignerManager::new(store);
- assert!(version_result.is_err());
- let err = match version_result {
- Ok(_) => panic!("invalid version"),
- Err(err) => err,
- };
- assert!(
- err.to_string()
- .contains("unsupported signer schema version")
- );
- }
-
- #[test]
- fn set_signer_identity_persists_invariant_checked_value() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- let signer_identity = fixture_alice_identity();
- manager
- .set_signer_identity(signer_identity.clone())
- .expect("set signer");
-
- let loaded = manager
- .signer_identity()
- .expect("identity")
- .expect("loaded");
- assert_same_public_identity(&loaded, &signer_identity);
- }
-
- #[test]
- fn register_connection_requires_signer_identity_and_normalizes_inputs() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- let err = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x3),
- public_identity(0x4),
- ))
- .expect_err("missing signer");
- assert!(err.to_string().contains("missing signer identity"));
-
- manager
- .set_signer_identity(public_identity(0x5))
- .expect("set signer");
-
- let sign_event = permission(Method::SignEvent, Some("kind:1"));
- let ping = permission(Method::Ping, None);
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x6), public_identity(0x7))
- .with_connect_secret(" secret ")
- .with_requested_permissions(
- vec![sign_event.clone(), ping.clone(), sign_event.clone()].into(),
- )
- .with_relays(vec![primary_relay(), secondary_relay(), secondary_relay()]),
- )
- .expect("register");
-
- assert!(
- record
- .connect_secret_hash
- .as_ref()
- .expect("connect secret hash")
- .matches_secret("secret")
- );
- assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Active);
- assert_eq!(
- record.approval_state,
- RadrootsNostrSignerApprovalState::NotRequired
- );
- assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired);
- assert_eq!(record.requested_permissions.as_slice(), &[ping, sign_event]);
- assert_eq!(record.relays, vec![secondary_relay(), primary_relay()]);
- }
-
- #[test]
- fn register_connection_normalizes_display_only_client_metadata() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(fixture_alice_identity())
- .expect("set signer identity");
- let requested_permissions = vec![permission(Method::Ping, None)].into();
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x90), public_identity(0x91))
- .with_requested_permissions(requested_permissions)
- .with_client_metadata(ClientMetadata {
- requested_permissions: vec![permission(Method::Nip44Encrypt, None)].into(),
- name: Some(" Example Client ".into()),
- url: Some("https://client.example.com".into()),
- image: None,
- }),
- )
- .expect("register metadata connection");
-
- let metadata = record.client_metadata.expect("stored client metadata");
- assert_eq!(metadata.name.as_deref(), Some("Example Client"));
- assert_eq!(metadata.url.as_deref(), Some("https://client.example.com/"));
- assert!(metadata.requested_permissions.is_empty());
- assert_eq!(
- record.requested_permissions.as_slice(),
- &[permission(Method::Ping, None)]
- );
- }
-
- #[test]
- fn register_connection_enforces_identity_and_uniqueness_rules() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x8))
- .expect("set signer");
-
- let user_identity = public_identity(0x9);
- let client_public_key = public_key(0x10);
- let pending = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity.clone())
- .with_connect_secret("shared-secret")
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register");
- assert_eq!(pending.status, RadrootsNostrSignerConnectionStatus::Pending);
-
- let duplicate_connection = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity)
- .with_connect_secret("other-secret"),
- )
- .expect_err("duplicate connection");
- assert!(
- duplicate_connection
- .to_string()
- .contains("connection already exists")
- );
-
- let duplicate_secret = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x11), public_identity(0x12))
- .with_connect_secret("shared-secret"),
- )
- .expect_err("duplicate secret");
- assert!(
- duplicate_secret
- .to_string()
- .contains("connect secret already in use")
- );
- }
-
- #[test]
- fn manager_query_helpers_find_connections() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x15))
- .expect("set signer");
-
- let client_public_key = public_key(0x16);
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(client_public_key, public_identity(0x17))
- .with_connect_secret("lookup-secret"),
- )
- .expect("register");
-
- let by_id = manager
- .get_connection(&record.connection_id)
- .expect("get connection");
- let by_client = manager
- .find_connections_by_client_public_key(&client_public_key)
- .expect("find by client");
- let by_secret = manager
- .find_connection_by_connect_secret(" lookup-secret ")
- .expect("find by secret");
- let empty_secret = manager
- .find_connection_by_connect_secret(" ")
- .expect("empty secret");
- let all_connections = manager.list_connections().expect("list connections");
-
- assert_same_connection(&by_id.expect("by id"), &record);
- assert_eq!(by_client.len(), 1);
- assert_same_connection(&by_client[0], &record);
- assert_same_connection(&by_secret.expect("by secret"), &record);
- assert!(empty_secret.is_none());
- assert_eq!(all_connections.len(), 1);
- assert_same_connection(&all_connections[0], &record);
- }
-
- #[test]
- fn granted_permissions_and_approval_enforce_subset_rules() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x18))
- .expect("set signer");
- let requested = vec![
- permission(Method::SignEvent, Some("kind:1")),
- permission(Method::Ping, None),
- ];
- let granted = vec![requested[1].clone()];
- let invalid = vec![permission(Method::Nip44Encrypt, Some("kind:1"))];
- let pending = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x19), public_identity(0x20))
- .with_requested_permissions(requested.clone().into())
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register");
-
- let invalid_set = manager
- .set_granted_permissions(&pending.connection_id, invalid.clone().into())
- .expect_err("invalid set grants");
- assert!(
- invalid_set
- .to_string()
- .contains("invalid granted permission")
- );
-
- let set_grants = manager
- .set_granted_permissions(&pending.connection_id, granted.clone().into())
- .expect("set grants");
- assert_eq!(
- set_grants.granted_permissions().as_slice(),
- granted.as_slice()
- );
- assert_eq!(
- set_grants.status,
- RadrootsNostrSignerConnectionStatus::Pending
- );
-
- let approved = manager
- .approve_connection(&pending.connection_id, granted.clone().into())
- .expect("approve");
- assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active);
- assert_eq!(
- approved.approval_state,
- RadrootsNostrSignerApprovalState::Approved
- );
- assert_eq!(
- approved.granted_permissions().as_slice(),
- granted.as_slice()
- );
-
- let reapprove = manager
- .approve_connection(&pending.connection_id, granted.into())
- .expect("reapprove active");
- assert_eq!(
- reapprove.status,
- RadrootsNostrSignerConnectionStatus::Active
- );
-
- let auto = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x21),
- public_identity(0x22),
- ))
- .expect("register auto");
- let err = manager
- .approve_connection(&auto.connection_id, Permissions::default())
- .expect_err("approval not required");
- assert!(err.to_string().contains("approval not required"));
-
- let terminal_pending = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x40), public_identity(0x41))
- .with_connect_secret("terminal-secret")
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register terminal");
- manager
- .reject_connection(&terminal_pending.connection_id, Some("terminal".into()))
- .expect("reject terminal");
- let terminal_approve = manager
- .approve_connection(
- &terminal_pending.connection_id,
- vec![requested[0].clone()].into(),
- )
- .expect_err("approve rejected");
- assert!(
- terminal_approve
- .to_string()
- .contains("cannot approve rejected connection")
- );
-
- let unrestricted = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x23),
- public_identity(0x24),
- ))
- .expect("register unrestricted");
- let unrestricted_grants = manager
- .set_granted_permissions(&unrestricted.connection_id, invalid.into())
- .expect("unrestricted grants");
- assert_eq!(unrestricted_grants.granted_permissions.len(), 1);
- }
-
- #[test]
- fn reject_revoke_and_relay_updates_cover_terminal_paths() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x25))
- .expect("set signer");
- let rejected = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x26), public_identity(0x27))
- .with_connect_secret("shared-secret")
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register reject");
- let rejected = manager
- .reject_connection(&rejected.connection_id, Some("denied".into()))
- .expect("reject");
- assert_eq!(
- rejected.status,
- RadrootsNostrSignerConnectionStatus::Rejected
- );
- assert_eq!(rejected.status_reason.as_deref(), Some("denied"));
-
- let reject_err = manager
- .reject_connection(&rejected.connection_id, None)
- .expect_err("reject terminal");
- assert!(
- reject_err
- .to_string()
- .contains("cannot reject rejected connection")
- );
-
- let relay_err = manager
- .update_relays(&rejected.connection_id, vec![primary_relay()])
- .expect_err("update rejected");
- assert!(
- relay_err
- .to_string()
- .contains("cannot update relays for rejected connection")
- );
- let rejected_lookup = manager
- .find_connection_by_connect_secret("shared-secret")
- .expect("lookup rejected secret");
- assert!(rejected_lookup.is_none());
-
- let active = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x28),
- public_identity(0x29),
- ))
- .expect("register active");
- let active = manager
- .update_relays(
- &active.connection_id,
- vec![tertiary_relay(), secondary_relay(), secondary_relay()],
- )
- .expect("update relays");
- assert_eq!(active.relays, vec![secondary_relay(), tertiary_relay()]);
-
- let revoked = manager
- .revoke_connection(&active.connection_id, Some("manual".into()))
- .expect("revoke");
- assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked);
- assert_eq!(revoked.status_reason.as_deref(), Some("manual"));
-
- let revoke_again = manager
- .revoke_connection(&active.connection_id, None)
- .expect("revoke twice idempotently");
- assert_eq!(
- revoke_again.status,
- RadrootsNostrSignerConnectionStatus::Revoked
- );
- assert_eq!(revoke_again.status_reason.as_deref(), Some("manual"));
- assert_eq!(revoke_again.updated_at_unix, revoked.updated_at_unix);
-
- let grants_err = manager
- .set_granted_permissions(
- &active.connection_id,
- vec![permission(Method::Ping, None)].into(),
- )
- .expect_err("update grants revoked");
- assert!(
- grants_err
- .to_string()
- .contains("cannot update granted permissions for revoked connection")
- );
-
- let require_auth_err = manager
- .require_auth_challenge(&active.connection_id, api_primary_https())
- .expect_err("require auth revoked");
- assert!(
- require_auth_err
- .to_string()
- .contains("cannot require auth for revoked connection")
- );
-
- let pending_request_err = manager
- .set_pending_request(&active.connection_id, request_message("req-terminal"))
- .expect_err("pending request revoked");
- assert!(
- pending_request_err
- .to_string()
- .contains("cannot set pending request for revoked connection")
- );
-
- let authorize_auth_err = manager
- .authorize_auth_challenge(&active.connection_id)
- .expect_err("authorize auth revoked");
- assert!(
- authorize_auth_err
- .to_string()
- .contains("cannot authorize auth challenge for revoked connection")
- );
- }
-
- #[test]
- fn authentication_and_request_audit_paths_are_recorded() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x30))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x31),
- public_identity(0x32),
- ))
- .expect("register");
-
- let authenticated = manager
- .mark_authenticated(&record.connection_id)
- .expect("auth");
- assert!(authenticated.last_authenticated_at_unix.is_some());
-
- let consumed = manager
- .mark_connect_secret_consumed(&record.connection_id)
- .expect_err("consume missing secret");
- assert!(
- consumed
- .to_string()
- .contains("connection does not have a connect secret")
- );
-
- let audit = manager
- .record_request(
- &record.connection_id,
- " request-1 ",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Challenged,
- Some(" challenge ".into()),
- )
- .expect("record request");
- assert_eq!(audit.request_id.as_str(), "request-1");
- assert_eq!(audit.message.as_deref(), Some("challenge"));
-
- let blank_message_audit = manager
- .record_request(
- &record.connection_id,
- "request-2",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Denied,
- Some(" ".into()),
- )
- .expect("record blank message");
- assert!(blank_message_audit.message.is_none());
-
- let all_audits = manager.list_audit_records().expect("list audits");
- let connection_audits = manager
- .audit_records_for_connection(&record.connection_id)
- .expect("connection audits");
- let stored = manager
- .get_connection(&record.connection_id)
- .expect("get")
- .expect("stored");
- assert_eq!(all_audits, vec![audit.clone(), blank_message_audit.clone()]);
- assert_eq!(connection_audits, vec![audit, blank_message_audit]);
- assert!(stored.last_request_at_unix.is_some());
-
- let request_err = manager
- .record_request(
- &record.connection_id,
- " ",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Denied,
- None,
- )
- .expect_err("invalid request id");
- assert!(request_err.to_string().contains("invalid request id"));
- }
-
- #[test]
- fn auth_challenge_and_pending_request_state_are_persisted_and_replayed() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x34))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x35),
- public_identity(0x36),
- ))
- .expect("register");
-
- let required = manager
- .require_auth_challenge(
- &record.connection_id,
- format!(" {}/flow ", api_primary_https()).as_str(),
- )
- .expect("require auth");
- assert_eq!(required.auth_state, RadrootsNostrSignerAuthState::Pending);
- assert_eq!(
- required
- .auth_challenge
- .as_ref()
- .expect("auth challenge")
- .auth_url,
- format!("{}/flow", api_primary_https())
- );
- assert!(required.pending_request.is_none());
-
- let pending = manager
- .set_pending_request(&record.connection_id, request_message(" req-auth "))
- .expect("set pending request");
- assert_eq!(
- pending
- .pending_request
- .as_ref()
- .expect("pending request")
- .request_id()
- .as_str(),
- "req-auth"
- );
-
- let authorized = manager
- .authorize_auth_challenge(&record.connection_id)
- .expect("authorize");
- assert_eq!(
- authorized.connection.auth_state,
- RadrootsNostrSignerAuthState::Authorized
- );
- assert!(authorized.connection.last_authenticated_at_unix.is_some());
- assert!(authorized.connection.pending_request.is_none());
- assert_eq!(
- authorized
- .pending_request
- .as_ref()
- .expect("replayed request")
- .request_message()
- .id,
- "req-auth"
- );
- assert_eq!(
- authorized
- .connection
- .auth_challenge
- .as_ref()
- .expect("authorized challenge")
- .authorized_at_unix,
- authorized.connection.last_authenticated_at_unix
- );
-
- let invalid_url = manager
- .require_auth_challenge(&record.connection_id, "not-a-url")
- .expect_err("invalid auth url");
- assert!(invalid_url.to_string().contains("invalid auth url"));
-
- let no_pending_auth = manager
- .set_pending_request(&record.connection_id, request_message("req-again"))
- .expect_err("pending request without auth challenge");
- assert!(
- no_pending_auth
- .to_string()
- .contains("auth challenge not pending for connection")
- );
-
- let no_authorize = manager
- .authorize_auth_challenge(&record.connection_id)
- .expect_err("authorize without pending auth challenge");
- assert!(
- no_authorize
- .to_string()
- .contains("auth challenge not pending for connection")
- );
- }
-
- #[test]
- fn restored_authorized_auth_challenge_requeues_pending_request() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x134))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x135),
- public_identity(0x136),
- ))
- .expect("register");
-
- manager
- .require_auth_challenge(
- &record.connection_id,
- format!("{}/flow", api_primary_https()).as_str(),
- )
- .expect("require auth");
- manager
- .set_pending_request(&record.connection_id, request_message("req-replay"))
- .expect("set pending");
-
- let authorized = manager
- .authorize_auth_challenge(&record.connection_id)
- .expect("authorize");
- let pending_request = authorized.pending_request.expect("pending request");
-
- let restored = manager
- .restore_pending_auth_challenge(&record.connection_id, pending_request.clone())
- .expect("restore pending challenge");
- assert_eq!(restored.auth_state, RadrootsNostrSignerAuthState::Pending);
- assert_eq!(
- restored
- .auth_challenge
- .as_ref()
- .expect("challenge")
- .authorized_at_unix,
- None
- );
- assert!(restored.last_authenticated_at_unix.is_none());
- assert_eq!(
- restored
- .pending_request
- .as_ref()
- .expect("pending request")
- .request_id()
- .as_str(),
- pending_request.request_id().as_str()
- );
- }
-
- #[test]
- fn connect_secret_consumption_persists_and_remains_idempotent() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x37))
- .expect("set signer");
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x38), public_identity(0x39))
- .with_connect_secret("one-shot-secret"),
- )
- .expect("register");
-
- let consumed = manager
- .mark_connect_secret_consumed(&record.connection_id)
- .expect("consume secret");
- assert!(consumed.connect_secret_is_consumed());
- assert!(consumed.connect_secret_consumed_at_unix.is_some());
-
- let consumed_again = manager
- .mark_connect_secret_consumed(&record.connection_id)
- .expect("consume secret again");
- assert_eq!(
- consumed_again.connect_secret_consumed_at_unix,
- consumed.connect_secret_consumed_at_unix
- );
-
- let found = manager
- .find_connection_by_connect_secret("one-shot-secret")
- .expect("find consumed secret")
- .expect("stored secret");
- assert!(found.connect_secret_is_consumed());
- assert_eq!(
- found.connect_secret_consumed_at_unix,
- consumed.connect_secret_consumed_at_unix
- );
- }
-
- #[test]
- fn connect_secret_publish_workflow_is_persisted_and_finalized() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x237))
- .expect("set signer");
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x238), public_identity(0x239))
- .with_connect_secret("workflow-secret"),
- )
- .expect("register");
-
- let workflow = manager
- .begin_connect_secret_publish_finalization(&record.connection_id)
- .expect("begin workflow");
- assert_eq!(
- workflow.kind,
- RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
- );
- assert_eq!(
- workflow.state,
- RadrootsNostrSignerPublishWorkflowState::PendingPublish
- );
- assert!(workflow.pending_request.is_none());
- assert!(
- !manager
- .get_connection(&record.connection_id)
- .expect("get")
- .expect("stored")
- .connect_secret_is_consumed()
- );
- assert_eq!(
- manager.list_publish_workflows().expect("list workflows"),
- vec![workflow.clone()]
- );
-
- let published = manager
- .mark_publish_workflow_published(&workflow.workflow_id)
- .expect("mark published");
- assert_eq!(
- published.state,
- RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
- );
-
- let finalized = manager
- .finalize_publish_workflow(&workflow.workflow_id)
- .expect("finalize workflow");
- assert!(finalized.connect_secret_is_consumed());
- assert!(
- manager
- .list_publish_workflows()
- .expect("list workflows")
- .is_empty()
- );
- assert!(
- manager
- .find_connection_by_connect_secret("workflow-secret")
- .expect("find secret")
- .expect("stored")
- .connect_secret_is_consumed()
- );
- }
-
- #[test]
- fn auth_replay_publish_workflow_is_persisted_and_finalized() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x23a))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x23b),
- public_identity(0x23c),
- ))
- .expect("register");
-
- manager
- .require_auth_challenge(
- &record.connection_id,
- format!("{}/flow", api_primary_https()).as_str(),
- )
- .expect("require auth");
- let pending = manager
- .set_pending_request(&record.connection_id, request_message("req-auth-workflow"))
- .expect("set pending");
- let pending_request = pending.pending_request.expect("pending request");
-
- let workflow = manager
- .begin_auth_replay_publish_finalization(&record.connection_id)
- .expect("begin auth replay workflow");
- assert_eq!(
- workflow.kind,
- RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
- );
- assert_eq!(workflow.pending_request.as_ref(), Some(&pending_request));
- assert!(workflow.authorized_at_unix.is_some());
-
- let stored_before_publish = manager
- .get_connection(&record.connection_id)
- .expect("get")
- .expect("stored");
- assert_eq!(
- stored_before_publish.auth_state,
- RadrootsNostrSignerAuthState::Pending
- );
- assert_eq!(
- stored_before_publish.pending_request.as_ref(),
- Some(&pending_request)
- );
-
- manager
- .mark_publish_workflow_published(&workflow.workflow_id)
- .expect("mark published");
- let finalized = manager
- .finalize_publish_workflow(&workflow.workflow_id)
- .expect("finalize auth replay");
- assert_eq!(
- finalized.auth_state,
- RadrootsNostrSignerAuthState::Authorized
- );
- assert!(finalized.pending_request.is_none());
- assert_eq!(
- finalized
- .auth_challenge
- .as_ref()
- .expect("challenge")
- .authorized_at_unix,
- workflow.authorized_at_unix
- );
- assert_eq!(
- finalized.last_authenticated_at_unix,
- workflow.authorized_at_unix
- );
- assert!(
- manager
- .list_publish_workflows()
- .expect("list workflows")
- .is_empty()
- );
- }
-
- #[test]
- fn canceling_auth_replay_publish_workflow_preserves_pending_request() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x23d))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x23e),
- public_identity(0x23f),
- ))
- .expect("register");
-
- manager
- .require_auth_challenge(
- &record.connection_id,
- format!("{}/flow", api_primary_https()).as_str(),
- )
- .expect("require auth");
- let pending = manager
- .set_pending_request(&record.connection_id, request_message("req-auth-cancel"))
- .expect("set pending");
- let pending_request = pending.pending_request.expect("pending request");
-
- let workflow = manager
- .begin_auth_replay_publish_finalization(&record.connection_id)
- .expect("begin auth replay workflow");
- let canceled = manager
- .cancel_publish_workflow(&workflow.workflow_id)
- .expect("cancel workflow");
- assert_eq!(canceled.workflow_id, workflow.workflow_id);
-
- let stored = manager
- .get_connection(&record.connection_id)
- .expect("get")
- .expect("stored");
- assert_eq!(stored.auth_state, RadrootsNostrSignerAuthState::Pending);
- assert_eq!(stored.pending_request.as_ref(), Some(&pending_request));
- assert!(
- manager
- .list_publish_workflows()
- .expect("list workflows")
- .is_empty()
- );
- }
-
- #[test]
- fn evaluate_auth_replay_publish_workflow_uses_authorized_view_without_mutating_state() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x240))
- .expect("set signer");
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x241),
- public_identity(0x242),
- ))
- .expect("register");
-
- manager
- .set_granted_permissions(
- &record.connection_id,
- vec!["get_public_key".parse().expect("permission")].into(),
- )
- .expect("grant permissions");
- manager
- .require_auth_challenge(
- &record.connection_id,
- format!("{}/flow", api_primary_https()).as_str(),
- )
- .expect("require auth");
- let challenged = manager
- .evaluate_request(
- &record.connection_id,
- RequestMessage::new("req-auth-preview", Request::GetPublicKey),
- )
- .expect("evaluate challenged request");
- assert_eq!(
- challenged.audit.decision,
- RadrootsNostrSignerRequestDecision::Challenged
- );
- let pending_request = challenged
- .connection
- .pending_request
- .expect("pending request");
-
- let workflow = manager
- .begin_auth_replay_publish_finalization(&record.connection_id)
- .expect("begin auth replay workflow");
- let evaluation = manager
- .evaluate_auth_replay_publish_workflow(&workflow.workflow_id)
- .expect("evaluate auth replay workflow");
-
- assert_eq!(
- evaluation.request_id.as_str(),
- pending_request.request_id().as_str()
- );
- assert_eq!(
- evaluation.connection.auth_state,
- RadrootsNostrSignerAuthState::Authorized
- );
- assert!(evaluation.connection.pending_request.is_none());
- assert!(matches!(
- evaluation.action,
- RadrootsNostrSignerRequestAction::Allowed { .. }
- ));
-
- let stored = manager
- .get_connection(&record.connection_id)
- .expect("get")
- .expect("stored");
- assert_eq!(stored.auth_state, RadrootsNostrSignerAuthState::Pending);
- assert_eq!(stored.pending_request.as_ref(), Some(&pending_request));
- let audits = manager.list_audit_records().expect("list audits");
- assert_eq!(audits.len(), 1);
- assert_eq!(audits[0].request_id.as_str(), "req-auth-preview");
- assert_eq!(
- audits[0].decision,
- RadrootsNostrSignerRequestDecision::Allowed
- );
- }
-
- #[test]
- fn publish_workflow_duplicate_and_missing_paths_are_rejected() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x240))
- .expect("set signer");
- let record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x241), public_identity(0x242))
- .with_connect_secret("duplicate-secret"),
- )
- .expect("register");
-
- let workflow = manager
- .begin_connect_secret_publish_finalization(&record.connection_id)
- .expect("begin workflow");
- let duplicate = manager
- .begin_connect_secret_publish_finalization(&record.connection_id)
- .expect_err("duplicate workflow");
- assert!(
- duplicate
- .to_string()
- .contains("publish workflow already active")
- );
-
- let missing_workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-missing").expect("id");
- let missing_mark = manager
- .mark_publish_workflow_published(&missing_workflow_id)
- .expect_err("missing mark");
- let missing_finalize = manager
- .finalize_publish_workflow(&missing_workflow_id)
- .expect_err("missing finalize");
- let missing_cancel = manager
- .cancel_publish_workflow(&missing_workflow_id)
- .expect_err("missing cancel");
-
- for err in [missing_mark, missing_finalize, missing_cancel] {
- assert!(err.to_string().contains("publish workflow not found"));
- }
-
- let unpublished_finalize = manager
- .finalize_publish_workflow(&workflow.workflow_id)
- .expect_err("unpublished finalize");
- assert!(
- unpublished_finalize
- .to_string()
- .contains("publish workflow has not reached published state")
- );
- }
-
- #[test]
- fn publish_workflow_entrypoints_reject_invalid_connection_states() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x300))
- .expect("set signer");
- let missing_connection_id =
- RadrootsNostrSignerConnectionId::parse("conn-missing-publish").expect("connection id");
- let restore_pending_request =
- RadrootsNostrSignerPendingRequest::new(request_message("req-restore-invalid"), 61)
- .expect("pending request");
-
- let missing_restore_err = manager
- .restore_pending_auth_challenge(&missing_connection_id, restore_pending_request.clone())
- .expect_err("missing restore connection");
- assert!(
- missing_restore_err
- .to_string()
- .contains("connection not found")
- );
-
- let terminal_restore = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x301),
- public_identity(0x302),
- ))
- .expect("register terminal restore");
- manager
- .reject_connection(&terminal_restore.connection_id, Some("closed".into()))
- .expect("reject terminal restore");
- let terminal_restore_err = manager
- .restore_pending_auth_challenge(
- &terminal_restore.connection_id,
- restore_pending_request.clone(),
- )
- .expect_err("terminal restore error");
- assert!(
- terminal_restore_err
- .to_string()
- .contains("cannot restore auth challenge for rejected connection")
- );
-
- let unauthorized_restore = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x303),
- public_identity(0x304),
- ))
- .expect("register unauthorized restore");
- let unauthorized_restore_err = manager
- .restore_pending_auth_challenge(
- &unauthorized_restore.connection_id,
- restore_pending_request.clone(),
- )
- .expect_err("unauthorized restore error");
- assert!(
- unauthorized_restore_err
- .to_string()
- .contains("auth challenge not authorized for connection")
- );
-
- let missing_challenge_restore = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x305),
- public_identity(0x306),
- ))
- .expect("register missing challenge restore");
- manager
- .require_auth_challenge(
- &missing_challenge_restore.connection_id,
- format!("{}/restore", api_primary_https()).as_str(),
- )
- .expect("require auth");
- manager
- .set_pending_request(
- &missing_challenge_restore.connection_id,
- request_message("req-restore-missing-challenge"),
- )
- .expect("set pending");
- let replay = manager
- .authorize_auth_challenge(&missing_challenge_restore.connection_id)
- .expect("authorize")
- .pending_request
- .expect("pending request");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == missing_challenge_restore.connection_id)
- .expect("stored connection");
- record.auth_challenge = None;
- }
- let missing_challenge_restore_err = manager
- .restore_pending_auth_challenge(&missing_challenge_restore.connection_id, replay)
- .expect_err("missing challenge restore error");
- assert!(
- missing_challenge_restore_err
- .to_string()
- .contains("auth challenge missing for connection")
- );
-
- let terminal_connect = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x307), public_identity(0x308))
- .with_connect_secret("terminal-connect-secret"),
- )
- .expect("register terminal connect");
- manager
- .reject_connection(&terminal_connect.connection_id, Some("closed".into()))
- .expect("reject terminal connect");
- let terminal_connect_err = manager
- .begin_connect_secret_publish_finalization(&terminal_connect.connection_id)
- .expect_err("terminal connect workflow");
- assert!(
- terminal_connect_err
- .to_string()
- .contains("cannot begin connect secret finalization for rejected connection")
- );
-
- let no_secret_connect = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x309),
- public_identity(0x30a),
- ))
- .expect("register no secret connect");
- let no_secret_connect_err = manager
- .begin_connect_secret_publish_finalization(&no_secret_connect.connection_id)
- .expect_err("missing secret workflow");
- assert!(
- no_secret_connect_err
- .to_string()
- .contains("connection does not have a connect secret")
- );
-
- let consumed_connect = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x30b), public_identity(0x30c))
- .with_connect_secret("consumed-connect-secret"),
- )
- .expect("register consumed connect");
- manager
- .mark_connect_secret_consumed(&consumed_connect.connection_id)
- .expect("consume connect secret");
- let consumed_connect_err = manager
- .begin_connect_secret_publish_finalization(&consumed_connect.connection_id)
- .expect_err("consumed secret workflow");
- assert!(
- consumed_connect_err
- .to_string()
- .contains("connect secret already consumed for connection")
- );
-
- let missing_mark_consumed_err = manager
- .mark_connect_secret_consumed(&missing_connection_id)
- .expect_err("missing mark connect secret consumed");
- assert!(
- missing_mark_consumed_err
- .to_string()
- .contains("connection not found")
- );
-
- let terminal_auth = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x30d),
- public_identity(0x30e),
- ))
- .expect("register terminal auth");
- manager
- .reject_connection(&terminal_auth.connection_id, Some("closed".into()))
- .expect("reject terminal auth");
- let terminal_auth_err = manager
- .begin_auth_replay_publish_finalization(&terminal_auth.connection_id)
- .expect_err("terminal auth workflow");
- assert!(
- terminal_auth_err
- .to_string()
- .contains("cannot begin auth replay finalization for rejected connection")
- );
-
- let not_pending_auth = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x30f),
- public_identity(0x310),
- ))
- .expect("register not pending auth");
- let not_pending_auth_err = manager
- .begin_auth_replay_publish_finalization(¬_pending_auth.connection_id)
- .expect_err("not pending auth workflow");
- assert!(
- not_pending_auth_err
- .to_string()
- .contains("auth challenge not pending for connection")
- );
-
- let missing_challenge_auth = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x311),
- public_identity(0x312),
- ))
- .expect("register missing challenge auth");
- manager
- .require_auth_challenge(
- &missing_challenge_auth.connection_id,
- format!("{}/auth-missing-challenge", api_primary_https()).as_str(),
- )
- .expect("require auth");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == missing_challenge_auth.connection_id)
- .expect("stored connection");
- record.auth_challenge = None;
- }
- let missing_challenge_auth_err = manager
- .begin_auth_replay_publish_finalization(&missing_challenge_auth.connection_id)
- .expect_err("missing challenge auth workflow");
- assert!(
- missing_challenge_auth_err
- .to_string()
- .contains("auth challenge missing for connection")
- );
-
- let missing_pending_auth = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x313),
- public_identity(0x314),
- ))
- .expect("register missing pending auth");
- manager
- .require_auth_challenge(
- &missing_pending_auth.connection_id,
- format!("{}/auth-missing-pending", api_primary_https()).as_str(),
- )
- .expect("require auth");
- let missing_pending_auth_err = manager
- .begin_auth_replay_publish_finalization(&missing_pending_auth.connection_id)
- .expect_err("missing pending auth workflow");
- assert!(
- missing_pending_auth_err
- .to_string()
- .contains("pending request missing for auth replay finalization")
- );
-
- let duplicate_auth = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x315),
- public_identity(0x316),
- ))
- .expect("register duplicate auth");
- manager
- .require_auth_challenge(
- &duplicate_auth.connection_id,
- format!("{}/auth-duplicate", api_primary_https()).as_str(),
- )
- .expect("require auth");
- manager
- .set_pending_request(
- &duplicate_auth.connection_id,
- request_message("req-auth-duplicate"),
- )
- .expect("set pending");
- manager
- .begin_auth_replay_publish_finalization(&duplicate_auth.connection_id)
- .expect("begin auth workflow");
- let duplicate_auth_err = manager
- .begin_auth_replay_publish_finalization(&duplicate_auth.connection_id)
- .expect_err("duplicate auth workflow");
- assert!(
- duplicate_auth_err
- .to_string()
- .contains("publish workflow already active for auth_replay_finalization")
- );
- }
-
- #[test]
- fn publish_workflow_finalize_and_evaluate_reject_corrupted_states() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x320))
- .expect("set signer");
-
- let missing_workflow_id =
- RadrootsNostrSignerWorkflowId::parse("wf-evaluate-missing").expect("workflow id");
- let missing_evaluate_err = manager
- .evaluate_auth_replay_publish_workflow(&missing_workflow_id)
- .expect_err("missing workflow evaluate");
- assert!(
- missing_evaluate_err
- .to_string()
- .contains("publish workflow not found")
- );
-
- let connect_kind_record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x321), public_identity(0x322))
- .with_connect_secret("evaluate-connect-kind"),
- )
- .expect("register connect kind");
- let connect_kind_workflow = manager
- .begin_connect_secret_publish_finalization(&connect_kind_record.connection_id)
- .expect("begin connect workflow");
- let wrong_kind_err = manager
- .evaluate_auth_replay_publish_workflow(&connect_kind_workflow.workflow_id)
- .expect_err("wrong workflow kind");
- assert!(
- wrong_kind_err
- .to_string()
- .contains("publish workflow is not an auth replay finalization")
- );
-
- let connect_missing_secret_record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x323), public_identity(0x324))
- .with_connect_secret("missing-secret-finalize"),
- )
- .expect("register connect missing secret");
- let connect_missing_secret_workflow = manager
- .begin_connect_secret_publish_finalization(&connect_missing_secret_record.connection_id)
- .expect("begin connect missing secret workflow");
- manager
- .mark_publish_workflow_published(&connect_missing_secret_workflow.workflow_id)
- .expect("mark connect missing secret workflow");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == connect_missing_secret_record.connection_id)
- .expect("stored connection");
- record.connect_secret_hash = None;
- record.connect_secret_consumed_at_unix = None;
- }
- let connect_missing_secret_err = manager
- .finalize_publish_workflow(&connect_missing_secret_workflow.workflow_id)
- .expect_err("missing connect secret finalize");
- assert!(
- connect_missing_secret_err
- .to_string()
- .contains("connection does not have a connect secret")
- );
-
- let connect_consumed_record = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x325), public_identity(0x326))
- .with_connect_secret("consumed-secret-finalize"),
- )
- .expect("register connect consumed");
- let connect_consumed_workflow = manager
- .begin_connect_secret_publish_finalization(&connect_consumed_record.connection_id)
- .expect("begin connect consumed workflow");
- manager
- .mark_publish_workflow_published(&connect_consumed_workflow.workflow_id)
- .expect("mark connect consumed workflow");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == connect_consumed_record.connection_id)
- .expect("stored connection");
- record.connect_secret_consumed_at_unix = Some(88);
- }
- let connect_consumed_err = manager
- .finalize_publish_workflow(&connect_consumed_workflow.workflow_id)
- .expect_err("consumed connect secret finalize");
- assert!(
- connect_consumed_err
- .to_string()
- .contains("connect secret already consumed for connection")
- );
-
- let start_auth_replay_workflow = |suffix: u32,
- request_id: &str|
- -> (
- RadrootsNostrSignerConnectionRecord,
- RadrootsNostrSignerPublishWorkflowRecord,
- RadrootsNostrSignerPendingRequest,
- ) {
- let record = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x330 + suffix),
- public_identity(0x340 + suffix),
- ))
- .expect("register auth workflow");
- manager
- .require_auth_challenge(
- &record.connection_id,
- format!("{}/auth-workflow-{suffix}", api_primary_https()).as_str(),
- )
- .expect("require auth");
- let pending = manager
- .set_pending_request(&record.connection_id, request_message(request_id))
- .expect("set pending");
- let pending_request = pending.pending_request.expect("pending request");
- let workflow = manager
- .begin_auth_replay_publish_finalization(&record.connection_id)
- .expect("begin auth workflow");
- (record, workflow, pending_request)
- };
-
- let (missing_pending_record, missing_pending_workflow, _) =
- start_auth_replay_workflow(0, "req-eval-missing-pending");
- {
- let mut state = manager.state.write().expect("write");
- let workflow = state
- .publish_workflows
- .iter_mut()
- .find(|workflow| workflow.workflow_id == missing_pending_workflow.workflow_id)
- .expect("stored workflow");
- workflow.pending_request = None;
- }
- let missing_pending_eval_err = manager
- .evaluate_auth_replay_publish_workflow(&missing_pending_workflow.workflow_id)
- .expect_err("missing pending evaluate");
- assert!(
- missing_pending_eval_err
- .to_string()
- .contains("auth replay workflow missing pending request")
- );
- {
- let mut state = manager.state.write().expect("write");
- state
- .publish_workflows
- .retain(|workflow| workflow.workflow_id != missing_pending_workflow.workflow_id);
- state
- .connections
- .retain(|record| record.connection_id != missing_pending_record.connection_id);
- }
-
- let (missing_challenge_eval_record, missing_challenge_eval_workflow, pending_request) =
- start_auth_replay_workflow(1, "req-eval-no-challenge");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == missing_challenge_eval_record.connection_id)
- .expect("stored connection");
- record.auth_challenge = None;
- }
- let evaluation = manager
- .evaluate_auth_replay_publish_workflow(&missing_challenge_eval_workflow.workflow_id)
- .expect("evaluate without challenge");
- assert_eq!(
- evaluation.request_id.as_str(),
- pending_request.request_id().as_str()
- );
- assert_eq!(
- evaluation.connection.auth_state,
- RadrootsNostrSignerAuthState::Authorized
- );
- assert!(evaluation.connection.pending_request.is_none());
-
- let (terminal_eval_record, terminal_eval_workflow, _) =
- start_auth_replay_workflow(2, "req-eval-terminal");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == terminal_eval_record.connection_id)
- .expect("stored connection");
- record.status = RadrootsNostrSignerConnectionStatus::Rejected;
- }
- let terminal_eval_err = manager
- .evaluate_auth_replay_publish_workflow(&terminal_eval_workflow.workflow_id)
- .expect_err("terminal evaluate");
- assert!(
- terminal_eval_err
- .to_string()
- .contains("cannot evaluate auth replay workflow for rejected connection")
- );
-
- let (not_pending_eval_record, not_pending_eval_workflow, _) =
- start_auth_replay_workflow(3, "req-eval-not-pending");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == not_pending_eval_record.connection_id)
- .expect("stored connection");
- record.auth_state = RadrootsNostrSignerAuthState::Authorized;
- }
- let not_pending_eval_err = manager
- .evaluate_auth_replay_publish_workflow(¬_pending_eval_workflow.workflow_id)
- .expect_err("not pending evaluate");
- assert!(
- not_pending_eval_err
- .to_string()
- .contains("auth challenge not pending for connection")
- );
-
- let (mismatch_eval_record, mismatch_eval_workflow, _) =
- start_auth_replay_workflow(4, "req-eval-mismatch");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == mismatch_eval_record.connection_id)
- .expect("stored connection");
- record.pending_request = Some(
- RadrootsNostrSignerPendingRequest::new(
- request_message("req-eval-mismatch-other"),
- 77,
- )
- .expect("mismatched pending request"),
- );
- }
- let mismatch_eval_err = manager
- .evaluate_auth_replay_publish_workflow(&mismatch_eval_workflow.workflow_id)
- .expect_err("mismatch evaluate");
- assert!(
- mismatch_eval_err
- .to_string()
- .contains("pending request does not match auth replay workflow")
- );
-
- let start_published_auth_workflow = |suffix: u32, request_id: &str| {
- let (record, workflow, pending_request) =
- start_auth_replay_workflow(suffix, request_id);
- let published = manager
- .mark_publish_workflow_published(&workflow.workflow_id)
- .expect("mark published");
- (record, published, pending_request)
- };
-
- let (auth_not_pending_record, auth_not_pending_workflow, _) =
- start_published_auth_workflow(5, "req-finalize-not-pending");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == auth_not_pending_record.connection_id)
- .expect("stored connection");
- record.auth_state = RadrootsNostrSignerAuthState::Authorized;
- }
- let auth_not_pending_err = manager
- .finalize_publish_workflow(&auth_not_pending_workflow.workflow_id)
- .expect_err("not pending finalize");
- assert!(
- auth_not_pending_err
- .to_string()
- .contains("auth challenge not pending for connection")
- );
-
- let (missing_connection_finalize_record, missing_connection_finalize_workflow, _) =
- start_published_auth_workflow(11, "req-finalize-missing-connection");
- {
- let mut state = manager.state.write().expect("write");
- let workflow = state
- .publish_workflows
- .iter_mut()
- .find(|workflow| {
- workflow.workflow_id == missing_connection_finalize_workflow.workflow_id
- })
- .expect("stored workflow");
- workflow.connection_id =
- RadrootsNostrSignerConnectionId::parse("conn-finalize-missing")
- .expect("connection id");
- }
- let missing_connection_finalize_err = manager
- .finalize_publish_workflow(&missing_connection_finalize_workflow.workflow_id)
- .expect_err("missing connection finalize");
- assert!(
- missing_connection_finalize_err
- .to_string()
- .contains("connection not found")
- );
- {
- let mut state = manager.state.write().expect("write");
- state.publish_workflows.retain(|workflow| {
- workflow.workflow_id != missing_connection_finalize_workflow.workflow_id
- });
- state.connections.retain(|record| {
- record.connection_id != missing_connection_finalize_record.connection_id
- });
- }
-
- let (auth_missing_challenge_record, auth_missing_challenge_workflow, _) =
- start_published_auth_workflow(6, "req-finalize-missing-challenge");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == auth_missing_challenge_record.connection_id)
- .expect("stored connection");
- record.auth_challenge = None;
- }
- let auth_missing_challenge_err = manager
- .finalize_publish_workflow(&auth_missing_challenge_workflow.workflow_id)
- .expect_err("missing challenge finalize");
- assert!(
- auth_missing_challenge_err
- .to_string()
- .contains("auth challenge missing for connection")
- );
-
- let (workflow_missing_pending_record, workflow_missing_pending_workflow, _) =
- start_published_auth_workflow(7, "req-finalize-workflow-missing-pending");
- {
- let mut state = manager.state.write().expect("write");
- let workflow = state
- .publish_workflows
- .iter_mut()
- .find(|workflow| {
- workflow.workflow_id == workflow_missing_pending_workflow.workflow_id
- })
- .expect("stored workflow");
- workflow.pending_request = None;
- }
- let workflow_missing_pending_err = manager
- .finalize_publish_workflow(&workflow_missing_pending_workflow.workflow_id)
- .expect_err("workflow missing pending finalize");
- assert!(
- workflow_missing_pending_err
- .to_string()
- .contains("auth replay workflow missing pending request")
- );
- {
- let mut state = manager.state.write().expect("write");
- state.publish_workflows.retain(|workflow| {
- workflow.workflow_id != workflow_missing_pending_workflow.workflow_id
- });
- state.connections.retain(|record| {
- record.connection_id != workflow_missing_pending_record.connection_id
- });
- }
-
- let (mismatch_finalize_record, mismatch_finalize_workflow, _) =
- start_published_auth_workflow(8, "req-finalize-mismatch");
- {
- let mut state = manager.state.write().expect("write");
- let record = state
- .connections
- .iter_mut()
- .find(|record| record.connection_id == mismatch_finalize_record.connection_id)
- .expect("stored connection");
- record.pending_request = Some(
- RadrootsNostrSignerPendingRequest::new(
- request_message("req-finalize-mismatch-other"),
- 78,
- )
- .expect("mismatched pending request"),
- );
- }
- let mismatch_finalize_err = manager
- .finalize_publish_workflow(&mismatch_finalize_workflow.workflow_id)
- .expect_err("mismatch finalize");
- assert!(
- mismatch_finalize_err
- .to_string()
- .contains("pending request does not match auth replay workflow")
- );
-
- let (missing_authorized_record, missing_authorized_workflow, _) =
- start_published_auth_workflow(9, "req-finalize-missing-authorized");
- {
- let mut state = manager.state.write().expect("write");
- let workflow = state
- .publish_workflows
- .iter_mut()
- .find(|workflow| workflow.workflow_id == missing_authorized_workflow.workflow_id)
- .expect("stored workflow");
- workflow.authorized_at_unix = None;
- }
- let missing_authorized_err = manager
- .finalize_publish_workflow(&missing_authorized_workflow.workflow_id)
- .expect_err("missing authorized finalize");
- assert!(
- missing_authorized_err
- .to_string()
- .contains("auth replay workflow missing authorized timestamp")
- );
- {
- let mut state = manager.state.write().expect("write");
- state
- .publish_workflows
- .retain(|workflow| workflow.workflow_id != missing_authorized_workflow.workflow_id);
- state
- .connections
- .retain(|record| record.connection_id != missing_authorized_record.connection_id);
- }
-
- let (missing_connection_eval_record, missing_connection_eval_workflow, _) =
- start_auth_replay_workflow(12, "req-eval-missing-connection");
- {
- let mut state = manager.state.write().expect("write");
- let workflow = state
- .publish_workflows
- .iter_mut()
- .find(|workflow| {
- workflow.workflow_id == missing_connection_eval_workflow.workflow_id
- })
- .expect("stored workflow");
- workflow.connection_id =
- RadrootsNostrSignerConnectionId::parse("conn-evaluate-missing")
- .expect("connection id");
- }
- let missing_connection_eval_err = manager
- .evaluate_auth_replay_publish_workflow(&missing_connection_eval_workflow.workflow_id)
- .expect_err("missing connection evaluate");
- assert!(
- missing_connection_eval_err
- .to_string()
- .contains("connection not found")
- );
- {
- let mut state = manager.state.write().expect("write");
- state.publish_workflows.retain(|workflow| {
- workflow.workflow_id != missing_connection_eval_workflow.workflow_id
- });
- state.connections.retain(|record| {
- record.connection_id != missing_connection_eval_record.connection_id
- });
- }
- }
-
- #[test]
- fn manager_reports_missing_connections_and_save_failures() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id");
- let missing_get = manager.get_connection(&missing_id).expect("missing get");
- assert!(missing_get.is_none());
-
- let mark_err = manager
- .mark_authenticated(&missing_id)
- .expect_err("missing auth");
- assert!(mark_err.to_string().contains("connection not found"));
-
- let save_error_store =
- Arc::new(SaveErrorStore::new(RadrootsNostrSignerStoreState::default()));
- let loaded_state = save_error_store.load().expect("load save error store");
- assert_eq!(loaded_state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION);
- let manager = RadrootsNostrSignerManager::new(save_error_store).expect("manager");
- let err = manager
- .set_signer_identity(public_identity(0x33))
- .expect_err("save error");
- assert!(err.to_string().contains("store save failed"));
-
- let signer_identity = public_identity(0x243);
- let connection = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::parse("conn-save-error").expect("id"),
- signer_identity.clone(),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x244), public_identity(0x245))
- .with_connect_secret("save-error-secret"),
- 1,
- );
- let manager = RadrootsNostrSignerManager::new(Arc::new(SaveErrorStore::new(
- RadrootsNostrSignerStoreState {
- version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
- signer_identity: Some(signer_identity),
- connections: vec![connection.clone()],
- audit_records: Vec::new(),
- publish_workflows: Vec::new(),
- },
- )))
- .expect("manager with preloaded state");
- let workflow_err = manager
- .begin_connect_secret_publish_finalization(&connection.connection_id)
- .expect_err("workflow save error");
- assert!(workflow_err.to_string().contains("store save failed"));
- }
-
- #[test]
- fn mutation_methods_cover_remaining_error_paths() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x51))
- .expect("set signer");
-
- let missing_id = RadrootsNostrSignerConnectionId::parse("missing-2").expect("id");
- let missing_permissions: Permissions = vec![permission(Method::Ping, None)].into();
-
- let missing_grants = manager
- .set_granted_permissions(&missing_id, missing_permissions.clone())
- .expect_err("missing grants");
- let missing_approve = manager
- .approve_connection(&missing_id, Permissions::default())
- .expect_err("missing approve");
- let missing_reject = manager
- .reject_connection(&missing_id, None)
- .expect_err("missing reject");
- let missing_revoke = manager
- .revoke_connection(&missing_id, None)
- .expect_err("missing revoke");
- let missing_relays = manager
- .update_relays(&missing_id, vec![primary_relay()])
- .expect_err("missing relays");
- let missing_require_auth = manager
- .require_auth_challenge(&missing_id, api_primary_https())
- .expect_err("missing require auth");
- let missing_pending_request = manager
- .set_pending_request(&missing_id, request_message("req-missing-2"))
- .expect_err("missing pending request");
- let missing_begin_connect_workflow = manager
- .begin_connect_secret_publish_finalization(&missing_id)
- .expect_err("missing connect workflow");
- let missing_begin_auth_workflow = manager
- .begin_auth_replay_publish_finalization(&missing_id)
- .expect_err("missing auth workflow");
- let missing_authorize_auth = manager
- .authorize_auth_challenge(&missing_id)
- .expect_err("missing authorize auth");
- let missing_request = manager
- .record_request(
- &missing_id,
- "req-missing",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Denied,
- None,
- )
- .expect_err("missing request");
-
- for err in [
- missing_grants,
- missing_approve,
- missing_reject,
- missing_revoke,
- missing_relays,
- missing_require_auth,
- missing_pending_request,
- missing_begin_connect_workflow,
- missing_begin_auth_workflow,
- missing_authorize_auth,
- missing_request,
- ] {
- assert!(err.to_string().contains("connection not found"));
- }
-
- let requested = vec![permission(Method::Ping, None)];
- let pending = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x52), public_identity(0x53))
- .with_requested_permissions(requested.into())
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register pending");
- let invalid_approve = manager
- .approve_connection(
- &pending.connection_id,
- vec![permission(Method::Nip44Encrypt, Some("kind:1"))].into(),
- )
- .expect_err("invalid approve grants");
- assert!(
- invalid_approve
- .to_string()
- .contains("invalid granted permission")
- );
-
- let auth_required = manager
- .require_auth_challenge(&pending.connection_id, api_primary_https())
- .expect("require auth");
- assert_eq!(
- auth_required.auth_state,
- RadrootsNostrSignerAuthState::Pending
- );
-
- let invalid_pending_request = manager
- .set_pending_request(&pending.connection_id, request_message(" "))
- .expect_err("invalid pending request id");
- assert!(
- invalid_pending_request
- .to_string()
- .contains("invalid request id")
- );
-
- let update_state_err = manager
- .update_state(|_| Err(RadrootsNostrSignerError::InvalidState("manual".into())))
- .expect_err("update_state error");
- assert!(update_state_err.to_string().contains("manual"));
- }
-
- #[test]
- fn manager_reports_poisoned_state_lock() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- poison_manager_state(&manager);
-
- let identity = manager.signer_identity().expect_err("poisoned read");
- assert!(identity.to_string().contains("signer state lock poisoned"));
- }
-
- #[test]
- fn read_helpers_report_poisoned_state_lock() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- poison_manager_state(&manager);
-
- let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id");
- let client_public_key = public_key(0x47);
-
- let get_err = manager
- .get_connection(&connection_id)
- .expect_err("poisoned get");
- let list_err = manager.list_connections().expect_err("poisoned list");
- let audit_list_err = manager
- .list_audit_records()
- .expect_err("poisoned audit list");
- let audit_for_connection_err = manager
- .audit_records_for_connection(&connection_id)
- .expect_err("poisoned audit connection");
- let workflow_list_err = manager
- .list_publish_workflows()
- .expect_err("poisoned workflow list");
- let workflow_get_err = manager
- .get_publish_workflow(&RadrootsNostrSignerWorkflowId::parse("wf-poison").expect("id"))
- .expect_err("poisoned workflow get");
- let find_secret_err = manager
- .find_connection_by_connect_secret("secret")
- .expect_err("poisoned secret lookup");
- let find_client_err = manager
- .find_connections_by_client_public_key(&client_public_key)
- .expect_err("poisoned client lookup");
- let lookup_secret_err = manager
- .lookup_session(&client_public_key, Some("secret"))
- .expect_err("poisoned session secret lookup");
- let lookup_client_err = manager
- .lookup_session(&client_public_key, None)
- .expect_err("poisoned session client lookup");
-
- for err in [
- get_err,
- list_err,
- audit_list_err,
- audit_for_connection_err,
- workflow_list_err,
- workflow_get_err,
- find_secret_err,
- find_client_err,
- lookup_secret_err,
- lookup_client_err,
- ] {
- assert!(err.to_string().contains("signer state lock poisoned"));
- }
- }
-
- #[test]
- fn evaluate_connect_request_reports_poisoned_state_lock() {
- let store = Arc::new(RadrootsNostrMemorySignerStore::new());
- let signer_identity = public_identity(0x57);
- let state = RadrootsNostrSignerStoreState {
- signer_identity: Some(signer_identity.clone()),
- ..Default::default()
- };
- store.save(&state).expect("save state");
-
- let manager = RadrootsNostrSignerManager::new(store).expect("manager");
- poison_manager_state(&manager);
-
- let err = manager
- .evaluate_connect_request(
- public_key(0x58),
- Request::Connect {
- remote_signer_public_key: signer_identity.public_key(),
- secret: Some("secret".into()),
- requested_permissions: Permissions::default(),
- client_metadata: None,
- },
- )
- .expect_err("poisoned connect evaluation");
- assert!(err.to_string().contains("signer state lock poisoned"));
- }
-
- #[test]
- fn mutation_helpers_report_poisoned_state_lock() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- poison_manager_state(&manager);
-
- let signer_identity = public_identity(0x48);
- let connection_id = RadrootsNostrSignerConnectionId::parse("conn-2").expect("id");
- let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-2").expect("id");
- let connect_draft =
- RadrootsNostrSignerConnectionDraft::new(public_key(0x49), public_identity(0x50));
-
- let set_signer_err = manager
- .set_signer_identity(signer_identity)
- .expect_err("poisoned set signer");
- let register_err = manager
- .register_connection(connect_draft)
- .expect_err("poisoned register");
- let grants_err = manager
- .set_granted_permissions(&connection_id, vec![permission(Method::Ping, None)].into())
- .expect_err("poisoned set grants");
- let approve_err = manager
- .approve_connection(&connection_id, Permissions::default())
- .expect_err("poisoned approve");
- let reject_err = manager
- .reject_connection(&connection_id, Some("reason".into()))
- .expect_err("poisoned reject");
- let revoke_err = manager
- .revoke_connection(&connection_id, Some("reason".into()))
- .expect_err("poisoned revoke");
- let update_relays_err = manager
- .update_relays(&connection_id, vec![primary_relay()])
- .expect_err("poisoned relays");
- let require_auth_err = manager
- .require_auth_challenge(&connection_id, api_primary_https())
- .expect_err("poisoned require auth");
- let set_pending_request_err = manager
- .set_pending_request(&connection_id, request_message("req-2"))
- .expect_err("poisoned set pending request");
- let authorize_auth_err = manager
- .authorize_auth_challenge(&connection_id)
- .expect_err("poisoned authorize auth");
- let begin_connect_workflow_err = manager
- .begin_connect_secret_publish_finalization(&connection_id)
- .expect_err("poisoned connect workflow");
- let begin_auth_workflow_err = manager
- .begin_auth_replay_publish_finalization(&connection_id)
- .expect_err("poisoned auth workflow");
- let mark_workflow_err = manager
- .mark_publish_workflow_published(&workflow_id)
- .expect_err("poisoned mark workflow");
- let finalize_workflow_err = manager
- .finalize_publish_workflow(&workflow_id)
- .expect_err("poisoned finalize workflow");
- let cancel_workflow_err = manager
- .cancel_publish_workflow(&workflow_id)
- .expect_err("poisoned cancel workflow");
- let auth_err = manager
- .mark_authenticated(&connection_id)
- .expect_err("poisoned auth");
- let request_err = manager
- .record_request(
- &connection_id,
- "req-1",
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Allowed,
- None,
- )
- .expect_err("poisoned request");
-
- for err in [
- set_signer_err,
- register_err,
- grants_err,
- approve_err,
- reject_err,
- revoke_err,
- update_relays_err,
- require_auth_err,
- set_pending_request_err,
- authorize_auth_err,
- begin_connect_workflow_err,
- begin_auth_workflow_err,
- mark_workflow_err,
- finalize_workflow_err,
- cancel_workflow_err,
- auth_err,
- request_err,
- ] {
- assert!(err.to_string().contains("signer state lock poisoned"));
- }
- }
-
- #[test]
- fn save_error_store_reports_poisoned_load_lock() {
- let store = SaveErrorStore::new(RadrootsNostrSignerStoreState::default());
- let shared = Arc::new(store);
- let poison = shared.clone();
- let _ = thread::spawn(move || {
- let _guard = poison.state.write().expect("write");
- panic!("poison save error store");
- })
- .join();
-
- let err = shared.load().expect_err("poisoned load");
- assert!(err.to_string().contains("save error store poisoned"));
- }
-
- #[test]
- fn helpers_cover_status_labels_and_consumed_secret_reuse_rules() {
- assert_eq!(
- status_label(RadrootsNostrSignerConnectionStatus::Pending),
- "pending"
- );
- assert_eq!(
- status_label(RadrootsNostrSignerConnectionStatus::Active),
- "active"
- );
- assert_eq!(
- status_label(RadrootsNostrSignerConnectionStatus::Rejected),
- "rejected"
- );
- assert_eq!(
- status_label(RadrootsNostrSignerConnectionStatus::Revoked),
- "revoked"
- );
-
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x42))
- .expect("set signer");
-
- let initial = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x43), public_identity(0x44))
- .with_connect_secret("reusable-secret")
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register initial");
- manager
- .reject_connection(&initial.connection_id, Some("closed".into()))
- .expect("reject initial");
-
- let reused = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x45), public_identity(0x46))
- .with_connect_secret("reusable-secret"),
- )
- .expect("register reused secret");
-
- assert!(
- reused
- .connect_secret_hash
- .as_ref()
- .expect("connect secret hash")
- .matches_secret("reusable-secret")
- );
-
- let consumed = manager
- .mark_connect_secret_consumed(&reused.connection_id)
- .expect("consume secret");
- assert!(consumed.connect_secret_is_consumed());
- manager
- .reject_connection(&reused.connection_id, Some("closed".into()))
- .expect("reject consumed");
-
- let blocked_reuse = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x47), public_identity(0x48))
- .with_connect_secret("reusable-secret"),
- )
- .expect_err("block consumed secret reuse");
- assert!(matches!(
- blocked_reuse,
- RadrootsNostrSignerError::ConnectSecretAlreadyInUse
- ));
- }
-
- #[test]
- fn session_lookup_and_connect_evaluation_cover_new_paths() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- let signer_identity = public_identity(0x60);
- let signer_public_key =
- PublicKey::from_hex(&signer_identity.public_key().to_hex()).expect("signer public key");
- manager
- .set_signer_identity(signer_identity)
- .expect("set signer");
-
- let client_public_key = public_key(0x61);
- let primary = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(client_public_key, public_identity(0x62))
- .with_connect_secret("connect-secret"),
- )
- .expect("register primary");
-
- let single_lookup = manager
- .lookup_session(&client_public_key, None)
- .expect("lookup single");
- assert_same_connection(&expect_connection_lookup(single_lookup), &primary);
-
- let secret_lookup = manager
- .lookup_session(&client_public_key, Some("connect-secret"))
- .expect("lookup by secret");
- assert_same_connection(&expect_connection_lookup(secret_lookup), &primary);
- let missing_secret_lookup = manager
- .lookup_session(&client_public_key, Some("missing-secret"))
- .expect("lookup missing secret");
- assert_same_connection(&expect_connection_lookup(missing_secret_lookup), &primary);
-
- let second = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(client_public_key, public_identity(0x63))
- .with_connect_secret("second-secret"),
- )
- .expect("register second");
-
- let ambiguous_by_missing_secret = manager
- .lookup_session(&client_public_key, Some("missing-secret"))
- .expect("lookup missing secret after second");
- let found = expect_ambiguous_lookup(ambiguous_by_missing_secret);
- assert_eq!(found.len(), 2);
- assert_same_connection(&found[0], &primary);
- assert_same_connection(&found[1], &second);
- let ambiguous_lookup = manager
- .lookup_session(&client_public_key, None)
- .expect("lookup ambiguous");
- let found = expect_ambiguous_lookup(ambiguous_lookup);
- assert_eq!(found.len(), 2);
- assert_same_connection(&found[0], &primary);
- assert_same_connection(&found[1], &second);
-
- let mismatch_secret = manager
- .lookup_session(&public_key(0x64), Some("connect-secret"))
- .expect_err("secret mismatch");
- assert!(
- mismatch_secret
- .to_string()
- .contains("different client public key")
- );
-
- let none_lookup = manager
- .lookup_session(&public_key(0x65), None)
- .expect("lookup none");
- expect_none_lookup(none_lookup);
-
- let non_connect_err = manager
- .evaluate_connect_request(client_public_key, Request::Ping)
- .expect_err("non-connect evaluation");
- assert!(
- non_connect_err
- .to_string()
- .contains("connect evaluation requires a connect request")
- );
-
- let missing_signer_err = RadrootsNostrSignerManager::new_in_memory()
- .evaluate_connect_request(
- client_public_key,
- Request::Connect {
- remote_signer_public_key: connect_public_key(signer_public_key),
- secret: None,
- requested_permissions: Permissions::default(),
- client_metadata: None,
- },
- )
- .expect_err("missing signer");
- assert_eq!(missing_signer_err.to_string(), "missing signer identity");
-
- let signer_mismatch_err = manager
- .evaluate_connect_request(
- client_public_key,
- Request::Connect {
- remote_signer_public_key: connect_public_key(public_key(0x66)),
- secret: None,
- requested_permissions: Permissions::default(),
- client_metadata: None,
- },
- )
- .expect_err("signer mismatch");
- assert!(
- signer_mismatch_err
- .to_string()
- .contains("remote signer public key mismatch")
- );
-
- let existing_connect = manager
- .evaluate_connect_request(
- client_public_key,
- Request::Connect {
- remote_signer_public_key: connect_public_key(signer_public_key),
- secret: Some(" connect-secret ".into()),
- requested_permissions: vec![
- permission(Method::Ping, None),
- permission(Method::Ping, None),
- ]
- .into(),
- client_metadata: None,
- },
- )
- .expect("existing connect request");
- assert_same_connection(&expect_existing_connect(existing_connect), &primary);
-
- let registration_connect = manager
- .evaluate_connect_request(
- public_key(0x67),
- Request::Connect {
- remote_signer_public_key: connect_public_key(signer_public_key),
- secret: Some(" fresh-secret ".into()),
- requested_permissions: vec![
- permission(Method::Ping, None),
- permission(Method::SignEvent, Some("kind:1")),
- permission(Method::Ping, None),
- ]
- .into(),
- client_metadata: Some(ClientMetadata {
- requested_permissions: vec![permission(Method::Nip44Encrypt, None)].into(),
- name: Some(" Example Client ".into()),
- url: Some("https://client.example.com".into()),
- image: None,
- }),
- },
- )
- .expect("registration connect request");
- let proposal = expect_registration_connect(registration_connect);
- assert_eq!(proposal.client_public_key, public_key(0x67));
- assert_eq!(proposal.connect_secret.as_deref(), Some("fresh-secret"));
- let metadata = proposal.client_metadata.as_ref().expect("client metadata");
- assert_eq!(metadata.name.as_deref(), Some("Example Client"));
- assert_eq!(metadata.url.as_deref(), Some("https://client.example.com/"));
- assert!(metadata.requested_permissions.is_empty());
- assert_eq!(
- proposal.requested_permissions.as_slice(),
- &[
- permission(Method::Ping, None),
- permission(Method::SignEvent, Some("kind:1")),
- ]
- );
-
- let existing_secret_mismatch = manager
- .evaluate_connect_request(
- public_key(0x68),
- Request::Connect {
- remote_signer_public_key: connect_public_key(signer_public_key),
- secret: Some("connect-secret".into()),
- requested_permissions: Permissions::default(),
- client_metadata: None,
- },
- )
- .expect_err("existing secret mismatch");
- assert!(
- existing_secret_mismatch
- .to_string()
- .contains("different client public key")
- );
- }
-
- #[test]
- fn evaluate_request_covers_allowed_denied_and_challenged_paths() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x71))
- .expect("set signer");
-
- let active = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x72), public_identity(0x73))
- .with_requested_permissions(
- vec![permission(Method::SignEvent, Some("kind:1"))].into(),
- ),
- )
- .expect("register active");
-
- let get_public_key = manager
- .evaluate_request(
- &active.connection_id,
- request_message_with_request("req-get", Request::GetPublicKey),
- )
- .expect("evaluate get_public_key");
- expect_allowed_user_public_key(&get_public_key.action);
- assert_eq!(
- get_public_key.audit.decision,
- RadrootsNostrSignerRequestDecision::Allowed
- );
- assert!(get_public_key.denied_reason().is_none());
-
- let allowed_sign = manager
- .evaluate_request(
- &active.connection_id,
- request_message_with_request("req-sign-1", Request::SignEvent(unsigned_event(1))),
- )
- .expect("evaluate sign allowed");
- expect_allowed_without_response_hint(&allowed_sign.action);
-
- let denied_sign = manager
- .evaluate_request(
- &active.connection_id,
- request_message_with_request("req-sign-2", Request::SignEvent(unsigned_event(2))),
- )
- .expect("evaluate sign denied");
- assert_eq!(denied_sign.denied_reason(), Some("unauthorized sign_event"));
- assert_eq!(
- denied_sign.audit.decision,
- RadrootsNostrSignerRequestDecision::Denied
- );
-
- let pending = manager
- .register_connection(
- RadrootsNostrSignerConnectionDraft::new(public_key(0x74), public_identity(0x75))
- .with_approval_requirement(
- RadrootsNostrSignerApprovalRequirement::ExplicitUser,
- ),
- )
- .expect("register pending");
- let pending_eval = manager
- .evaluate_request(&pending.connection_id, request_message("req-pending"))
- .expect("evaluate pending");
- assert_eq!(pending_eval.denied_reason(), Some("connection is pending"));
-
- let challenged = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x76),
- public_identity(0x77),
- ))
- .expect("register challenged");
- manager
- .require_auth_challenge(&challenged.connection_id, api_primary_https())
- .expect("require auth challenge");
- let challenged_eval = manager
- .evaluate_request(&challenged.connection_id, request_message("req-auth"))
- .expect("evaluate challenged");
- expect_challenged_action(&challenged_eval.action);
- assert_eq!(
- challenged_eval.audit.decision,
- RadrootsNostrSignerRequestDecision::Challenged
- );
- assert_eq!(
- challenged_eval
- .connection
- .pending_request
- .as_ref()
- .expect("pending request")
- .request_id()
- .as_str(),
- "req-auth"
- );
-
- let rejected = manager
- .reject_connection(&challenged.connection_id, Some("closed".into()))
- .expect("reject challenged");
- let rejected_eval = manager
- .evaluate_request(&rejected.connection_id, request_message("req-rejected"))
- .expect("evaluate rejected");
- assert_eq!(
- rejected_eval.denied_reason(),
- Some("connection is rejected")
- );
-
- let connect_eval_err = manager
- .evaluate_request(
- &active.connection_id,
- request_message_with_request(
- "req-connect",
- Request::Connect {
- remote_signer_public_key: connect_public_key(active.client_public_key),
- secret: None,
- requested_permissions: Permissions::default(),
- client_metadata: None,
- },
- ),
- )
- .expect_err("connect through evaluate_request");
- assert!(
- connect_eval_err
- .to_string()
- .contains("evaluate_connect_request")
- );
- }
-
- #[test]
- fn evaluate_request_reports_invalid_corrupted_auth_state() {
- let store = Arc::new(RadrootsNostrMemorySignerStore::new());
- let signer_identity = public_identity(0x78);
- let mut state = RadrootsNostrSignerStoreState {
- signer_identity: Some(signer_identity.clone()),
- ..Default::default()
- };
- let mut record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- signer_identity,
- RadrootsNostrSignerConnectionDraft::new(public_key(0x79), public_identity(0x80)),
- 1,
- );
- record.auth_state = RadrootsNostrSignerAuthState::Pending;
- record.auth_challenge = None;
- state.connections.push(record.clone());
- store.save(&state).expect("save corrupted auth state");
-
- let manager = RadrootsNostrSignerManager::new(store).expect("manager");
- let err = manager
- .evaluate_request(&record.connection_id, request_message("req-corrupt"))
- .expect_err("corrupted auth evaluation");
- assert!(err.to_string().contains("auth challenge missing"));
- }
-
- #[test]
- fn evaluate_request_reports_invalid_request_id_and_missing_connection() {
- let manager = RadrootsNostrSignerManager::new_in_memory();
- manager
- .set_signer_identity(public_identity(0x81))
- .expect("set signer");
-
- let active = manager
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- public_key(0x82),
- public_identity(0x83),
- ))
- .expect("register active");
-
- let invalid_request_id = manager
- .evaluate_request(
- &active.connection_id,
- request_message_with_request(" ", Request::Ping),
- )
- .expect_err("invalid request id");
- assert!(
- invalid_request_id
- .to_string()
- .contains("invalid request id")
- );
-
- let missing_connection = manager
- .evaluate_request(
- &RadrootsNostrSignerConnectionId::new_v7(),
- request_message("req-missing"),
- )
- .expect_err("missing connection");
- assert!(
- missing_connection
- .to_string()
- .contains("connection not found")
- );
- }
-
- #[test]
- fn evaluate_request_action_reports_pending_request_and_response_hint_errors() {
- let mut pending_record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- public_identity(0x84),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x85), public_identity(0x86)),
- 1,
- );
- pending_record.status = RadrootsNostrSignerConnectionStatus::Active;
- pending_record.auth_state = RadrootsNostrSignerAuthState::Pending;
- pending_record.auth_challenge =
- Some(RadrootsNostrSignerAuthChallenge::new(api_primary_https(), 1).expect("challenge"));
- let invalid_pending = evaluate_request_action(
- &mut pending_record,
- &request_message_with_request(" ", Request::Ping),
- 1,
- )
- .expect_err("invalid pending request");
- assert!(invalid_pending.to_string().contains("invalid request id"));
- }
-}
diff --git a/src/signer/mod.rs b/src/signer/mod.rs
@@ -1,65 +0,0 @@
-//! Myc-owned signer-service state, policy, persistence, and NIP-46 execution.
-//!
-//! Generic signing requests and receipts come from `radroots_signing`, while
-//! protocol parsing comes from `radroots_nostr_connect`. Approval, session,
-//! persistence, and service execution remain local to this host.
-
-pub mod backend;
-pub mod capability;
-pub mod error;
-pub mod evaluation;
-pub mod manager;
-pub mod migrations;
-pub mod model;
-pub mod nip46;
-pub mod sqlite;
-pub mod store;
-
-#[cfg(test)]
-mod test_fixtures;
-#[cfg(test)]
-mod test_support;
-
-pub mod prelude {
- pub use super::backend::{
- RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend,
- RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition,
- RadrootsNostrSignerSignOutput,
- };
- pub use super::capability::{
- RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
- RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerCapability,
- };
- pub use super::error::RadrootsNostrSignerError;
- pub use super::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal,
- RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup,
- };
- pub use super::manager::RadrootsNostrSignerManager;
- pub use super::model::{
- RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement,
- RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge,
- RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome,
- RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
- RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
- RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
- RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
- RadrootsNostrSignerRequestId, RadrootsNostrSignerSecretDigestAlgorithm,
- RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
- };
- pub use super::nip46::{
- RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
- RadrootsNostrSignerNip46Codec, RadrootsNostrSignerNip46ConnectDecision,
- RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Policy,
- RadrootsNostrSignerNip46Signer, connect_response_outcome, handled_request_for_action,
- response_from_hint,
- };
- pub use super::sqlite::RadrootsNostrSignerSqliteDb;
- pub use super::store::{
- RadrootsNostrFileSignerStore, RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore,
- RadrootsNostrSqliteSignerStore,
- };
-}
diff --git a/src/signer/model.rs b/src/signer/model.rs
@@ -1,1594 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::error::RadrootsNostrSignerError;
-use hex::encode as hex_encode;
-use nostr::{PublicKey, RelayUrl};
-use radroots_nostr_connect::{
- Method, Permission, message::RequestMessage, permission::Permissions, uri::ClientMetadata,
-};
-use serde::{Deserialize, Deserializer, Serialize};
-use sha2::{Digest, Sha256};
-use std::fmt;
-use std::str::FromStr;
-use url::Url;
-use uuid::Uuid;
-
-pub const RADROOTS_NOSTR_SIGNER_STORE_VERSION: u32 = 1;
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerConnectionId(String);
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerRequestId(String);
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerWorkflowId(String);
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerApprovalRequirement {
- NotRequired,
- ExplicitUser,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerApprovalState {
- NotRequired,
- Pending,
- Approved,
- Rejected,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerConnectionStatus {
- Pending,
- Active,
- Rejected,
- Revoked,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum RadrootsNostrSignerPublishWorkflowKind {
- ConnectSecretFinalization,
- AuthReplayFinalization,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum RadrootsNostrSignerPublishWorkflowState {
- PendingPublish,
- PublishedPendingFinalize,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerRequestDecision {
- Allowed,
- Denied,
- Challenged,
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-pub enum RadrootsNostrSignerAuthState {
- #[default]
- NotRequired,
- Pending,
- Authorized,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum RadrootsNostrSignerSecretDigestAlgorithm {
- Sha256,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerConnectSecretHash {
- pub algorithm: RadrootsNostrSignerSecretDigestAlgorithm,
- pub digest_hex: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
-pub struct RadrootsNostrSignerAuthChallenge {
- pub auth_url: String,
- pub required_at_unix: u64,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub authorized_at_unix: Option<u64>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerPendingRequest {
- pub request_message: RequestMessage,
- pub created_at_unix: u64,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsNostrSignerAuthorizationOutcome {
- pub connection: RadrootsNostrSignerConnectionRecord,
- pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerPermissionGrant {
- #[serde(
- serialize_with = "serialize_permission",
- deserialize_with = "deserialize_permission"
- )]
- pub permission: Permission,
- pub granted_at_unix: u64,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsNostrSignerConnectionDraft {
- pub client_public_key: PublicKey,
- pub user_identity: PublicIdentity,
- pub connect_secret: Option<String>,
- pub client_metadata: Option<ClientMetadata>,
- pub requested_permissions: Permissions,
- pub relays: Vec<RelayUrl>,
- pub approval_requirement: RadrootsNostrSignerApprovalRequirement,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerConnectionRecord {
- pub connection_id: RadrootsNostrSignerConnectionId,
- pub client_public_key: PublicKey,
- pub signer_identity: PublicIdentity,
- pub user_identity: PublicIdentity,
- #[serde(
- default,
- alias = "connect_secret",
- deserialize_with = "deserialize_connect_secret_hash_option",
- skip_serializing_if = "Option::is_none"
- )]
- pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub connect_secret_consumed_at_unix: Option<u64>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub client_metadata: Option<ClientMetadata>,
- pub requested_permissions: Permissions,
- #[serde(default)]
- pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>,
- #[serde(default)]
- pub relays: Vec<RelayUrl>,
- pub approval_requirement: RadrootsNostrSignerApprovalRequirement,
- pub approval_state: RadrootsNostrSignerApprovalState,
- #[serde(default)]
- pub auth_state: RadrootsNostrSignerAuthState,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub auth_challenge: Option<RadrootsNostrSignerAuthChallenge>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
- pub status: RadrootsNostrSignerConnectionStatus,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub status_reason: Option<String>,
- pub created_at_unix: u64,
- pub updated_at_unix: u64,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub last_authenticated_at_unix: Option<u64>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub last_request_at_unix: Option<u64>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerRequestAuditRecord {
- pub request_id: RadrootsNostrSignerRequestId,
- pub connection_id: RadrootsNostrSignerConnectionId,
- pub method: Method,
- pub decision: RadrootsNostrSignerRequestDecision,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub message: Option<String>,
- pub created_at_unix: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerPublishWorkflowRecord {
- pub workflow_id: RadrootsNostrSignerWorkflowId,
- pub connection_id: RadrootsNostrSignerConnectionId,
- pub kind: RadrootsNostrSignerPublishWorkflowKind,
- pub state: RadrootsNostrSignerPublishWorkflowState,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub authorized_at_unix: Option<u64>,
- pub created_at_unix: u64,
- pub updated_at_unix: u64,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RadrootsNostrSignerStoreState {
- pub version: u32,
- pub signer_identity: Option<PublicIdentity>,
- pub connections: Vec<RadrootsNostrSignerConnectionRecord>,
- pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>,
- #[serde(default)]
- pub publish_workflows: Vec<RadrootsNostrSignerPublishWorkflowRecord>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(untagged)]
-enum RadrootsNostrSignerConnectSecretHashRepr {
- Hash(RadrootsNostrSignerConnectSecretHash),
- LegacyPlaintext(String),
-}
-
-impl RadrootsNostrSignerConnectionId {
- pub fn new_v7() -> Self {
- Self(Uuid::now_v7().to_string())
- }
-
- pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(RadrootsNostrSignerError::InvalidConnectionId(
- value.to_owned(),
- ));
- }
- Ok(Self(trimmed.to_owned()))
- }
-
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn into_string(self) -> String {
- self.0
- }
-}
-
-impl fmt::Display for RadrootsNostrSignerConnectionId {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(self.as_str())
- }
-}
-
-impl AsRef<str> for RadrootsNostrSignerConnectionId {
- fn as_ref(&self) -> &str {
- self.as_str()
- }
-}
-
-impl FromStr for RadrootsNostrSignerConnectionId {
- type Err = RadrootsNostrSignerError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::parse(value)
- }
-}
-
-impl RadrootsNostrSignerRequestId {
- pub fn new_v7() -> Self {
- Self(Uuid::now_v7().to_string())
- }
-
- pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(RadrootsNostrSignerError::InvalidRequestId(value.to_owned()));
- }
- Ok(Self(trimmed.to_owned()))
- }
-
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn into_string(self) -> String {
- self.0
- }
-}
-
-impl RadrootsNostrSignerWorkflowId {
- pub fn new_v7() -> Self {
- Self(Uuid::now_v7().to_string())
- }
-
- pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- return Err(RadrootsNostrSignerError::InvalidWorkflowId(
- value.to_owned(),
- ));
- }
- Ok(Self(trimmed.to_owned()))
- }
-
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn into_string(self) -> String {
- self.0
- }
-}
-
-impl fmt::Display for RadrootsNostrSignerWorkflowId {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(self.as_str())
- }
-}
-
-impl AsRef<str> for RadrootsNostrSignerWorkflowId {
- fn as_ref(&self) -> &str {
- self.as_str()
- }
-}
-
-impl FromStr for RadrootsNostrSignerWorkflowId {
- type Err = RadrootsNostrSignerError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::parse(value)
- }
-}
-
-impl fmt::Display for RadrootsNostrSignerRequestId {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(self.as_str())
- }
-}
-
-impl AsRef<str> for RadrootsNostrSignerRequestId {
- fn as_ref(&self) -> &str {
- self.as_str()
- }
-}
-
-impl FromStr for RadrootsNostrSignerRequestId {
- type Err = RadrootsNostrSignerError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::parse(value)
- }
-}
-
-impl RadrootsNostrSignerConnectSecretHash {
- pub fn from_secret(secret: &str) -> Option<Self> {
- normalize_optional_string(secret).map(|normalized| {
- let mut hasher = Sha256::new();
- hasher.update(normalized.as_bytes());
- Self {
- algorithm: RadrootsNostrSignerSecretDigestAlgorithm::Sha256,
- digest_hex: hex_encode(hasher.finalize()),
- }
- })
- }
-
- pub fn matches_secret(&self, secret: &str) -> bool {
- Self::from_secret(secret).as_ref() == Some(self)
- }
-
- fn normalize(self) -> Result<Self, String> {
- let digest_hex = self.digest_hex.trim().to_ascii_lowercase();
- if digest_hex.len() != 64 || !digest_hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
- return Err("invalid connect secret digest".into());
- }
- Ok(Self {
- algorithm: self.algorithm,
- digest_hex,
- })
- }
-}
-
-impl RadrootsNostrSignerAuthChallenge {
- pub fn new(auth_url: &str, required_at_unix: u64) -> Result<Self, RadrootsNostrSignerError> {
- let auth_url = normalize_optional_string(auth_url)
- .ok_or_else(|| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.to_owned()))?;
- let auth_url: String = Url::parse(&auth_url)
- .map_err(|_| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.clone()))?
- .into();
- Ok(Self {
- auth_url,
- required_at_unix,
- authorized_at_unix: None,
- })
- }
-
- pub fn mark_authorized(&mut self, authorized_at_unix: u64) {
- self.authorized_at_unix = Some(authorized_at_unix);
- }
-}
-
-impl<'de> Deserialize<'de> for RadrootsNostrSignerAuthChallenge {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: Deserializer<'de>,
- {
- #[derive(Deserialize)]
- struct RawAuthChallenge {
- auth_url: String,
- required_at_unix: u64,
- #[serde(default)]
- authorized_at_unix: Option<u64>,
- }
-
- let raw = RawAuthChallenge::deserialize(deserializer)?;
- let mut challenge =
- Self::new(&raw.auth_url, raw.required_at_unix).map_err(serde::de::Error::custom)?;
- challenge.authorized_at_unix = raw.authorized_at_unix;
- Ok(challenge)
- }
-}
-
-impl RadrootsNostrSignerPendingRequest {
- pub fn new(
- request_message: RequestMessage,
- created_at_unix: u64,
- ) -> Result<Self, RadrootsNostrSignerError> {
- let normalized_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?;
- Ok(Self {
- request_message: RequestMessage::new(normalized_id.as_str(), request_message.request),
- created_at_unix,
- })
- }
-
- pub fn request_message(&self) -> RequestMessage {
- self.request_message.clone()
- }
-
- pub fn request_id(&self) -> RadrootsNostrSignerRequestId {
- RadrootsNostrSignerRequestId::parse(&self.request_message.id)
- .expect("pending request ids are validated on construction")
- }
-}
-
-impl RadrootsNostrSignerAuthorizationOutcome {
- pub fn new(
- connection: RadrootsNostrSignerConnectionRecord,
- pending_request: Option<RadrootsNostrSignerPendingRequest>,
- ) -> Self {
- Self {
- connection,
- pending_request,
- }
- }
-}
-
-impl RadrootsNostrSignerPermissionGrant {
- pub fn new(permission: Permission, granted_at_unix: u64) -> Self {
- Self {
- permission,
- granted_at_unix,
- }
- }
-}
-
-impl RadrootsNostrSignerConnectionDraft {
- pub fn new(client_public_key: PublicKey, user_identity: PublicIdentity) -> Self {
- Self {
- client_public_key,
- user_identity,
- connect_secret: None,
- client_metadata: None,
- requested_permissions: Permissions::default(),
- relays: Vec::new(),
- approval_requirement: RadrootsNostrSignerApprovalRequirement::NotRequired,
- }
- }
-
- pub fn with_connect_secret(mut self, connect_secret: impl Into<String>) -> Self {
- self.connect_secret = Some(connect_secret.into());
- self
- }
-
- pub fn with_requested_permissions(mut self, requested_permissions: Permissions) -> Self {
- self.requested_permissions = requested_permissions;
- self
- }
-
- pub fn with_client_metadata(mut self, client_metadata: ClientMetadata) -> Self {
- self.client_metadata = Some(client_metadata);
- self
- }
-
- pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self {
- self.relays = relays;
- self
- }
-
- pub fn with_approval_requirement(
- mut self,
- approval_requirement: RadrootsNostrSignerApprovalRequirement,
- ) -> Self {
- self.approval_requirement = approval_requirement;
- self
- }
-}
-
-impl RadrootsNostrSignerConnectionRecord {
- pub fn new(
- connection_id: RadrootsNostrSignerConnectionId,
- signer_identity: PublicIdentity,
- draft: RadrootsNostrSignerConnectionDraft,
- created_at_unix: u64,
- ) -> Self {
- let (approval_state, status) = match draft.approval_requirement {
- RadrootsNostrSignerApprovalRequirement::NotRequired => (
- RadrootsNostrSignerApprovalState::NotRequired,
- RadrootsNostrSignerConnectionStatus::Active,
- ),
- RadrootsNostrSignerApprovalRequirement::ExplicitUser => (
- RadrootsNostrSignerApprovalState::Pending,
- RadrootsNostrSignerConnectionStatus::Pending,
- ),
- };
-
- Self {
- connection_id,
- client_public_key: draft.client_public_key,
- signer_identity,
- user_identity: draft.user_identity,
- connect_secret_hash: draft
- .connect_secret
- .as_deref()
- .and_then(RadrootsNostrSignerConnectSecretHash::from_secret),
- connect_secret_consumed_at_unix: None,
- client_metadata: draft.client_metadata,
- requested_permissions: draft.requested_permissions,
- granted_permissions: Vec::new(),
- relays: draft.relays,
- approval_requirement: draft.approval_requirement,
- approval_state,
- auth_state: RadrootsNostrSignerAuthState::NotRequired,
- auth_challenge: None,
- pending_request: None,
- status,
- status_reason: None,
- created_at_unix,
- updated_at_unix: created_at_unix,
- last_authenticated_at_unix: None,
- last_request_at_unix: None,
- }
- }
-
- pub fn granted_permissions(&self) -> Permissions {
- self.granted_permissions
- .iter()
- .map(|grant| grant.permission.clone())
- .collect::<Vec<_>>()
- .into()
- }
-
- pub fn effective_permissions(&self) -> Permissions {
- let granted_permissions = self.granted_permissions();
- if !granted_permissions.is_empty() {
- granted_permissions
- } else if self.approval_state == RadrootsNostrSignerApprovalState::NotRequired {
- self.requested_permissions.clone()
- } else {
- Permissions::default()
- }
- }
-
- pub fn is_terminal(&self) -> bool {
- matches!(
- self.status,
- RadrootsNostrSignerConnectionStatus::Rejected
- | RadrootsNostrSignerConnectionStatus::Revoked
- )
- }
-
- pub fn connect_secret_is_consumed(&self) -> bool {
- self.connect_secret_hash.is_some() && self.connect_secret_consumed_at_unix.is_some()
- }
-
- pub fn touch_updated(&mut self, updated_at_unix: u64) {
- self.updated_at_unix = updated_at_unix;
- }
-
- pub fn mark_authenticated(&mut self, authenticated_at_unix: u64) {
- self.last_authenticated_at_unix = Some(authenticated_at_unix);
- self.updated_at_unix = authenticated_at_unix;
- }
-
- pub fn mark_request(&mut self, request_at_unix: u64) {
- self.last_request_at_unix = Some(request_at_unix);
- self.updated_at_unix = request_at_unix;
- }
-
- pub fn mark_connect_secret_consumed(&mut self, consumed_at_unix: u64) {
- if self.connect_secret_hash.is_none() || self.connect_secret_consumed_at_unix.is_some() {
- return;
- }
- self.connect_secret_consumed_at_unix = Some(consumed_at_unix);
- self.updated_at_unix = consumed_at_unix;
- }
-
- pub fn require_auth_challenge(&mut self, auth_challenge: RadrootsNostrSignerAuthChallenge) {
- self.auth_state = RadrootsNostrSignerAuthState::Pending;
- self.auth_challenge = Some(auth_challenge.clone());
- self.pending_request = None;
- self.updated_at_unix = auth_challenge.required_at_unix;
- }
-
- pub fn set_pending_request(&mut self, pending_request: RadrootsNostrSignerPendingRequest) {
- self.pending_request = Some(pending_request.clone());
- self.updated_at_unix = pending_request.created_at_unix;
- }
-
- pub fn authorize_auth_challenge(
- &mut self,
- authorized_at_unix: u64,
- ) -> Option<RadrootsNostrSignerPendingRequest> {
- self.auth_state = RadrootsNostrSignerAuthState::Authorized;
- if let Some(auth_challenge) = self.auth_challenge.as_mut() {
- auth_challenge.mark_authorized(authorized_at_unix);
- }
- self.last_authenticated_at_unix = Some(authorized_at_unix);
- self.updated_at_unix = authorized_at_unix;
- self.pending_request.take()
- }
-
- pub fn restore_pending_auth_challenge(
- &mut self,
- pending_request: RadrootsNostrSignerPendingRequest,
- restored_at_unix: u64,
- ) {
- self.auth_state = RadrootsNostrSignerAuthState::Pending;
- if let Some(auth_challenge) = self.auth_challenge.as_mut() {
- let previous_authorized_at_unix = auth_challenge.authorized_at_unix.take();
- if self.last_authenticated_at_unix == previous_authorized_at_unix {
- self.last_authenticated_at_unix = None;
- }
- }
- self.pending_request = Some(pending_request);
- self.updated_at_unix = restored_at_unix;
- }
-}
-
-impl RadrootsNostrSignerRequestAuditRecord {
- pub fn new(
- request_id: RadrootsNostrSignerRequestId,
- connection_id: RadrootsNostrSignerConnectionId,
- method: Method,
- decision: RadrootsNostrSignerRequestDecision,
- message: Option<String>,
- created_at_unix: u64,
- ) -> Self {
- Self {
- request_id,
- connection_id,
- method,
- decision,
- message,
- created_at_unix,
- }
- }
-}
-
-impl RadrootsNostrSignerPublishWorkflowRecord {
- pub fn new_connect_secret_finalization(
- connection_id: RadrootsNostrSignerConnectionId,
- created_at_unix: u64,
- ) -> Self {
- Self {
- workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
- connection_id,
- kind: RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization,
- state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
- pending_request: None,
- authorized_at_unix: None,
- created_at_unix,
- updated_at_unix: created_at_unix,
- }
- }
-
- pub fn new_auth_replay_finalization(
- connection_id: RadrootsNostrSignerConnectionId,
- pending_request: RadrootsNostrSignerPendingRequest,
- authorized_at_unix: u64,
- ) -> Self {
- Self {
- workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
- connection_id,
- kind: RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization,
- state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
- pending_request: Some(pending_request),
- authorized_at_unix: Some(authorized_at_unix),
- created_at_unix: authorized_at_unix,
- updated_at_unix: authorized_at_unix,
- }
- }
-
- pub fn mark_published(&mut self, updated_at_unix: u64) {
- self.state = RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize;
- self.updated_at_unix = updated_at_unix;
- }
-}
-
-impl Default for RadrootsNostrSignerStoreState {
- fn default() -> Self {
- Self {
- version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
- signer_identity: None,
- connections: Vec::new(),
- audit_records: Vec::new(),
- publish_workflows: Vec::new(),
- }
- }
-}
-
-fn serialize_permission<S>(permission: &Permission, serializer: S) -> Result<S::Ok, S::Error>
-where
- S: serde::Serializer,
-{
- serializer.serialize_str(&permission.to_string())
-}
-
-fn deserialize_permission<'de, D>(deserializer: D) -> Result<Permission, D::Error>
-where
- D: serde::Deserializer<'de>,
-{
- let value = String::deserialize(deserializer)?;
- value.parse().map_err(serde::de::Error::custom)
-}
-
-fn deserialize_connect_secret_hash_option<'de, D>(
- deserializer: D,
-) -> Result<Option<RadrootsNostrSignerConnectSecretHash>, D::Error>
-where
- D: Deserializer<'de>,
-{
- let value = Option::<RadrootsNostrSignerConnectSecretHashRepr>::deserialize(deserializer)?;
- match value {
- None => Ok(None),
- Some(RadrootsNostrSignerConnectSecretHashRepr::Hash(hash)) => {
- hash.normalize().map(Some).map_err(serde::de::Error::custom)
- }
- Some(RadrootsNostrSignerConnectSecretHashRepr::LegacyPlaintext(secret)) => {
- Ok(RadrootsNostrSignerConnectSecretHash::from_secret(&secret))
- }
- }
-}
-
-fn normalize_optional_string(value: &str) -> Option<String> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_owned())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
- use crate::signer::test_support::{
- api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key,
- primary_relay, synthetic_public_identity, synthetic_public_key,
- };
- use nostr::PublicKey;
- use serde_json::json;
- use std::str::FromStr;
- use tempfile::tempdir;
-
- fn public_identity(index: u32) -> PublicIdentity {
- synthetic_public_identity(index)
- }
-
- fn public_key(index: u32) -> PublicKey {
- synthetic_public_key(index)
- }
-
- fn request_message(id: &str) -> RequestMessage {
- RequestMessage::new(id, radroots_nostr_connect::Request::Ping)
- }
-
- #[test]
- fn connection_and_request_ids_parse_and_display() {
- let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection");
- let request_id = RadrootsNostrSignerRequestId::parse("req-1").expect("request");
- let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-1").expect("workflow");
-
- assert_eq!(connection_id.as_str(), "conn-1");
- assert_eq!(request_id.as_str(), "req-1");
- assert_eq!(workflow_id.as_str(), "wf-1");
- assert_eq!(connection_id.as_ref(), "conn-1");
- assert_eq!(request_id.as_ref(), "req-1");
- assert_eq!(workflow_id.as_ref(), "wf-1");
- assert_eq!(connection_id.to_string(), "conn-1");
- assert_eq!(request_id.to_string(), "req-1");
- assert_eq!(workflow_id.to_string(), "wf-1");
- assert_eq!(connection_id.clone().into_string(), "conn-1");
- assert_eq!(request_id.clone().into_string(), "req-1");
- assert_eq!(workflow_id.clone().into_string(), "wf-1");
-
- let parsed_connection =
- RadrootsNostrSignerConnectionId::from_str("conn-1").expect("from_str connection");
- let parsed_request =
- RadrootsNostrSignerRequestId::from_str("req-1").expect("from_str request");
- let parsed_workflow =
- RadrootsNostrSignerWorkflowId::from_str("wf-1").expect("from_str workflow");
- assert_eq!(parsed_connection, connection_id);
- assert_eq!(parsed_request, request_id);
- assert_eq!(parsed_workflow, workflow_id);
- }
-
- #[test]
- fn generated_ids_are_non_empty() {
- let connection_id = RadrootsNostrSignerConnectionId::new_v7();
- let request_id = RadrootsNostrSignerRequestId::new_v7();
- let workflow_id = RadrootsNostrSignerWorkflowId::new_v7();
-
- assert!(!connection_id.as_ref().is_empty());
- assert!(!request_id.as_ref().is_empty());
- assert!(!workflow_id.as_ref().is_empty());
- }
-
- #[test]
- fn ids_reject_empty_values() {
- let connection_err =
- RadrootsNostrSignerConnectionId::parse(" ").expect_err("empty connection");
- let request_err = RadrootsNostrSignerRequestId::parse("").expect_err("empty request");
- let workflow_err = RadrootsNostrSignerWorkflowId::parse(" ").expect_err("empty workflow");
-
- assert!(connection_err.to_string().contains("invalid connection id"));
- assert!(request_err.to_string().contains("invalid request id"));
- assert!(workflow_err.to_string().contains("invalid workflow id"));
- }
-
- #[test]
- fn connection_draft_builders_apply_values() {
- let permission = Permission::with_parameter(Method::SignEvent, "kind:1");
- let relay = primary_relay();
- let metadata = ClientMetadata {
- requested_permissions: Permissions::default(),
- name: Some("Example Client".into()),
- url: None,
- image: None,
- };
- let draft = RadrootsNostrSignerConnectionDraft::new(
- fixture_carol_public_key(),
- fixture_bob_identity(),
- )
- .with_connect_secret(" secret ")
- .with_client_metadata(metadata.clone())
- .with_requested_permissions(vec![permission.clone()].into())
- .with_relays(vec![relay.clone()])
- .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser);
-
- assert_eq!(draft.connect_secret.as_deref(), Some(" secret "));
- assert_eq!(draft.client_metadata.as_ref(), Some(&metadata));
- assert_eq!(draft.requested_permissions.as_slice(), &[permission]);
- assert_eq!(draft.relays, vec![relay]);
- assert_eq!(
- draft.approval_requirement,
- RadrootsNostrSignerApprovalRequirement::ExplicitUser
- );
- }
-
- #[test]
- fn connection_record_defaults_follow_approval_requirement_and_tracking_helpers() {
- let signer_identity = fixture_alice_identity();
- let user_identity = fixture_bob_identity();
- let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id");
- let draft =
- RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity)
- .with_connect_secret(" secret ")
- .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser);
- let mut record =
- RadrootsNostrSignerConnectionRecord::new(connection_id, signer_identity, draft, 10);
-
- assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Pending);
- assert_eq!(
- record.approval_state,
- RadrootsNostrSignerApprovalState::Pending
- );
- assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired);
- assert!(
- record
- .connect_secret_hash
- .as_ref()
- .expect("connect secret hash")
- .matches_secret("secret")
- );
- assert!(!record.connect_secret_is_consumed());
- assert!(!record.is_terminal());
-
- record.touch_updated(12);
- record.mark_authenticated(14);
- record.mark_request(16);
- record.mark_connect_secret_consumed(17);
- record.require_auth_challenge(
- RadrootsNostrSignerAuthChallenge::new(
- format!("{}/path", api_primary_https()).as_str(),
- 18,
- )
- .expect("auth challenge"),
- );
- record.set_pending_request(
- RadrootsNostrSignerPendingRequest::new(request_message("req-1"), 20)
- .expect("pending request"),
- );
- let replay = record.authorize_auth_challenge(22).expect("replay");
- let no_challenge_replay = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::parse("conn-1b").expect("id"),
- public_identity(0x9),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x10), public_identity(0x11)),
- 24,
- )
- .authorize_auth_challenge(25);
-
- assert_eq!(record.updated_at_unix, 22);
- assert_eq!(record.connect_secret_consumed_at_unix, Some(17));
- assert!(record.connect_secret_is_consumed());
- assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Authorized);
- assert_eq!(
- record
- .auth_challenge
- .as_ref()
- .expect("auth challenge")
- .authorized_at_unix,
- Some(22)
- );
- assert!(record.pending_request.is_none());
- assert_eq!(record.last_authenticated_at_unix, Some(22));
- assert_eq!(record.last_request_at_unix, Some(16));
- assert_eq!(replay.request_id().as_str(), "req-1");
- assert!(no_challenge_replay.is_none());
-
- record.restore_pending_auth_challenge(replay, 23);
-
- assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Pending);
- assert_eq!(
- record
- .auth_challenge
- .as_ref()
- .expect("restored challenge")
- .authorized_at_unix,
- None
- );
- assert_eq!(record.last_authenticated_at_unix, None);
- assert_eq!(record.updated_at_unix, 23);
- assert_eq!(
- record
- .pending_request
- .as_ref()
- .expect("restored pending request")
- .request_id()
- .as_str(),
- "req-1"
- );
- }
-
- #[test]
- fn connection_record_noop_consumption_and_restore_paths_preserve_state() {
- let mut no_secret_record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::parse("conn-no-secret").expect("id"),
- public_identity(0x12),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x13), public_identity(0x14)),
- 30,
- );
- let no_secret_updated_at = no_secret_record.updated_at_unix;
- assert!(!no_secret_record.connect_secret_is_consumed());
-
- no_secret_record.mark_connect_secret_consumed(31);
-
- assert_eq!(no_secret_record.connect_secret_consumed_at_unix, None);
- assert_eq!(no_secret_record.updated_at_unix, no_secret_updated_at);
- assert!(!no_secret_record.connect_secret_is_consumed());
-
- let restored_without_challenge =
- RadrootsNostrSignerPendingRequest::new(request_message("req-no-challenge"), 32)
- .expect("pending request");
- no_secret_record.last_authenticated_at_unix = Some(29);
- no_secret_record.restore_pending_auth_challenge(restored_without_challenge.clone(), 33);
-
- assert_eq!(no_secret_record.last_authenticated_at_unix, Some(29));
- assert_eq!(
- no_secret_record.pending_request.as_ref(),
- Some(&restored_without_challenge)
- );
- assert_eq!(no_secret_record.updated_at_unix, 33);
-
- let mut restored_record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::parse("conn-restore-preserve").expect("id"),
- public_identity(0x15),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x16), public_identity(0x17)),
- 40,
- );
- restored_record.require_auth_challenge(
- RadrootsNostrSignerAuthChallenge::new(
- format!("{}/preserve", api_primary_https()).as_str(),
- 41,
- )
- .expect("auth challenge"),
- );
- restored_record.set_pending_request(
- RadrootsNostrSignerPendingRequest::new(request_message("req-preserve"), 42)
- .expect("pending request"),
- );
- let replay = restored_record
- .authorize_auth_challenge(43)
- .expect("authorize challenge");
- restored_record.last_authenticated_at_unix = Some(99);
-
- restored_record.restore_pending_auth_challenge(replay.clone(), 44);
-
- assert_eq!(
- restored_record.auth_state,
- RadrootsNostrSignerAuthState::Pending
- );
- assert_eq!(restored_record.last_authenticated_at_unix, Some(99));
- assert_eq!(
- restored_record
- .auth_challenge
- .as_ref()
- .expect("restored challenge")
- .authorized_at_unix,
- None
- );
- assert_eq!(restored_record.pending_request.as_ref(), Some(&replay));
- assert_eq!(restored_record.updated_at_unix, 44);
- }
-
- #[test]
- fn granted_permissions_and_request_audit_build_correctly() {
- let permission = Permission::new(Method::Ping);
- let grant = RadrootsNostrSignerPermissionGrant::new(permission.clone(), 42);
- let mut record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"),
- public_identity(0x6),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x7), public_identity(0x8)),
- 20,
- );
- record.granted_permissions = vec![grant];
- let audit = RadrootsNostrSignerRequestAuditRecord::new(
- RadrootsNostrSignerRequestId::parse("req-2").expect("request"),
- RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"),
- Method::Ping,
- RadrootsNostrSignerRequestDecision::Allowed,
- Some("ok".into()),
- 25,
- );
-
- assert_eq!(record.granted_permissions().as_slice(), &[permission]);
- assert_eq!(audit.message.as_deref(), Some("ok"));
- assert_eq!(audit.created_at_unix, 25);
-
- let json = serde_json::to_string(&record.granted_permissions[0]).expect("serialize grant");
- let decoded: RadrootsNostrSignerPermissionGrant =
- serde_json::from_str(&json).expect("deserialize grant");
- assert_eq!(decoded.permission, Permission::new(Method::Ping));
- }
-
- #[test]
- fn publish_workflow_records_cover_connect_secret_and_auth_replay_lifecycle() {
- let connection_id = RadrootsNostrSignerConnectionId::parse("conn-workflow").expect("id");
- let pending_request =
- RadrootsNostrSignerPendingRequest::new(request_message("req-workflow"), 41)
- .expect("pending request");
-
- let connect_secret =
- RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
- connection_id.clone(),
- 40,
- );
- assert_eq!(
- connect_secret.kind,
- RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
- );
- assert_eq!(
- connect_secret.state,
- RadrootsNostrSignerPublishWorkflowState::PendingPublish
- );
- assert!(connect_secret.pending_request.is_none());
- assert!(connect_secret.authorized_at_unix.is_none());
-
- let mut auth_replay =
- RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
- connection_id,
- pending_request.clone(),
- 42,
- );
- assert_eq!(
- auth_replay.kind,
- RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
- );
- assert_eq!(
- auth_replay.state,
- RadrootsNostrSignerPublishWorkflowState::PendingPublish
- );
- assert_eq!(auth_replay.pending_request, Some(pending_request));
- assert_eq!(auth_replay.authorized_at_unix, Some(42));
-
- auth_replay.mark_published(43);
- assert_eq!(
- auth_replay.state,
- RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
- );
- assert_eq!(auth_replay.updated_at_unix, 43);
- }
-
- #[test]
- fn effective_permissions_prefers_grants_then_auto_requested_then_empty() {
- let requested: Permissions = vec![Permission::new(Method::Nip04Encrypt)].into();
- let auto_record = RadrootsNostrSignerConnectionRecord::new(
- RadrootsNostrSignerConnectionId::new_v7(),
- public_identity(0x31),
- RadrootsNostrSignerConnectionDraft::new(public_key(0x32), public_identity(0x33))
- .with_requested_permissions(requested.clone()),
- 1,
- );
- assert_eq!(auto_record.effective_permissions(), requested);
-
- let mut granted_record = auto_record.clone();
- granted_record.granted_permissions = vec![RadrootsNostrSignerPermissionGrant::new(
- Permission::new(Method::Ping),
- 2,
- )];
- assert_eq!(
- granted_record.effective_permissions(),
- vec![Permission::new(Method::Ping)].into()
- );
-
- let mut approved_without_grants = auto_record;
- approved_without_grants.approval_state = RadrootsNostrSignerApprovalState::Approved;
- assert!(approved_without_grants.effective_permissions().is_empty());
- }
-
- #[test]
- fn permission_serde_helpers_round_trip_through_wrapper() {
- #[derive(Debug, Serialize, Deserialize)]
- struct PermissionWrapper {
- #[serde(
- serialize_with = "serialize_permission",
- deserialize_with = "deserialize_permission"
- )]
- permission: Permission,
- }
-
- let wrapper = PermissionWrapper {
- permission: Permission::with_parameter(Method::SignEvent, "kind:1"),
- };
-
- let json = serde_json::to_vec_pretty(&wrapper).expect("serialize wrapper");
- let temp = tempdir().expect("tempdir");
- let path = temp.path().join("permission.json");
- std::fs::write(&path, &json).expect("write permission");
- let file = std::fs::File::open(&path).expect("open permission");
- let reader = std::io::BufReader::new(file);
- let decoded: PermissionWrapper =
- serde_json::from_reader(reader).expect("deserialize wrapper");
-
- assert_eq!(decoded.permission, wrapper.permission);
-
- let value = serde_json::to_value(&wrapper).expect("serialize wrapper to value");
- let decoded_from_value: PermissionWrapper =
- serde_json::from_value(value).expect("deserialize wrapper from value");
- assert_eq!(decoded_from_value.permission, wrapper.permission);
-
- let invalid = serde_json::from_str::<PermissionWrapper>(r#"{"permission":1}"#)
- .expect_err("invalid permission type");
- assert!(invalid.to_string().contains("invalid type"));
-
- let invalid_from_value =
- serde_json::from_value::<PermissionWrapper>(json!({ "permission": 1 }))
- .expect_err("invalid permission type from value");
- assert!(invalid_from_value.to_string().contains("invalid type"));
-
- let invalid_path = temp.path().join("invalid-permission.json");
- std::fs::write(&invalid_path, br#"{"permission":1}"#).expect("write invalid permission");
- let invalid_file = std::fs::File::open(&invalid_path).expect("open invalid permission");
- let invalid_reader = std::io::BufReader::new(invalid_file);
- let invalid_from_reader = serde_json::from_reader::<_, PermissionWrapper>(invalid_reader)
- .expect_err("invalid permission type from reader");
- assert!(invalid_from_reader.to_string().contains("invalid type"));
- }
-
- #[test]
- fn connect_secret_hash_and_pending_request_helpers_validate_inputs() {
- let hash =
- RadrootsNostrSignerConnectSecretHash::from_secret(" secret ").expect("secret hash");
- assert!(hash.matches_secret("secret"));
- assert!(!hash.matches_secret("other"));
- assert!(RadrootsNostrSignerConnectSecretHash::from_secret(" ").is_none());
-
- let pending = RadrootsNostrSignerPendingRequest::new(request_message("req-2"), 30)
- .expect("pending request");
- assert_eq!(pending.request_id().as_str(), "req-2");
- assert_eq!(pending.request_message().id, "req-2");
-
- let invalid_pending = RadrootsNostrSignerPendingRequest::new(request_message(" "), 30)
- .expect_err("invalid pending request id");
- assert!(invalid_pending.to_string().contains("invalid request id"));
-
- let auth_url = format!(" {} ", api_primary_https());
- let challenge =
- RadrootsNostrSignerAuthChallenge::new(auth_url.as_str(), 31).expect("challenge");
- assert_eq!(challenge.auth_url, format!("{}/", api_primary_https()));
-
- let invalid_challenge =
- RadrootsNostrSignerAuthChallenge::new("not-a-url", 31).expect_err("invalid challenge");
- assert!(invalid_challenge.to_string().contains("invalid auth url"));
-
- let empty_challenge =
- RadrootsNostrSignerAuthChallenge::new(" ", 31).expect_err("empty challenge");
- assert!(empty_challenge.to_string().contains("invalid auth url"));
- }
-
- #[test]
- fn auth_challenge_deserialize_rejects_invalid_urls_across_entrypoints() {
- let invalid_json = json!({
- "auth_url": " ",
- "required_at_unix": 44
- });
-
- let invalid_from_value =
- serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_json.clone())
- .expect_err("invalid auth challenge from value");
- assert!(invalid_from_value.to_string().contains("invalid auth url"));
-
- let invalid_from_str =
- serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(&invalid_json.to_string())
- .expect_err("invalid auth challenge from str");
- assert!(invalid_from_str.to_string().contains("invalid auth url"));
-
- let temp = tempdir().expect("tempdir");
- let path = temp.path().join("invalid-auth-challenge.json");
- std::fs::write(
- &path,
- serde_json::to_vec(&invalid_json).expect("serialize invalid auth challenge"),
- )
- .expect("write invalid auth challenge");
- let file = std::fs::File::open(&path).expect("open invalid auth challenge");
- let reader = std::io::BufReader::new(file);
- let invalid_from_reader =
- serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(reader)
- .expect_err("invalid auth challenge from reader");
- assert!(invalid_from_reader.to_string().contains("invalid auth url"));
-
- let invalid_shape_json = json!({
- "auth_url": 1,
- "required_at_unix": 44
- });
- let invalid_shape_from_value =
- serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_shape_json.clone())
- .expect_err("invalid auth challenge shape from value");
- assert!(
- invalid_shape_from_value
- .to_string()
- .contains("invalid type")
- );
-
- let invalid_shape_from_str = serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(
- &invalid_shape_json.to_string(),
- )
- .expect_err("invalid auth challenge shape from str");
- assert!(invalid_shape_from_str.to_string().contains("invalid type"));
-
- let invalid_shape_path = temp.path().join("invalid-auth-challenge-shape.json");
- std::fs::write(
- &invalid_shape_path,
- serde_json::to_vec(&invalid_shape_json)
- .expect("serialize invalid auth challenge shape"),
- )
- .expect("write invalid auth challenge shape");
- let invalid_shape_file =
- std::fs::File::open(&invalid_shape_path).expect("open invalid auth challenge shape");
- let invalid_shape_reader = std::io::BufReader::new(invalid_shape_file);
- let invalid_shape_from_reader =
- serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(invalid_shape_reader)
- .expect_err("invalid auth challenge shape from reader");
- assert!(
- invalid_shape_from_reader
- .to_string()
- .contains("invalid type")
- );
- }
-
- #[test]
- fn connection_record_serde_migrates_legacy_connect_secret_and_validates_new_fields() {
- let record_json = json!({
- "connection_id": "conn-legacy",
- "client_public_key": public_key(0x9).to_hex(),
- "signer_identity": public_identity(0x10),
- "user_identity": public_identity(0x11),
- "connect_secret": " legacy-secret ",
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "status_reason": null,
- "created_at_unix": 1,
- "updated_at_unix": 1,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- });
-
- let decoded_without_secret: RadrootsNostrSignerConnectionRecord =
- serde_json::from_value(json!({
- "connection_id": "conn-no-secret",
- "client_public_key": public_key(0x8).to_hex(),
- "signer_identity": public_identity(0x7),
- "user_identity": public_identity(0x6),
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 0,
- "updated_at_unix": 0,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect("deserialize record without secret");
- assert!(decoded_without_secret.connect_secret_hash.is_none());
- assert!(decoded_without_secret.client_metadata.is_none());
- assert!(
- decoded_without_secret
- .connect_secret_consumed_at_unix
- .is_none()
- );
-
- let decoded_with_null_secret: RadrootsNostrSignerConnectionRecord =
- serde_json::from_value(json!({
- "connection_id": "conn-null-secret",
- "client_public_key": public_key(0x5).to_hex(),
- "signer_identity": public_identity(0x4),
- "user_identity": public_identity(0x3),
- "connect_secret_hash": null,
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 0,
- "updated_at_unix": 0,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect("deserialize record with null secret");
- assert!(decoded_with_null_secret.connect_secret_hash.is_none());
- assert!(
- decoded_with_null_secret
- .connect_secret_consumed_at_unix
- .is_none()
- );
-
- let decoded: RadrootsNostrSignerConnectionRecord =
- serde_json::from_value(record_json).expect("deserialize legacy record");
- assert!(
- decoded
- .connect_secret_hash
- .as_ref()
- .expect("connect secret hash")
- .matches_secret("legacy-secret")
- );
-
- let encoded = serde_json::to_value(&decoded).expect("serialize record");
- assert!(encoded.get("connect_secret").is_none());
- assert!(encoded.get("connect_secret_hash").is_some());
- assert!(encoded.get("connect_secret_consumed_at_unix").is_none());
- assert_eq!(
- encoded
- .get("auth_state")
- .and_then(serde_json::Value::as_str),
- Some("NotRequired")
- );
-
- let valid_hash = RadrootsNostrSignerConnectSecretHash::from_secret("explicit-secret")
- .expect("valid hash");
- let decoded_new_format: RadrootsNostrSignerConnectionRecord =
- serde_json::from_value(json!({
- "connection_id": "conn-new",
- "client_public_key": public_key(0x15).to_hex(),
- "signer_identity": public_identity(0x16),
- "user_identity": public_identity(0x17),
- "connect_secret_hash": {
- "algorithm": "sha256",
- "digest_hex": valid_hash.digest_hex
- },
- "connect_secret_consumed_at_unix": 23,
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 3,
- "updated_at_unix": 3,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect("deserialize new-format record");
- assert!(
- decoded_new_format
- .connect_secret_hash
- .as_ref()
- .expect("new-format hash")
- .matches_secret("explicit-secret")
- );
- assert_eq!(decoded_new_format.connect_secret_consumed_at_unix, Some(23));
- assert!(decoded_new_format.connect_secret_is_consumed());
-
- let temp = tempdir().expect("tempdir");
- let path = temp.path().join("connection-record.json");
- let reader_json = json!({
- "connection_id": "conn-reader",
- "client_public_key": public_key(0x21).to_hex(),
- "signer_identity": public_identity(0x22),
- "user_identity": public_identity(0x23),
- "connect_secret_hash": {
- "algorithm": "sha256",
- "digest_hex": RadrootsNostrSignerConnectSecretHash::from_secret("reader-secret")
- .expect("reader hash")
- .digest_hex
- },
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "auth_state": "Pending",
- "auth_challenge": {
- "auth_url": format!("{}/reader", api_primary_https()),
- "required_at_unix": 5
- },
- "status": "Active",
- "created_at_unix": 5,
- "updated_at_unix": 5,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- });
- std::fs::write(
- &path,
- serde_json::to_vec(&reader_json).expect("serialize reader json"),
- )
- .expect("write reader json");
- let file = std::fs::File::open(&path).expect("open reader json");
- let reader = std::io::BufReader::new(file);
- let decoded_from_reader: RadrootsNostrSignerConnectionRecord =
- serde_json::from_reader(reader).expect("deserialize reader record");
- assert!(
- decoded_from_reader
- .connect_secret_hash
- .as_ref()
- .expect("reader hash")
- .matches_secret("reader-secret")
- );
- assert_eq!(
- decoded_from_reader
- .auth_challenge
- .as_ref()
- .expect("reader auth challenge")
- .auth_url,
- format!("{}/reader", api_primary_https())
- );
-
- let invalid_hash_json = json!({
- "connection_id": "conn-invalid",
- "client_public_key": public_key(0x12).to_hex(),
- "signer_identity": public_identity(0x13),
- "user_identity": public_identity(0x14),
- "connect_secret_hash": {
- "algorithm": "sha256",
- "digest_hex": "not-hex"
- },
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "auth_state": "Authorized",
- "auth_challenge": {
- "auth_url": api_primary_https(),
- "required_at_unix": 2
- },
- "status_reason": null,
- "created_at_unix": 2,
- "updated_at_unix": 2,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- });
- let invalid_hash =
- serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(invalid_hash_json)
- .expect_err("invalid hash");
- assert!(
- invalid_hash
- .to_string()
- .contains("invalid connect secret digest")
- );
-
- let invalid_nonhex_hash =
- serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({
- "connection_id": "conn-invalid-nonhex",
- "client_public_key": public_key(0x18).to_hex(),
- "signer_identity": public_identity(0x19),
- "user_identity": public_identity(0x20),
- "connect_secret_hash": {
- "algorithm": "sha256",
- "digest_hex": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
- },
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 4,
- "updated_at_unix": 4,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect_err("invalid nonhex hash");
- assert!(
- invalid_nonhex_hash
- .to_string()
- .contains("invalid connect secret digest")
- );
-
- let invalid_connect_secret_hash_type =
- serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({
- "connection_id": "conn-invalid-type",
- "client_public_key": public_key(0x24).to_hex(),
- "signer_identity": public_identity(0x25),
- "user_identity": public_identity(0x26),
- "connect_secret_hash": 7,
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 6,
- "updated_at_unix": 6,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect_err("invalid connect secret hash type");
- assert!(!invalid_connect_secret_hash_type.to_string().is_empty());
-
- let invalid_connect_secret_hash_path = temp.path().join("invalid-connect-secret-type.json");
- std::fs::write(
- &invalid_connect_secret_hash_path,
- serde_json::to_vec(&json!({
- "connection_id": "conn-invalid-type-reader",
- "client_public_key": public_key(0x27).to_hex(),
- "signer_identity": public_identity(0x28),
- "user_identity": public_identity(0x29),
- "connect_secret_hash": 9,
- "requested_permissions": "",
- "granted_permissions": [],
- "relays": [],
- "approval_requirement": "NotRequired",
- "approval_state": "NotRequired",
- "status": "Active",
- "created_at_unix": 7,
- "updated_at_unix": 7,
- "last_authenticated_at_unix": null,
- "last_request_at_unix": null
- }))
- .expect("serialize invalid connect secret hash type"),
- )
- .expect("write invalid connect secret hash type");
- let invalid_connect_secret_hash_file =
- std::fs::File::open(&invalid_connect_secret_hash_path)
- .expect("open invalid connect secret hash type");
- let invalid_connect_secret_hash_reader =
- std::io::BufReader::new(invalid_connect_secret_hash_file);
- let invalid_connect_secret_hash_from_reader = serde_json::from_reader::<
- _,
- RadrootsNostrSignerConnectionRecord,
- >(invalid_connect_secret_hash_reader)
- .expect_err("invalid connect secret hash type from reader");
- assert!(
- !invalid_connect_secret_hash_from_reader
- .to_string()
- .is_empty()
- );
- }
-
- #[test]
- fn store_state_default_is_empty() {
- let state = RadrootsNostrSignerStoreState::default();
- assert_eq!(state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION);
- assert!(state.signer_identity.is_none());
- assert!(state.connections.is_empty());
- assert!(state.audit_records.is_empty());
- }
-}
diff --git a/src/signer/nip46.rs b/src/signer/nip46.rs
@@ -1,2191 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use nostr::{
- JsonUtil, UnsignedEvent,
- filter::{Alphabet, SingleLetterTag},
-};
-use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl};
-use radroots_nostr::event::Event as RadrootsNostrEvent;
-use radroots_nostr::event::GenericBuilder;
-use radroots_nostr::event::Kind as RadrootsNostrKind;
-use radroots_nostr::event::Timestamp as RadrootsNostrTimestamp;
-use radroots_nostr::filter::Filter as RadrootsNostrFilter;
-use radroots_nostr::tag::Tag as RadrootsNostrTag;
-use radroots_nostr_connect::{
- Error as ConnectError, Request, Response,
- message::{
- RPC_KIND, RequestMessage, SignedEvent as ConnectSignedEvent,
- UnsignedEvent as ConnectUnsignedEvent,
- },
- permission::Permissions,
-};
-
-use crate::signer::backend::RadrootsNostrSignerBackend;
-use crate::signer::error::RadrootsNostrSignerError;
-use crate::signer::evaluation::{
- RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction,
- RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint,
- RadrootsNostrSignerSessionLookup,
-};
-use crate::signer::model::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionId,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord,
- RadrootsNostrSignerRequestDecision,
-};
-
-/// Cryptographic operations required by the external NIP-46 protocol.
-///
-/// NIP-46 `sign_event` accepts caller-supplied unsigned Nostr events. Signing
-/// one is protocol interoperability only and does not establish a Radroots
-/// typed product-authoring contract.
-pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync {
- fn signer_public_key_hex(&self) -> String;
- fn decrypt_request(
- &self,
- client_public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
- fn encrypt_response(
- &self,
- client_public_key: &RadrootsNostrPublicKey,
- payload: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
- fn user_identity(&self) -> PublicIdentity;
- /// Signs a caller-supplied NIP-46 unsigned event without claiming typed
- /// Radroots product authoring.
- fn sign_user_event(
- &self,
- unsigned_event: UnsignedEvent,
- ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError>;
- fn nip04_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
- fn nip04_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
- fn nip44_encrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- plaintext: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
- fn nip44_decrypt(
- &self,
- public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError>;
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RadrootsNostrSignerNip46ConnectDecision {
- Allow,
- RequireApproval,
- Deny,
-}
-
-pub trait RadrootsNostrSignerNip46Policy<B: RadrootsNostrSignerBackend>:
- Clone + Send + Sync
-{
- fn connect_decision(
- &self,
- client_public_key: &RadrootsNostrPublicKey,
- ) -> RadrootsNostrSignerNip46ConnectDecision;
-
- fn connect_rate_limit_denied_reason(
- &self,
- client_public_key: &RadrootsNostrPublicKey,
- ) -> Option<String>;
-
- fn approval_requirement_for_client(
- &self,
- client_public_key: &RadrootsNostrPublicKey,
- ) -> Option<RadrootsNostrSignerApprovalRequirement>;
-
- fn filtered_requested_permissions(&self, requested_permissions: &Permissions) -> Permissions;
-
- fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions;
-
- fn prepare_request(
- &self,
- backend: &B,
- connection: &RadrootsNostrSignerConnectionRecord,
- request_message: &RequestMessage,
- ) -> Result<Option<String>, RadrootsNostrSignerError>;
-}
-
-#[derive(Clone)]
-pub struct RadrootsNostrSignerNip46Codec<S> {
- signer: S,
-}
-
-#[derive(Clone)]
-pub struct RadrootsNostrSignerNip46Handler<B, P, S> {
- backend: B,
- policy: P,
- relays: Vec<RadrootsNostrRelayUrl>,
- codec: RadrootsNostrSignerNip46Codec<S>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum RadrootsNostrSignerHandledRequest {
- Respond {
- response: Box<Response>,
- connection_id: Option<RadrootsNostrSignerConnectionId>,
- consume_connect_secret_for: Option<RadrootsNostrSignerConnectionId>,
- },
- Ignore,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsNostrSignerHandledRequestOutcome {
- pub handled_request: RadrootsNostrSignerHandledRequest,
- pub audit: Option<RadrootsNostrSignerRequestAuditRecord>,
-}
-
-enum RadrootsNostrSignerPreparedRequestEvaluation {
- Denied {
- reason: String,
- audit: RadrootsNostrSignerRequestAuditRecord,
- },
- Evaluation(Box<RadrootsNostrSignerRequestEvaluation>),
-}
-
-impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
- pub fn new(signer: S) -> Self {
- Self { signer }
- }
-
- pub fn filter(&self) -> Result<RadrootsNostrFilter, RadrootsNostrSignerError> {
- let filter = RadrootsNostrFilter::new()
- .kind(RadrootsNostrKind::Custom(RPC_KIND))
- .since(RadrootsNostrTimestamp::now());
- Ok(filter.custom_tags(
- SingleLetterTag::lowercase(Alphabet::P),
- vec![self.signer.signer_public_key_hex()],
- ))
- }
-
- pub fn parse_request_event(
- &self,
- event: &RadrootsNostrEvent,
- ) -> Result<RequestMessage, RadrootsNostrSignerError> {
- let decrypted = self.signer.decrypt_request(&event.pubkey, &event.content)?;
- Ok(serde_json::from_str(&decrypted).map_err(ConnectError::from)?)
- }
-
- pub fn build_response_event(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_id: impl Into<String>,
- response: Response,
- ) -> Result<GenericBuilder, RadrootsNostrSignerError> {
- let envelope = response.into_envelope(request_id.into())?;
- let payload = serde_json::to_string(&envelope).map_err(ConnectError::from)?;
- let ciphertext = self.signer.encrypt_response(&client_public_key, &payload)?;
-
- Ok(
- GenericBuilder::new(RadrootsNostrKind::Custom(RPC_KIND), ciphertext)
- .tags(vec![RadrootsNostrTag::public_key(client_public_key)]),
- )
- }
-
- /// Produces a NIP-46 response for an externally supplied unsigned event.
- ///
- /// A successful response proves only protocol signing. It does not confer
- /// a Radroots typed-authoring or product-admission claim.
- pub fn sign_event_response(
- &self,
- unsigned_event: UnsignedEvent,
- ) -> Result<Response, RadrootsNostrSignerError> {
- let unsigned_event = ConnectUnsignedEvent::from_json(&unsigned_event.as_json())?;
- Ok(self.sign_event_response_value(unsigned_event))
- }
-
- fn sign_event_response_value(&self, unsigned_event: ConnectUnsignedEvent) -> Response {
- let unsigned_event = match serde_json::from_str::<UnsignedEvent>(&unsigned_event.as_json())
- {
- Ok(unsigned_event) => unsigned_event,
- Err(error) => {
- return Response::Error {
- result: None,
- error: format!("invalid sign_event payload: {error}"),
- };
- }
- };
- let user_public_key = self.signer.user_identity().public_key().to_hex();
- if unsigned_event.pubkey.to_hex() != user_public_key {
- return Response::Error {
- result: None,
- error: "sign_event pubkey does not match the managed user identity".to_owned(),
- };
- }
-
- match self.signer.sign_user_event(unsigned_event) {
- Ok(event) => match ConnectSignedEvent::from_json(&event.as_json()) {
- Ok(event) => Response::SignedEvent(event),
- Err(error) => Response::Error {
- result: None,
- error: format!("failed to encode signed event: {error}"),
- },
- },
- Err(error) => Response::Error {
- result: None,
- error: format!("failed to sign event: {error}"),
- },
- }
- }
-
- pub fn crypto_response(&self, request: Request) -> Result<Response, RadrootsNostrSignerError> {
- Ok(self.crypto_response_value(request))
- }
-
- fn crypto_response_value(&self, request: Request) -> Response {
- match request {
- Request::Nip04Encrypt {
- public_key,
- plaintext,
- } => match nostr_public_key(public_key)
- .and_then(|public_key| self.signer.nip04_encrypt(&public_key, &plaintext))
- {
- Ok(ciphertext) => Response::Nip04Encrypt(ciphertext),
- Err(error) => Response::Error {
- result: None,
- error: format!("nip04 encrypt failed: {error}"),
- },
- },
- Request::Nip04Decrypt {
- public_key,
- ciphertext,
- } => match nostr_public_key(public_key)
- .and_then(|public_key| self.signer.nip04_decrypt(&public_key, &ciphertext))
- {
- Ok(plaintext) => Response::Nip04Decrypt(plaintext),
- Err(error) => Response::Error {
- result: None,
- error: format!("nip04 decrypt failed: {error}"),
- },
- },
- Request::Nip44Encrypt {
- public_key,
- plaintext,
- } => match nostr_public_key(public_key)
- .and_then(|public_key| self.signer.nip44_encrypt(&public_key, &plaintext))
- {
- Ok(ciphertext) => Response::Nip44Encrypt(ciphertext),
- Err(error) => Response::Error {
- result: None,
- error: format!("nip44 encrypt failed: {error}"),
- },
- },
- Request::Nip44Decrypt {
- public_key,
- ciphertext,
- } => match nostr_public_key(public_key)
- .and_then(|public_key| self.signer.nip44_decrypt(&public_key, &ciphertext))
- {
- Ok(plaintext) => Response::Nip44Decrypt(plaintext),
- Err(error) => Response::Error {
- result: None,
- error: format!("nip44 decrypt failed: {error}"),
- },
- },
- other => Response::Error {
- result: None,
- error: format!("request `{}` is not a crypto method", other.method()),
- },
- }
- }
-}
-
-impl<B, P, S> RadrootsNostrSignerNip46Handler<B, P, S>
-where
- B: RadrootsNostrSignerBackend + Clone,
- P: RadrootsNostrSignerNip46Policy<B>,
- S: RadrootsNostrSignerNip46Signer,
-{
- pub fn new(backend: B, policy: P, relays: Vec<RadrootsNostrRelayUrl>, signer: S) -> Self {
- Self {
- backend,
- policy,
- relays,
- codec: RadrootsNostrSignerNip46Codec::new(signer),
- }
- }
-
- pub fn filter(&self) -> Result<RadrootsNostrFilter, RadrootsNostrSignerError> {
- self.codec.filter()
- }
-
- pub fn parse_request_event(
- &self,
- event: &RadrootsNostrEvent,
- ) -> Result<RequestMessage, RadrootsNostrSignerError> {
- self.codec.parse_request_event(event)
- }
-
- pub fn build_response_event(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_id: impl Into<String>,
- response: Response,
- ) -> Result<GenericBuilder, RadrootsNostrSignerError> {
- self.codec
- .build_response_event(client_public_key, request_id, response)
- }
-
- pub fn handle_request(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- match request_message.request.clone() {
- Request::Connect { secret, .. } => {
- self.handle_connect_request(client_public_key, request_message.request, secret)
- }
- Request::SignEvent(unsigned_event) => {
- self.handle_sign_event_request(client_public_key, request_message, unsigned_event)
- }
- Request::Nip04Encrypt { .. }
- | Request::Nip04Decrypt { .. }
- | Request::Nip44Encrypt { .. }
- | Request::Nip44Decrypt { .. } => {
- self.handle_crypto_request(client_public_key, request_message)
- }
- Request::GetPublicKey
- | Request::GetSessionCapability
- | Request::Ping
- | Request::SwitchRelays => self.handle_base_request(client_public_key, request_message),
- _ => Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- RadrootsNostrSignerHandledRequest::respond(Response::Error {
- result: None,
- error: format!(
- "method `{}` is not implemented yet",
- request_message.request.method()
- ),
- }),
- None,
- )),
- }
- }
-
- pub fn handle_authorized_request_evaluation(
- &self,
- request_message: RequestMessage,
- evaluation: RadrootsNostrSignerRequestEvaluation,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- let audit = evaluation.audit.clone();
- let handled_request = self.handled_request_for_evaluation(request_message, evaluation)?;
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- handled_request,
- Some(audit),
- ))
- }
-
- fn handle_connect_request(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request: Request,
- secret: Option<String>,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- let connect_decision = self.policy.connect_decision(&client_public_key);
- if let Some(connect_secret) = secret.as_deref()
- && let Some(connection) = self
- .backend
- .find_connection_by_connect_secret(connect_secret)?
- && connection.connect_secret_is_consumed()
- {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::ignore());
- }
- if !matches!(
- connect_decision,
- RadrootsNostrSignerNip46ConnectDecision::Deny
- ) && let Some(reason) = self
- .policy
- .connect_rate_limit_denied_reason(&client_public_key)
- {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
- Response::Error {
- result: None,
- error: reason,
- },
- ));
- }
-
- let evaluation = self
- .backend
- .evaluate_connect_request(client_public_key, request)?;
-
- match evaluation {
- RadrootsNostrSignerConnectEvaluation::ExistingConnection(connection) => {
- if matches!(
- connect_decision,
- RadrootsNostrSignerNip46ConnectDecision::Deny
- ) {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
- Response::Error {
- result: None,
- error: "client public key denied by policy".to_owned(),
- },
- ));
- }
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- connect_response_outcome(&connection, secret),
- None,
- ))
- }
- RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => {
- let requested_permissions = self
- .policy
- .filtered_requested_permissions(&proposal.requested_permissions);
- let Some(approval_requirement) = self
- .policy
- .approval_requirement_for_client(&client_public_key)
- else {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
- Response::Error {
- result: None,
- error: "client public key denied by policy".to_owned(),
- },
- ));
- };
- let draft = proposal
- .into_connection_draft(self.codec.signer.user_identity())
- .with_requested_permissions(requested_permissions)
- .with_relays(self.relays.clone())
- .with_approval_requirement(approval_requirement);
- let connection = self.backend.register_connection(draft)?;
- if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired {
- let granted_permissions = self
- .policy
- .auto_granted_permissions(&connection.requested_permissions);
- let _ = self
- .backend
- .set_granted_permissions(&connection.connection_id, granted_permissions)?;
- }
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- connect_response_outcome(&connection, secret),
- None,
- ))
- }
- }
- }
-
- fn handle_base_request(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- let connection = match self.lookup_connection(client_public_key)? {
- Ok(connection) => connection,
- Err(response) => {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
- }
- };
-
- match self.evaluate_request_with_policy(&connection, request_message)? {
- RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- Response::Error {
- result: None,
- error: reason,
- },
- ),
- Some(audit),
- ))
- }
- RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
- let evaluation = *evaluation;
- let audit = evaluation.audit.clone();
- let response_hint = match &evaluation.action {
- RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
- Some(response_hint.clone())
- }
- _ => None,
- };
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- handled_request_for_action(&evaluation.connection, evaluation.action, || {
- Ok(response_from_hint(
- &evaluation.connection,
- response_hint.expect("allowed action carries response hint"),
- ))
- })?,
- Some(audit),
- ))
- }
- }
- }
-
- fn handle_sign_event_request(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_message: RequestMessage,
- unsigned_event: ConnectUnsignedEvent,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- let connection = match self.lookup_connection(client_public_key)? {
- Ok(connection) => connection,
- Err(response) => {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
- }
- };
-
- match self.evaluate_request_with_policy(&connection, request_message)? {
- RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- Response::Error {
- result: None,
- error: reason,
- },
- ),
- Some(audit),
- ))
- }
- RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
- let evaluation = *evaluation;
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- self.handled_request_for_authorized_action(
- &evaluation.connection,
- evaluation.action,
- || Ok(self.codec.sign_event_response_value(unsigned_event)),
- )?,
- Some(evaluation.audit),
- ))
- }
- }
- }
-
- fn handle_crypto_request(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
- let request = request_message.request.clone();
- let connection = match self.lookup_connection(client_public_key)? {
- Ok(connection) => connection,
- Err(response) => {
- return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
- }
- };
-
- match self.evaluate_request_with_policy(&connection, request_message)? {
- RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- Response::Error {
- result: None,
- error: reason,
- },
- ),
- Some(audit),
- ))
- }
- RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
- let evaluation = *evaluation;
- Ok(RadrootsNostrSignerHandledRequestOutcome::new(
- self.handled_request_for_authorized_action(
- &evaluation.connection,
- evaluation.action,
- || Ok(self.codec.crypto_response_value(request)),
- )?,
- Some(evaluation.audit),
- ))
- }
- }
- }
-
- fn handled_request_for_evaluation(
- &self,
- request_message: RequestMessage,
- evaluation: RadrootsNostrSignerRequestEvaluation,
- ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError> {
- match request_message.request.clone() {
- Request::SignEvent(unsigned_event) => self.handled_request_for_authorized_action(
- &evaluation.connection,
- evaluation.action,
- || Ok(self.codec.sign_event_response_value(unsigned_event)),
- ),
- Request::Nip04Encrypt { .. }
- | Request::Nip04Decrypt { .. }
- | Request::Nip44Encrypt { .. }
- | Request::Nip44Decrypt { .. } => self.handled_request_for_authorized_action(
- &evaluation.connection,
- evaluation.action,
- || Ok(self.codec.crypto_response_value(request_message.request)),
- ),
- Request::GetPublicKey
- | Request::GetSessionCapability
- | Request::Ping
- | Request::SwitchRelays => {
- let response_hint = match &evaluation.action {
- RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
- Some(response_hint.clone())
- }
- _ => None,
- };
- self.handled_request_for_authorized_action(
- &evaluation.connection,
- evaluation.action,
- || {
- Ok(response_from_hint(
- &evaluation.connection,
- response_hint.expect("allowed action carries response hint"),
- ))
- },
- )
- }
- other => Ok(RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(evaluation.connection.connection_id.clone()),
- Response::Error {
- result: None,
- error: format!("method `{}` is not implemented yet", other.method()),
- },
- )),
- }
- }
-
- fn handled_request_for_authorized_action<F>(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- action: RadrootsNostrSignerRequestAction,
- on_allowed: F,
- ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError>
- where
- F: FnOnce() -> Result<Response, RadrootsNostrSignerError>,
- {
- handled_request_for_action(connection, action, on_allowed)
- }
-
- fn evaluate_request_with_policy(
- &self,
- connection: &RadrootsNostrSignerConnectionRecord,
- request_message: RequestMessage,
- ) -> Result<RadrootsNostrSignerPreparedRequestEvaluation, RadrootsNostrSignerError> {
- if let Some(reason) =
- self.policy
- .prepare_request(&self.backend, connection, &request_message)?
- {
- let audit = self.backend.record_request(
- &connection.connection_id,
- &request_message.id,
- request_message.request.method(),
- RadrootsNostrSignerRequestDecision::Denied,
- Some(reason.clone()),
- )?;
- return Ok(RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit });
- }
-
- Ok(RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(
- Box::new(
- self.backend
- .evaluate_request(&connection.connection_id, request_message)?,
- ),
- ))
- }
-
- fn lookup_connection(
- &self,
- client_public_key: RadrootsNostrPublicKey,
- ) -> Result<Result<RadrootsNostrSignerConnectionRecord, Response>, RadrootsNostrSignerError>
- {
- Ok(
- match self.backend.lookup_session(&client_public_key, None)? {
- RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(*connection),
- RadrootsNostrSignerSessionLookup::None => Err(Response::Error {
- result: None,
- error: "unauthorized".to_owned(),
- }),
- RadrootsNostrSignerSessionLookup::Ambiguous(_) => Err(Response::Error {
- result: None,
- error: "ambiguous client sessions".to_owned(),
- }),
- },
- )
- }
-}
-
-impl RadrootsNostrSignerHandledRequest {
- pub fn respond(response: Response) -> Self {
- Self::respond_for_connection(None, response)
- }
-
- pub fn respond_for_connection(
- connection_id: Option<RadrootsNostrSignerConnectionId>,
- response: Response,
- ) -> Self {
- Self::Respond {
- response: Box::new(response),
- connection_id,
- consume_connect_secret_for: None,
- }
- }
-
- pub fn into_publish_parts(
- self,
- ) -> Option<(
- Response,
- Option<RadrootsNostrSignerConnectionId>,
- Option<RadrootsNostrSignerConnectionId>,
- )> {
- match self {
- Self::Respond {
- response,
- connection_id,
- consume_connect_secret_for,
- } => Some((*response, connection_id, consume_connect_secret_for)),
- Self::Ignore => None,
- }
- }
-}
-
-impl RadrootsNostrSignerHandledRequestOutcome {
- pub fn new(
- handled_request: RadrootsNostrSignerHandledRequest,
- audit: Option<RadrootsNostrSignerRequestAuditRecord>,
- ) -> Self {
- Self {
- handled_request,
- audit,
- }
- }
-
- pub fn respond(response: Response) -> Self {
- Self::new(RadrootsNostrSignerHandledRequest::respond(response), None)
- }
-
- pub fn ignore() -> Self {
- Self::new(RadrootsNostrSignerHandledRequest::Ignore, None)
- }
-}
-
-pub fn connect_response_outcome(
- connection: &RadrootsNostrSignerConnectionRecord,
- secret: Option<String>,
-) -> RadrootsNostrSignerHandledRequest {
- let consume_connect_secret_for = secret.as_ref().map(|_| connection.connection_id.clone());
- RadrootsNostrSignerHandledRequest::Respond {
- response: Box::new(match secret {
- Some(secret) => Response::ConnectSecretEcho(secret),
- None => Response::ConnectAcknowledged,
- }),
- connection_id: Some(connection.connection_id.clone()),
- consume_connect_secret_for,
- }
-}
-
-pub fn response_from_hint(
- connection: &RadrootsNostrSignerConnectionRecord,
- hint: RadrootsNostrSignerRequestResponseHint,
-) -> Response {
- match hint {
- RadrootsNostrSignerRequestResponseHint::Pong => Response::Pong,
- RadrootsNostrSignerRequestResponseHint::UserPublicKey(public_key) => {
- Response::UserPublicKey(public_key)
- }
- RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => {
- Response::RemoteSessionCapability(capability)
- }
- RadrootsNostrSignerRequestResponseHint::RelayList(relays) => match connection
- .relays
- .iter()
- .map(|relay| radroots_nostr_connect::uri::RelayUrl::parse(&relay.to_string()))
- .collect::<Result<Vec<_>, _>>()
- {
- Ok(connection_relays) if relays == connection_relays => Response::RelayList(relays),
- Ok(connection_relays) => Response::RelayList(connection_relays),
- Err(error) => Response::Error {
- result: None,
- error: format!("invalid connection relay state: {error}"),
- },
- },
- RadrootsNostrSignerRequestResponseHint::None => Response::Error {
- result: None,
- error: "request evaluation did not provide a response hint".to_owned(),
- },
- }
-}
-
-fn nostr_public_key(
- public_key: radroots_identity::PublicKey,
-) -> Result<RadrootsNostrPublicKey, RadrootsNostrSignerError> {
- radroots_nostr::key::public_key_to_nostr(public_key).map_err(Into::into)
-}
-
-pub fn handled_request_for_action<F>(
- connection: &RadrootsNostrSignerConnectionRecord,
- action: RadrootsNostrSignerRequestAction,
- on_allowed: F,
-) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError>
-where
- F: FnOnce() -> Result<Response, RadrootsNostrSignerError>,
-{
- Ok(match action {
- RadrootsNostrSignerRequestAction::Denied { reason } => {
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- Response::Error {
- result: None,
- error: reason,
- },
- )
- }
- RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => {
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- Response::AuthUrl(auth_challenge.auth_url),
- )
- }
- RadrootsNostrSignerRequestAction::Allowed { .. } => {
- RadrootsNostrSignerHandledRequest::respond_for_connection(
- Some(connection.connection_id.clone()),
- on_allowed()?,
- )
- }
- })
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::{
- RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
- RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Handler,
- RadrootsNostrSignerNip46Policy, RadrootsNostrSignerNip46Signer,
- };
- use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
- use crate::signer::backend::{RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend};
- use crate::signer::error::RadrootsNostrSignerError;
- use crate::signer::evaluation::{
- RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint,
- };
- use crate::signer::manager::RadrootsNostrSignerManager;
- use crate::signer::model::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge,
- RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest,
- RadrootsNostrSignerStoreState,
- };
- use crate::signer::store::RadrootsNostrSignerStore;
- use crate::signer::test_support::{
- fixture_alice_identity, fixture_carol_public_key, primary_relay,
- };
- use nostr::PublicKey as RadrootsNostrPublicKey;
- use nostr::{JsonUtil, Keys, SecretKey, Timestamp, UnsignedEvent};
- use radroots_identity::PublicKey as IdentityPublicKey;
- use radroots_nostr::event::Event as RadrootsNostrEvent;
- use radroots_nostr::event::GenericBuilder;
- use radroots_nostr::event::Kind as RadrootsNostrKind;
- use radroots_nostr::tag::TagKind as RadrootsNostrTagKind;
- use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl;
- use radroots_nostr_connect::{
- Method, Permission, Request, Response,
- message::{
- RPC_KIND, RemoteSessionCapability, RequestMessage,
- UnsignedEvent as ConnectUnsignedEvent,
- },
- permission::Permissions,
- };
- use std::sync::{
- Arc, RwLock,
- atomic::{AtomicBool, Ordering},
- };
-
- #[derive(Clone)]
- struct TestSigner {
- signer_identity: Keys,
- user_identity: Keys,
- sign_events: bool,
- fail_crypto: bool,
- }
-
- #[derive(Clone)]
- struct TestPolicy {
- connect_decision: RadrootsNostrSignerNip46ConnectDecision,
- rate_limit_reason: Option<&'static str>,
- approval_requirement: Option<RadrootsNostrSignerApprovalRequirement>,
- prepare_denial: Option<&'static str>,
- }
-
- #[derive(Clone, Default)]
- struct ToggleSaveStore {
- state: Arc<RwLock<RadrootsNostrSignerStoreState>>,
- fail_saves: Arc<AtomicBool>,
- }
-
- impl RadrootsNostrSignerStore for ToggleSaveStore {
- fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
- self.state
- .read()
- .map(|state| state.clone())
- .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into()))
- }
-
- fn save(
- &self,
- state: &RadrootsNostrSignerStoreState,
- ) -> Result<(), RadrootsNostrSignerError> {
- if self.fail_saves.load(Ordering::SeqCst) {
- return Err(RadrootsNostrSignerError::Store(
- "test store save failure".into(),
- ));
- }
- self.state
- .write()
- .map(|mut stored| *stored = state.clone())
- .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into()))
- }
- }
-
- impl Default for TestPolicy {
- fn default() -> Self {
- Self {
- connect_decision: RadrootsNostrSignerNip46ConnectDecision::Allow,
- rate_limit_reason: None,
- approval_requirement: Some(RadrootsNostrSignerApprovalRequirement::NotRequired),
- prepare_denial: None,
- }
- }
- }
-
- impl RadrootsNostrSignerNip46Signer for TestSigner {
- fn signer_public_key_hex(&self) -> String {
- self.signer_identity.public_key().to_hex()
- }
-
- fn decrypt_request(
- &self,
- _client_public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- Ok(ciphertext.to_owned())
- }
-
- fn encrypt_response(
- &self,
- _client_public_key: &RadrootsNostrPublicKey,
- payload: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- Ok(payload.to_owned())
- }
-
- fn user_identity(&self) -> PublicIdentity {
- public_identity_from_keys(&self.user_identity)
- }
-
- fn sign_user_event(
- &self,
- unsigned_event: UnsignedEvent,
- ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError> {
- if self.sign_events {
- return unsigned_event
- .sign_with_keys(&self.user_identity)
- .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string()));
- }
- Err(RadrootsNostrSignerError::Sign(
- "test signer does not sign events".to_owned(),
- ))
- }
-
- fn nip04_encrypt(
- &self,
- _public_key: &RadrootsNostrPublicKey,
- plaintext: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- if self.fail_crypto {
- return Err(RadrootsNostrSignerError::Sign(
- "test crypto failure".to_owned(),
- ));
- }
- Ok(plaintext.to_owned())
- }
-
- fn nip04_decrypt(
- &self,
- _public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- if self.fail_crypto {
- return Err(RadrootsNostrSignerError::Sign(
- "test crypto failure".to_owned(),
- ));
- }
- Ok(ciphertext.to_owned())
- }
-
- fn nip44_encrypt(
- &self,
- _public_key: &RadrootsNostrPublicKey,
- plaintext: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- if self.fail_crypto {
- return Err(RadrootsNostrSignerError::Sign(
- "test crypto failure".to_owned(),
- ));
- }
- Ok(plaintext.to_owned())
- }
-
- fn nip44_decrypt(
- &self,
- _public_key: &RadrootsNostrPublicKey,
- ciphertext: &str,
- ) -> Result<String, RadrootsNostrSignerError> {
- if self.fail_crypto {
- return Err(RadrootsNostrSignerError::Sign(
- "test crypto failure".to_owned(),
- ));
- }
- Ok(ciphertext.to_owned())
- }
- }
-
- impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for TestPolicy {
- fn connect_decision(
- &self,
- _client_public_key: &RadrootsNostrPublicKey,
- ) -> RadrootsNostrSignerNip46ConnectDecision {
- self.connect_decision
- }
-
- fn connect_rate_limit_denied_reason(
- &self,
- _client_public_key: &RadrootsNostrPublicKey,
- ) -> Option<String> {
- self.rate_limit_reason.map(ToOwned::to_owned)
- }
-
- fn approval_requirement_for_client(
- &self,
- _client_public_key: &RadrootsNostrPublicKey,
- ) -> Option<RadrootsNostrSignerApprovalRequirement> {
- self.approval_requirement
- }
-
- fn filtered_requested_permissions(
- &self,
- requested_permissions: &Permissions,
- ) -> Permissions {
- requested_permissions.clone()
- }
-
- fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions {
- requested_permissions.clone()
- }
-
- fn prepare_request(
- &self,
- _backend: &B,
- _connection: &crate::signer::model::RadrootsNostrSignerConnectionRecord,
- _request_message: &RequestMessage,
- ) -> Result<Option<String>, RadrootsNostrSignerError> {
- Ok(self.prepare_denial.map(ToOwned::to_owned))
- }
- }
-
- fn test_signer() -> TestSigner {
- test_signer_with_options(false, false)
- }
-
- fn keys_from_secret(secret_key_hex: &str) -> Keys {
- Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key"))
- }
-
- fn public_identity_from_keys(keys: &Keys) -> PublicIdentity {
- PublicIdentity::new(keys.public_key()).expect("identity public key")
- }
-
- fn connect_public_key(public_key: RadrootsNostrPublicKey) -> IdentityPublicKey {
- radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key")
- }
-
- fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl {
- ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay")
- }
-
- fn test_signer_with_options(sign_events: bool, fail_crypto: bool) -> TestSigner {
- TestSigner {
- signer_identity: keys_from_secret(
- "1111111111111111111111111111111111111111111111111111111111111111",
- ),
- user_identity: keys_from_secret(
- "2222222222222222222222222222222222222222222222222222222222222222",
- ),
- sign_events,
- fail_crypto,
- }
- }
-
- fn embedded_backend() -> RadrootsNostrEmbeddedSignerBackend {
- RadrootsNostrEmbeddedSignerBackend::new(
- crate::signer::manager::RadrootsNostrSignerManager::new_in_memory(),
- test_signer().signer_identity.clone(),
- )
- .expect("embedded backend")
- }
-
- fn handler_with_backend(
- backend: RadrootsNostrEmbeddedSignerBackend,
- ) -> RadrootsNostrSignerNip46Handler<RadrootsNostrEmbeddedSignerBackend, TestPolicy, TestSigner>
- {
- handler_with_policy(backend, TestPolicy::default())
- }
-
- fn handler_with_policy(
- backend: RadrootsNostrEmbeddedSignerBackend,
- policy: TestPolicy,
- ) -> RadrootsNostrSignerNip46Handler<RadrootsNostrEmbeddedSignerBackend, TestPolicy, TestSigner>
- {
- RadrootsNostrSignerNip46Handler::new(backend, policy, vec![primary_relay()], test_signer())
- }
-
- fn connect_request(secret: Option<&str>) -> RequestMessage {
- connect_request_with_permissions(secret, vec![Permission::new(Method::Nip04Encrypt)])
- }
-
- fn connect_request_with_permissions(
- secret: Option<&str>,
- permissions: Vec<Permission>,
- ) -> RequestMessage {
- let signer_public_key = test_signer().signer_identity.public_key();
- RequestMessage::new(
- "req-connect",
- Request::Connect {
- remote_signer_public_key: connect_public_key(signer_public_key),
- secret: secret.map(ToOwned::to_owned),
- requested_permissions: permissions.into(),
- client_metadata: None,
- },
- )
- }
-
- fn all_runtime_permissions() -> Vec<Permission> {
- vec![
- Permission::new(Method::SignEvent),
- Permission::new(Method::Nip04Encrypt),
- Permission::new(Method::Nip04Decrypt),
- Permission::new(Method::Nip44Encrypt),
- Permission::new(Method::Nip44Decrypt),
- Permission::new(Method::SwitchRelays),
- ]
- }
-
- fn request_message(id: &str, request: Request) -> RequestMessage {
- RequestMessage::new(id, request)
- }
-
- fn unsigned_user_event(kind: u16) -> UnsignedEvent {
- serde_json::from_value(serde_json::json!({
- "pubkey": test_signer().user_identity.public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": kind,
- "tags": [],
- "content": "hello",
- }))
- .expect("unsigned event")
- }
-
- fn connect_unsigned_event(kind: u16) -> ConnectUnsignedEvent {
- let event = unsigned_user_event(kind);
- ConnectUnsignedEvent::from_json(&event.as_json()).expect("connect unsigned event")
- }
-
- fn registered_connection(
- backend: &RadrootsNostrEmbeddedSignerBackend,
- client_public_key: &RadrootsNostrPublicKey,
- ) -> RadrootsNostrSignerConnectionRecord {
- backend
- .find_connections_by_client_public_key(client_public_key)
- .expect("connections")
- .into_iter()
- .next()
- .expect("connection")
- }
-
- fn connect_with_permissions(
- handler: &RadrootsNostrSignerNip46Handler<
- RadrootsNostrEmbeddedSignerBackend,
- TestPolicy,
- TestSigner,
- >,
- client_public_key: RadrootsNostrPublicKey,
- permissions: Vec<Permission>,
- ) {
- let outcome = handler
- .handle_request(
- client_public_key,
- connect_request_with_permissions(None, permissions),
- )
- .expect("connect");
- assert!(matches!(
- outcome.handled_request,
- RadrootsNostrSignerHandledRequest::Respond { .. }
- ));
- }
-
- fn response_from_outcome(outcome: RadrootsNostrSignerHandledRequestOutcome) -> Response {
- match outcome.handled_request {
- RadrootsNostrSignerHandledRequest::Respond { response, .. } => *response,
- other => panic!("unexpected handled request: {other:?}"),
- }
- }
-
- #[test]
- fn codec_and_handler_facades_cover_rpc_event_surface() {
- let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer());
- let _ = codec.filter().expect("codec filter");
- let client_public_key = fixture_carol_public_key();
- let request = request_message("req-parse", Request::Ping);
- let raw = serde_json::to_string(&request).expect("serialize request");
- let event = GenericBuilder::new(RadrootsNostrKind::Custom(RPC_KIND), raw)
- .sign_with_keys(&Keys::generate())
- .expect("sign request event");
-
- let parsed = codec.parse_request_event(&event).expect("parse request");
- assert_eq!(parsed, request);
-
- let response_builder = codec
- .build_response_event(client_public_key, "req-parse", Response::Pong)
- .expect("response builder");
- let response_event = response_builder
- .sign_with_keys(&Keys::generate())
- .expect("sign response event");
- assert_eq!(response_event.kind, RadrootsNostrKind::Custom(RPC_KIND));
- assert!(response_event.tags.iter().any(|tag| {
- tag.kind() == RadrootsNostrTagKind::p()
- && tag.content() == Some(client_public_key.to_hex().as_str())
- }));
-
- let handler = handler_with_backend(embedded_backend());
- let _ = handler.filter().expect("handler filter");
- assert_eq!(
- handler.parse_request_event(&event).expect("handler parse"),
- request
- );
- let handler_event = handler
- .build_response_event(
- client_public_key,
- "req-handler",
- Response::ConnectAcknowledged,
- )
- .expect("handler response")
- .sign_with_keys(&Keys::generate())
- .expect("sign handler response event");
- assert_eq!(handler_event.kind, RadrootsNostrKind::Custom(RPC_KIND));
- }
-
- #[test]
- fn codec_crypto_and_signing_responses_cover_method_matrix() {
- let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer());
- let client_public_key = fixture_carol_public_key();
-
- assert_eq!(
- codec
- .crypto_response(Request::Nip04Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain".to_owned(),
- })
- .expect("nip04 encrypt"),
- Response::Nip04Encrypt("plain".to_owned())
- );
- assert_eq!(
- codec
- .crypto_response(Request::Nip04Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher".to_owned(),
- })
- .expect("nip04 decrypt"),
- Response::Nip04Decrypt("cipher".to_owned())
- );
- assert_eq!(
- codec
- .crypto_response(Request::Nip44Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain44".to_owned(),
- })
- .expect("nip44 encrypt"),
- Response::Nip44Encrypt("plain44".to_owned())
- );
- assert_eq!(
- codec
- .crypto_response(Request::Nip44Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher44".to_owned(),
- })
- .expect("nip44 decrypt"),
- Response::Nip44Decrypt("cipher44".to_owned())
- );
-
- let non_crypto = codec
- .crypto_response(Request::Ping)
- .expect("non crypto response");
- assert!(matches!(non_crypto, Response::Error { .. }));
-
- let failing_codec =
- super::RadrootsNostrSignerNip46Codec::new(test_signer_with_options(false, true));
- for request in [
- Request::Nip04Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain".to_owned(),
- },
- Request::Nip04Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher".to_owned(),
- },
- Request::Nip44Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain44".to_owned(),
- },
- Request::Nip44Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher44".to_owned(),
- },
- ] {
- assert!(matches!(
- failing_codec
- .crypto_response(request)
- .expect("failing crypto response"),
- Response::Error { .. }
- ));
- }
-
- let signing = codec
- .sign_event_response(unsigned_user_event(1))
- .expect("signing response");
- match signing {
- Response::Error { error, .. } => {
- assert!(error.contains("failed to sign event"));
- }
- other => panic!("unexpected sign response: {other:?}"),
- }
-
- let signed =
- super::RadrootsNostrSignerNip46Codec::new(test_signer_with_options(true, false))
- .sign_event_response(unsigned_user_event(1))
- .expect("signed response");
- assert!(matches!(signed, Response::SignedEvent(_)));
- }
-
- #[test]
- fn handler_connect_policy_paths_cover_registration_branches() {
- let client_public_key = fixture_carol_public_key();
-
- let rate_limited = handler_with_policy(
- embedded_backend(),
- TestPolicy {
- rate_limit_reason: Some("slow down"),
- ..TestPolicy::default()
- },
- )
- .handle_request(client_public_key, connect_request(None))
- .expect("rate limit outcome");
- assert_eq!(
- response_from_outcome(rate_limited),
- Response::Error {
- result: None,
- error: "slow down".to_owned(),
- }
- );
-
- let denied_registration = handler_with_policy(
- embedded_backend(),
- TestPolicy {
- approval_requirement: None,
- ..TestPolicy::default()
- },
- )
- .handle_request(client_public_key, connect_request(None))
- .expect("registration denial");
- assert_eq!(
- response_from_outcome(denied_registration),
- Response::Error {
- result: None,
- error: "client public key denied by policy".to_owned(),
- }
- );
-
- let approval_backend = embedded_backend();
- let approval_handler = handler_with_policy(
- approval_backend.clone(),
- TestPolicy {
- approval_requirement: Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
- ..TestPolicy::default()
- },
- );
- let _ = approval_handler
- .handle_request(client_public_key, connect_request(None))
- .expect("approval connect");
- let approval_connection = registered_connection(&approval_backend, &client_public_key);
- assert_eq!(
- approval_connection.approval_requirement,
- RadrootsNostrSignerApprovalRequirement::ExplicitUser
- );
- }
-
- #[test]
- fn handler_connect_existing_connection_paths_cover_policy_edges() {
- let client_public_key = fixture_carol_public_key();
- let secret = "connect-secret";
- let existing_backend = embedded_backend();
- let existing_handler = handler_with_backend(existing_backend.clone());
-
- let first = existing_handler
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("initial connect");
- assert_eq!(
- response_from_outcome(first),
- Response::ConnectSecretEcho(secret.to_owned())
- );
- let existing = existing_handler
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("existing connect by secret");
- assert_eq!(
- response_from_outcome(existing),
- Response::ConnectSecretEcho(secret.to_owned())
- );
-
- let denied_backend = embedded_backend();
- let denied_handler = handler_with_backend(denied_backend.clone());
- let _ = denied_handler
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("denied seed connect");
- let denying_handler = handler_with_policy(
- denied_backend,
- TestPolicy {
- connect_decision: RadrootsNostrSignerNip46ConnectDecision::Deny,
- ..TestPolicy::default()
- },
- );
- let denied = denying_handler
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("existing connect denied");
- assert_eq!(
- response_from_outcome(denied),
- Response::Error {
- result: None,
- error: "client public key denied by policy".to_owned(),
- }
- );
- }
-
- #[test]
- fn handler_request_paths_cover_base_sign_crypto_denied_and_challenged() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let client_public_key = fixture_carol_public_key();
- connect_with_permissions(&handler, client_public_key, all_runtime_permissions());
-
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message("req-pubkey", Request::GetPublicKey),
- )
- .expect("pubkey")
- ),
- Response::UserPublicKey(_)
- ));
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message("req-capability", Request::GetSessionCapability,),
- )
- .expect("capability")
- ),
- Response::RemoteSessionCapability(_)
- ));
- assert_eq!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message("req-relays", Request::SwitchRelays),
- )
- .expect("relays")
- ),
- Response::RelayList(vec![connect_relay(primary_relay())])
- );
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message("req-sign", Request::SignEvent(connect_unsigned_event(1)),),
- )
- .expect("sign")
- ),
- Response::Error { .. }
- ));
- assert_eq!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message(
- "req-nip04-decrypt",
- Request::Nip04Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher".to_owned(),
- },
- ),
- )
- .expect("nip04 decrypt")
- ),
- Response::Nip04Decrypt("cipher".to_owned())
- );
- assert_eq!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message(
- "req-nip44-encrypt",
- Request::Nip44Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain".to_owned(),
- },
- ),
- )
- .expect("nip44 encrypt")
- ),
- Response::Nip44Encrypt("plain".to_owned())
- );
-
- let unimplemented = handler
- .handle_request(
- client_public_key,
- request_message(
- "req-custom",
- Request::Custom {
- method: Method::custom("publish_note").expect("valid custom NIP-46 method"),
- params: vec![],
- },
- ),
- )
- .expect("custom");
- assert!(matches!(
- response_from_outcome(unimplemented),
- Response::Error { .. }
- ));
-
- let limited_backend = embedded_backend();
- let limited_handler = handler_with_backend(limited_backend);
- connect_with_permissions(
- &limited_handler,
- client_public_key,
- vec![Permission::new(Method::Nip04Encrypt)],
- );
- let denied_crypto = limited_handler
- .handle_request(
- client_public_key,
- request_message(
- "req-denied",
- Request::Nip04Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher".to_owned(),
- },
- ),
- )
- .expect("denied crypto");
- assert!(matches!(
- response_from_outcome(denied_crypto),
- Response::Error { .. }
- ));
-
- let denied_backend = embedded_backend();
- let open_handler = handler_with_backend(denied_backend.clone());
- connect_with_permissions(&open_handler, client_public_key, all_runtime_permissions());
- let denying_handler = handler_with_policy(
- denied_backend,
- TestPolicy {
- prepare_denial: Some("policy blocked"),
- ..TestPolicy::default()
- },
- );
- let denied_base = denying_handler
- .handle_request(
- client_public_key,
- request_message("req-policy-denied", Request::Ping),
- )
- .expect("policy denied");
- assert!(matches!(
- response_from_outcome(denied_base),
- Response::Error { .. }
- ));
- let denied_sign = denying_handler
- .handle_request(
- client_public_key,
- request_message(
- "req-policy-denied-sign",
- Request::SignEvent(connect_unsigned_event(1)),
- ),
- )
- .expect("policy denied sign");
- assert!(matches!(
- response_from_outcome(denied_sign),
- Response::Error { .. }
- ));
- let denied_crypto = denying_handler
- .handle_request(
- client_public_key,
- request_message(
- "req-policy-denied-crypto",
- Request::Nip44Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain".to_owned(),
- },
- ),
- )
- .expect("policy denied crypto");
- assert!(matches!(
- response_from_outcome(denied_crypto),
- Response::Error { .. }
- ));
-
- let challenge_backend = embedded_backend();
- let challenge_handler = handler_with_backend(challenge_backend.clone());
- connect_with_permissions(
- &challenge_handler,
- client_public_key,
- all_runtime_permissions(),
- );
- let challenged = registered_connection(&challenge_backend, &client_public_key);
- challenge_backend
- .manager()
- .require_auth_challenge(&challenged.connection_id, "https://example.test/auth")
- .expect("require challenge");
- let auth_url = challenge_handler
- .handle_request(
- client_public_key,
- request_message("req-challenge", Request::Ping),
- )
- .expect("challenge");
- assert_eq!(
- response_from_outcome(auth_url),
- Response::AuthUrl("https://example.test/auth".to_owned())
- );
- }
-
- #[test]
- fn policy_denial_propagates_audit_persistence_failures() {
- let store = ToggleSaveStore::default();
- let manager = RadrootsNostrSignerManager::new(Arc::new(store.clone()))
- .expect("manager with toggle store");
- let backend =
- RadrootsNostrEmbeddedSignerBackend::new(manager, test_signer().signer_identity.clone())
- .expect("embedded backend");
- let client_public_key = fixture_carol_public_key();
- connect_with_permissions(
- &handler_with_backend(backend.clone()),
- client_public_key,
- Vec::new(),
- );
- store.fail_saves.store(true, Ordering::SeqCst);
-
- let handler = handler_with_policy(
- backend,
- TestPolicy {
- prepare_denial: Some("policy blocked"),
- ..TestPolicy::default()
- },
- );
- let error = handler
- .handle_request(
- client_public_key,
- request_message("req-audit-save", Request::Ping),
- )
- .expect_err("audit persistence failure");
- assert!(error.to_string().contains("test store save failure"));
- }
-
- #[test]
- fn handler_rejects_unauthorized_base_sign_and_crypto_requests() {
- let handler = handler_with_backend(embedded_backend());
- let client_public_key = fixture_carol_public_key();
-
- for request in [
- Request::Ping,
- Request::SignEvent(connect_unsigned_event(1)),
- Request::Nip04Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "cipher".to_owned(),
- },
- ] {
- let outcome = handler
- .handle_request(
- client_public_key,
- request_message("req-unauthorized", request),
- )
- .expect("unauthorized request");
- assert_eq!(
- response_from_outcome(outcome),
- Response::Error {
- result: None,
- error: "unauthorized".to_owned(),
- }
- );
- }
- }
-
- #[test]
- fn handler_allowed_sign_and_crypto_requests_execute_codec_paths() {
- let backend = embedded_backend();
- let handler = RadrootsNostrSignerNip46Handler::new(
- backend,
- TestPolicy::default(),
- vec![primary_relay()],
- test_signer_with_options(true, false),
- );
- let client_public_key = fixture_carol_public_key();
- connect_with_permissions(
- &handler,
- client_public_key,
- vec![
- Permission::with_parameter(Method::SignEvent, "kind:1"),
- Permission::new(Method::Nip04Encrypt),
- ],
- );
-
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message(
- "req-allowed-sign",
- Request::SignEvent(connect_unsigned_event(1)),
- ),
- )
- .expect("allowed sign")
- ),
- Response::SignedEvent(_)
- ));
- assert_eq!(
- response_from_outcome(
- handler
- .handle_request(
- client_public_key,
- request_message(
- "req-allowed-nip04-encrypt",
- Request::Nip04Encrypt {
- public_key: connect_public_key(client_public_key),
- plaintext: "plain".to_owned(),
- },
- ),
- )
- .expect("allowed nip04 encrypt")
- ),
- Response::Nip04Encrypt("plain".to_owned())
- );
- }
-
- #[test]
- fn handler_authorized_evaluation_facade_covers_request_variants() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let client_public_key = fixture_carol_public_key();
- connect_with_permissions(&handler, client_public_key, all_runtime_permissions());
- let connection = registered_connection(&backend, &client_public_key);
-
- let base = request_message("req-eval-ping", Request::Ping);
- let base_eval = backend
- .evaluate_request(&connection.connection_id, base.clone())
- .expect("base evaluation");
- assert_eq!(
- response_from_outcome(
- handler
- .handle_authorized_request_evaluation(base, base_eval)
- .expect("base authorized")
- ),
- Response::Pong
- );
- let mut denied_base_eval = backend
- .evaluate_request(
- &connection.connection_id,
- request_message("req-eval-denied-ping", Request::Ping),
- )
- .expect("denied base evaluation");
- denied_base_eval.action = RadrootsNostrSignerRequestAction::Denied {
- reason: "blocked".to_owned(),
- };
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_authorized_request_evaluation(
- request_message("req-eval-denied-ping", Request::Ping),
- denied_base_eval,
- )
- .expect("denied base authorized")
- ),
- Response::Error { .. }
- ));
-
- let crypto = request_message(
- "req-eval-crypto",
- Request::Nip44Decrypt {
- public_key: connect_public_key(client_public_key),
- ciphertext: "sealed".to_owned(),
- },
- );
- let crypto_eval = backend
- .evaluate_request(&connection.connection_id, crypto.clone())
- .expect("crypto evaluation");
- assert_eq!(
- response_from_outcome(
- handler
- .handle_authorized_request_evaluation(crypto, crypto_eval)
- .expect("crypto authorized")
- ),
- Response::Nip44Decrypt("sealed".to_owned())
- );
-
- let sign = request_message(
- "req-eval-sign",
- Request::SignEvent(connect_unsigned_event(1)),
- );
- let sign_eval = backend
- .evaluate_request(&connection.connection_id, sign.clone())
- .expect("sign evaluation");
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_authorized_request_evaluation(sign, sign_eval)
- .expect("sign authorized")
- ),
- Response::Error { .. }
- ));
-
- let custom = request_message(
- "req-eval-custom",
- Request::Custom {
- method: Method::custom("do_work").expect("valid custom NIP-46 method"),
- params: vec![],
- },
- );
- let custom_eval = backend
- .evaluate_request(&connection.connection_id, custom.clone())
- .expect("custom evaluation");
- assert!(matches!(
- response_from_outcome(
- handler
- .handle_authorized_request_evaluation(custom, custom_eval)
- .expect("custom authorized")
- ),
- Response::Error { .. }
- ));
- }
-
- #[test]
- fn standalone_response_helpers_cover_publish_parts_and_hints() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let client_public_key = fixture_carol_public_key();
- connect_with_permissions(&handler, client_public_key, all_runtime_permissions());
- let connection = registered_connection(&backend, &client_public_key);
-
- let parts = super::connect_response_outcome(&connection, Some("secret".to_owned()))
- .into_publish_parts()
- .expect("publish parts");
- assert_eq!(parts.0, Response::ConnectSecretEcho("secret".to_owned()));
- assert_eq!(parts.1, Some(connection.connection_id.clone()));
- assert_eq!(parts.2, Some(connection.connection_id.clone()));
- assert!(
- RadrootsNostrSignerHandledRequest::Ignore
- .into_publish_parts()
- .is_none()
- );
- assert!(
- RadrootsNostrSignerHandledRequest::respond(Response::Pong)
- .into_publish_parts()
- .is_some()
- );
- assert_eq!(
- response_from_outcome(RadrootsNostrSignerHandledRequestOutcome::respond(
- Response::Pong,
- )),
- Response::Pong
- );
-
- assert_eq!(
- super::response_from_hint(
- &connection,
- RadrootsNostrSignerRequestResponseHint::UserPublicKey(connect_public_key(
- client_public_key,
- )),
- ),
- Response::UserPublicKey(connect_public_key(client_public_key))
- );
- let capability = RemoteSessionCapability {
- user_public_key: connect_public_key(client_public_key),
- relays: vec![connect_relay(primary_relay())],
- permissions: all_runtime_permissions().into(),
- };
- assert_eq!(
- super::response_from_hint(
- &connection,
- RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability.clone(),),
- ),
- Response::RemoteSessionCapability(capability)
- );
- assert_eq!(
- super::response_from_hint(
- &connection,
- RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay(
- primary_relay(),
- )]),
- ),
- Response::RelayList(vec![connect_relay(primary_relay())])
- );
- assert_eq!(
- super::response_from_hint(
- &connection,
- RadrootsNostrSignerRequestResponseHint::RelayList(Vec::new()),
- ),
- Response::RelayList(vec![connect_relay(primary_relay())])
- );
- assert!(matches!(
- super::response_from_hint(&connection, RadrootsNostrSignerRequestResponseHint::None),
- Response::Error { .. }
- ));
-
- let denied = super::handled_request_for_action(
- &connection,
- RadrootsNostrSignerRequestAction::Denied {
- reason: "blocked".to_owned(),
- },
- || Ok(Response::Pong),
- )
- .expect("denied action");
- assert!(matches!(
- denied,
- RadrootsNostrSignerHandledRequest::Respond { .. }
- ));
-
- let allowed = super::handled_request_for_action(
- &connection,
- RadrootsNostrSignerRequestAction::Allowed {
- required_permission: None,
- response_hint: RadrootsNostrSignerRequestResponseHint::Pong,
- },
- || Ok(Response::Pong),
- )
- .expect("allowed action");
- assert!(matches!(
- allowed,
- RadrootsNostrSignerHandledRequest::Respond { .. }
- ));
-
- let challenged = super::handled_request_for_action(
- &connection,
- RadrootsNostrSignerRequestAction::Challenged {
- auth_challenge: RadrootsNostrSignerAuthChallenge::new(
- "https://example.test/auth",
- 1,
- )
- .expect("challenge"),
- pending_request: RadrootsNostrSignerPendingRequest::new(
- request_message("req-pending", Request::Ping),
- 1,
- )
- .expect("pending"),
- },
- || Ok(Response::Pong),
- )
- .expect("challenged action");
- assert!(matches!(
- challenged,
- RadrootsNostrSignerHandledRequest::Respond { .. }
- ));
- }
-
- #[test]
- fn handler_reports_ambiguous_client_sessions() {
- let backend = embedded_backend();
- let client_public_key = fixture_carol_public_key();
- backend
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- client_public_key,
- test_signer().user_identity(),
- ))
- .expect("first connection");
- backend
- .register_connection(RadrootsNostrSignerConnectionDraft::new(
- client_public_key,
- public_identity_from_keys(&keys_from_secret(
- "3333333333333333333333333333333333333333333333333333333333333333",
- )),
- ))
- .expect("second connection");
-
- let outcome = handler_with_backend(backend)
- .handle_request(
- client_public_key,
- request_message("req-ambiguous", Request::Ping),
- )
- .expect("ambiguous request");
- assert_eq!(
- response_from_outcome(outcome),
- Response::Error {
- result: None,
- error: "ambiguous client sessions".to_owned(),
- }
- );
- }
-
- #[test]
- fn handler_registers_connections_and_returns_audit_for_authorized_requests() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let client_public_key = fixture_carol_public_key();
-
- let connect = handler
- .handle_request(client_public_key, connect_request(None))
- .expect("connect outcome");
- assert!(connect.audit.is_none());
- match connect.handled_request {
- RadrootsNostrSignerHandledRequest::Respond { response, .. } => {
- assert_eq!(*response, Response::ConnectAcknowledged);
- }
- other => panic!("unexpected connect outcome: {other:?}"),
- }
-
- let ping = handler
- .handle_request(
- client_public_key,
- RequestMessage::new("req-ping", Request::Ping),
- )
- .expect("ping outcome");
- match ping.handled_request {
- RadrootsNostrSignerHandledRequest::Respond { response, .. } => {
- assert_eq!(*response, Response::Pong);
- }
- other => panic!("unexpected ping outcome: {other:?}"),
- }
- let audit = ping.audit.expect("audit");
- assert_eq!(audit.request_id.as_str(), "req-ping");
- assert_eq!(
- backend
- .find_connections_by_client_public_key(&client_public_key)
- .expect("connections")
- .len(),
- 1
- );
- }
-
- #[test]
- fn handler_ignores_reused_consumed_connect_secrets() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let client_public_key = fixture_carol_public_key();
- let secret = "connect-secret";
-
- let first = handler
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("first connect");
- assert!(first.audit.is_none());
-
- let connection = backend
- .find_connections_by_client_public_key(&client_public_key)
- .expect("connections")
- .into_iter()
- .next()
- .expect("connection");
- backend
- .mark_connect_secret_consumed(&connection.connection_id)
- .expect("consume secret");
-
- let reused = handler_with_backend(backend)
- .handle_request(client_public_key, connect_request(Some(secret)))
- .expect("reused outcome");
- assert_eq!(
- reused.handled_request,
- RadrootsNostrSignerHandledRequest::Ignore
- );
- }
-
- #[test]
- fn sign_event_response_rejects_wrong_user_pubkey() {
- let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer());
- let response = codec
- .sign_event_response(
- serde_json::from_value(serde_json::json!({
- "pubkey": fixture_alice_identity().public_key().to_hex(),
- "created_at": 1,
- "kind": 1,
- "tags": [],
- "content": "hello",
- }))
- .expect("unsigned event"),
- )
- .expect("response");
-
- assert_eq!(
- response,
- Response::Error {
- result: None,
- error: "sign_event pubkey does not match the managed user identity".to_owned(),
- }
- );
- }
-
- #[test]
- fn connect_decision_enum_covers_all_states() {
- assert_eq!(
- [
- RadrootsNostrSignerNip46ConnectDecision::Allow,
- RadrootsNostrSignerNip46ConnectDecision::RequireApproval,
- RadrootsNostrSignerNip46ConnectDecision::Deny,
- ]
- .len(),
- 3
- );
- }
-
- #[test]
- fn connect_request_keeps_requested_permissions() {
- let request = connect_request(None);
- assert_eq!(
- request.request,
- Request::Connect {
- remote_signer_public_key: connect_public_key(
- test_signer().signer_identity.public_key(),
- ),
- secret: None,
- requested_permissions: vec![Permission::new(Method::Nip04Encrypt,)].into(),
- client_metadata: None,
- }
- );
- }
-
- #[test]
- fn handler_registration_initializes_non_terminal_connection_state() {
- let backend = embedded_backend();
- let handler = handler_with_backend(backend.clone());
- let _ = handler
- .handle_request(fixture_carol_public_key(), connect_request(None))
- .expect("connect");
- let connection = backend
- .find_connections_by_client_public_key(&fixture_carol_public_key())
- .expect("connections")
- .into_iter()
- .next()
- .expect("connection");
- assert!(matches!(
- connection.auth_state,
- RadrootsNostrSignerAuthState::NotRequired
- | RadrootsNostrSignerAuthState::Pending
- | RadrootsNostrSignerAuthState::Authorized
- ));
- assert_eq!(
- connection.user_identity.id(),
- test_signer().user_identity().id()
- );
- }
-}
diff --git a/src/signer/test_fixtures.rs b/src/signer/test_fixtures.rs
@@ -1,107 +0,0 @@
-#![forbid(unsafe_code)]
-#![allow(dead_code)]
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct ApprovedFixtureIdentity {
- pub label: &'static str,
- pub username: &'static str,
- pub email: &'static str,
- pub secret_key_hex: &'static str,
- pub public_key_hex: &'static str,
- pub nsec: &'static str,
- pub npub: &'static str,
-}
-
-pub const APPROVED_FIXTURE_NAMESPACE: &str = "radroots-approved-fixture-v1";
-
-pub const FIXTURE_ALICE_LABEL: &str = "fixture_alice";
-pub const FIXTURE_ALICE_USERNAME: &str = "fixture_alice";
-pub const FIXTURE_ALICE_EMAIL: &str = "fixture_alice@fixtures.test";
-pub const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
- "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
-pub const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
- "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
-pub const FIXTURE_ALICE_NSEC: &str =
- "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz";
-pub const FIXTURE_ALICE_NPUB: &str =
- "npub1tp2ez55a5zatxxemrv0eses3ea05xhw2snuh3jy7azjqejn3q00s3vy5a9";
-pub const FIXTURE_ALICE: ApprovedFixtureIdentity = ApprovedFixtureIdentity {
- label: FIXTURE_ALICE_LABEL,
- username: FIXTURE_ALICE_USERNAME,
- email: FIXTURE_ALICE_EMAIL,
- secret_key_hex: FIXTURE_ALICE_SECRET_KEY_HEX,
- public_key_hex: FIXTURE_ALICE_PUBLIC_KEY_HEX,
- nsec: FIXTURE_ALICE_NSEC,
- npub: FIXTURE_ALICE_NPUB,
-};
-
-pub const FIXTURE_BOB_LABEL: &str = "fixture_bob";
-pub const FIXTURE_BOB_USERNAME: &str = "fixture_bob";
-pub const FIXTURE_BOB_EMAIL: &str = "fixture_bob@fixtures.test";
-pub const FIXTURE_BOB_SECRET_KEY_HEX: &str =
- "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
-pub const FIXTURE_BOB_PUBLIC_KEY_HEX: &str =
- "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
-pub const FIXTURE_BOB_NSEC: &str =
- "nsec1tyujayrg7ejrrvf0wqscldsjs89kksea0unu2htp7xnrlcdfdluqrjya9h";
-pub const FIXTURE_BOB_NPUB: &str =
- "npub1uqnxu08mp55gd7guw06ls68nhxp8xuf7tlxe0sypvcl42x9ykwhsd55k2g";
-pub const FIXTURE_BOB: ApprovedFixtureIdentity = ApprovedFixtureIdentity {
- label: FIXTURE_BOB_LABEL,
- username: FIXTURE_BOB_USERNAME,
- email: FIXTURE_BOB_EMAIL,
- secret_key_hex: FIXTURE_BOB_SECRET_KEY_HEX,
- public_key_hex: FIXTURE_BOB_PUBLIC_KEY_HEX,
- nsec: FIXTURE_BOB_NSEC,
- npub: FIXTURE_BOB_NPUB,
-};
-
-pub const FIXTURE_CAROL_LABEL: &str = "fixture_carol";
-pub const FIXTURE_CAROL_USERNAME: &str = "fixture_carol";
-pub const FIXTURE_CAROL_EMAIL: &str = "fixture_carol@fixtures.test";
-pub const FIXTURE_CAROL_SECRET_KEY_HEX: &str =
- "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509";
-pub const FIXTURE_CAROL_PUBLIC_KEY_HEX: &str =
- "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c";
-pub const FIXTURE_CAROL_NSEC: &str =
- "nsec1f4kzplwcdptaualltna9c4zh2xazalgjdc9kvsk6a9my67pdv5ys2pqkaj";
-pub const FIXTURE_CAROL_NPUB: &str =
- "npub1r9ft33558zvtemluludhdxwy5a66f5fmf2d6qztt5fh0q3yjhvwqgzmkl6";
-pub const FIXTURE_CAROL: ApprovedFixtureIdentity = ApprovedFixtureIdentity {
- label: FIXTURE_CAROL_LABEL,
- username: FIXTURE_CAROL_USERNAME,
- email: FIXTURE_CAROL_EMAIL,
- secret_key_hex: FIXTURE_CAROL_SECRET_KEY_HEX,
- public_key_hex: FIXTURE_CAROL_PUBLIC_KEY_HEX,
- nsec: FIXTURE_CAROL_NSEC,
- npub: FIXTURE_CAROL_NPUB,
-};
-
-pub const FIXTURE_DIEGO_LABEL: &str = "fixture_diego";
-pub const FIXTURE_DIEGO_USERNAME: &str = "fixture_diego";
-pub const FIXTURE_DIEGO_EMAIL: &str = "fixture_diego@fixtures.test";
-pub const FIXTURE_DIEGO_SECRET_KEY_HEX: &str =
- "9de56c1fdfce9ab00af85b3d7003c1d15cffb84cdf303c3a83c1a3fb1a2d0db0";
-pub const FIXTURE_DIEGO_PUBLIC_KEY_HEX: &str =
- "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4";
-pub const FIXTURE_DIEGO_NSEC: &str =
- "nsec1nhjkc87le6dtqzhctv7hqq7p69w0lwzvmucrcw5rcx3lkx3dpkcqkrmgp5";
-pub const FIXTURE_DIEGO_NPUB: &str =
- "npub1t5l2kmncadlyv757r94xx3tvn7hmj0ac3dc99wpj9xrs3zvj82jqwwcglm";
-pub const FIXTURE_DIEGO: ApprovedFixtureIdentity = ApprovedFixtureIdentity {
- label: FIXTURE_DIEGO_LABEL,
- username: FIXTURE_DIEGO_USERNAME,
- email: FIXTURE_DIEGO_EMAIL,
- secret_key_hex: FIXTURE_DIEGO_SECRET_KEY_HEX,
- public_key_hex: FIXTURE_DIEGO_PUBLIC_KEY_HEX,
- nsec: FIXTURE_DIEGO_NSEC,
- npub: FIXTURE_DIEGO_NPUB,
-};
-
-pub const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com";
-pub const RELAY_SECONDARY_WSS: &str = "wss://relay-2.example.com";
-pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com";
-
-pub const APP_PRIMARY_HTTPS: &str = "https://app.example.com";
-pub const API_PRIMARY_HTTPS: &str = "https://api.example.com";
-pub const CDN_PRIMARY_HTTPS: &str = "https://cdn.example.com";
diff --git a/src/signer/test_support.rs b/src/signer/test_support.rs
@@ -1,85 +0,0 @@
-use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
-use crate::signer::test_fixtures::{
- API_PRIMARY_HTTPS, ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL,
- FIXTURE_DIEGO, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS,
-};
-use nostr::{Keys, PublicKey, RelayUrl, SecretKey};
-
-fn approved_public_identity(identity: ApprovedFixtureIdentity) -> PublicIdentity {
- let public_key = approved_public_key(identity);
- PublicIdentity::new(public_key).expect("identity public key")
-}
-
-fn approved_public_key(identity: ApprovedFixtureIdentity) -> PublicKey {
- let secret = SecretKey::from_hex(identity.secret_key_hex).expect("secret");
- Keys::new(secret).public_key()
-}
-
-fn relay(url: &str) -> RelayUrl {
- RelayUrl::parse(url).expect("relay")
-}
-
-pub(crate) fn fixture_alice_identity() -> PublicIdentity {
- approved_public_identity(FIXTURE_ALICE)
-}
-
-pub(crate) fn fixture_alice_public_key() -> PublicKey {
- approved_public_key(FIXTURE_ALICE)
-}
-
-pub(crate) fn fixture_bob_identity() -> PublicIdentity {
- approved_public_identity(FIXTURE_BOB)
-}
-
-pub(crate) fn fixture_carol_identity() -> PublicIdentity {
- approved_public_identity(FIXTURE_CAROL)
-}
-
-pub(crate) fn fixture_carol_public_key() -> PublicKey {
- approved_public_key(FIXTURE_CAROL)
-}
-
-pub(crate) fn fixture_diego_identity() -> PublicIdentity {
- approved_public_identity(FIXTURE_DIEGO)
-}
-
-pub(crate) fn fixture_diego_public_key() -> PublicKey {
- approved_public_key(FIXTURE_DIEGO)
-}
-
-pub(crate) fn primary_relay() -> RelayUrl {
- relay(RELAY_PRIMARY_WSS)
-}
-
-pub(crate) fn secondary_relay() -> RelayUrl {
- relay(RELAY_SECONDARY_WSS)
-}
-
-pub(crate) fn tertiary_relay() -> RelayUrl {
- relay(RELAY_TERTIARY_WSS)
-}
-
-pub(crate) fn api_primary_https() -> &'static str {
- API_PRIMARY_HTTPS
-}
-
-pub(crate) fn synthetic_secret_hex(index: u32) -> String {
- format!("{index:064x}")
-}
-
-pub(crate) fn synthetic_public_identity(index: u32) -> PublicIdentity {
- let public_key = synthetic_public_key(index);
- PublicIdentity::new(public_key).expect("identity public key")
-}
-
-pub(crate) fn synthetic_public_key(index: u32) -> PublicKey {
- let secret_hex = synthetic_secret_hex(index);
- let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret");
- Keys::new(secret).public_key()
-}
-
-pub(crate) fn synthetic_keys(index: u32) -> Keys {
- let secret_hex = synthetic_secret_hex(index);
- let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret");
- Keys::new(secret)
-}
diff --git a/src/signing_adapter.rs b/src/signing_adapter.rs
@@ -1,88 +0,0 @@
-//! Adapter from Myc-owned identity operations to the final signing contract.
-
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp};
-use radroots_event::{SignedEvent, wire::v1::Nip01EventWire};
-use radroots_signing::capability::{CancellationSupport, SignerCapability, SignerKind};
-use radroots_signing::error::Kind as SigningErrorKind;
-use radroots_signing::recovery::ReplayCapability;
-use radroots_signing::status::{SignProgress, SignProgressStage, SignerAvailability};
-use radroots_signing::{Error, SignReceipt, SignRequest, Signer, SignerStatus};
-
-use crate::custody::MycActiveIdentity;
-
-impl Signer for MycActiveIdentity {
- fn status(&self) -> radroots_signing::signer::BoxFuture<'_, Result<SignerStatus, Error>> {
- Box::pin(async {
- Ok(SignerStatus::new(
- SignerAvailability::Ready,
- vec![SignerCapability::new(
- SignerKind::HostMediated,
- ReplayCapability::LocalReplaySafe,
- CancellationSupport::BeforePublication,
- true,
- true,
- )],
- None,
- ))
- })
- }
-
- fn sign(
- &self,
- request: SignRequest,
- ) -> radroots_signing::signer::BoxFuture<'_, Result<SignReceipt, Error>> {
- Box::pin(async move {
- let now = now_unix_ms();
- request.ensure_active(now)?;
- if request.expected_author() != &self.public_identity().public_key() {
- return Err(Error::new(SigningErrorKind::AuthorizationDenied));
- }
- report_progress(&request, SignProgressStage::Validating)?;
-
- let kind = u16::try_from(request.kind())
- .map_err(|_| Error::new(SigningErrorKind::InvalidArgument))?;
- let tags = request
- .tags()
- .iter()
- .cloned()
- .map(Tag::parse)
- .collect::<Result<Vec<_>, _>>()
- .map_err(|source| Error::with_source(SigningErrorKind::InvalidArgument, source))?;
- let unsigned = EventBuilder::new(Kind::Custom(kind), request.content())
- .tags(tags)
- .custom_created_at(Timestamp::from(request.created_at()))
- .build(self.public_key());
- let event = self
- .sign_unsigned_event(unsigned, "final signing request")
- .map_err(|source| Error::with_source(SigningErrorKind::InternalError, source))?;
-
- report_progress(&request, SignProgressStage::VerifyingOutput)?;
- let raw_json = event.as_json();
- let wire = Nip01EventWire::parse_json(&raw_json).map_err(|source| {
- Error::with_source(SigningErrorKind::SignerOutputInvalid, source)
- })?;
- let signed_event =
- SignedEvent::from_wire_verified_id(wire, raw_json).map_err(|source| {
- Error::with_source(SigningErrorKind::SignerOutputInvalid, source)
- })?;
- let receipt = SignReceipt::from_signed_event(&request, signed_event, now)?;
- report_progress(&request, SignProgressStage::Complete)?;
- Ok(receipt)
- })
- }
-}
-
-fn report_progress(request: &SignRequest, stage: SignProgressStage) -> Result<(), Error> {
- request.report_progress(&SignProgress::stage(stage)?);
- Ok(())
-}
-
-fn now_unix_ms() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_or(0, |duration| {
- u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
- })
-}
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -22,7 +22,7 @@ fn service_host_is_the_exact_default_feature_profile() {
assert!(MANIFEST.contains("[features]\ndefault = [\"service-host\"]\nservice-host = []"));
assert!(!MANIFEST.contains("getrandom = \"0.2\""));
assert!(MANIFEST.contains(
- "tokio = { version = \"1.48\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"process\", \"rt-multi-thread\", \"sync\", \"time\"] }"
+ "tokio = { version = \"1.48\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"rt-multi-thread\", \"sync\", \"time\"] }"
));
}
diff --git a/tests/services_hardening_legacy_removal.rs b/tests/services_hardening_legacy_removal.rs
@@ -5,6 +5,7 @@ use std::process::Command;
const LIB_SOURCE: &str = include_str!("../src/lib.rs");
const MAIN_SOURCE: &str = include_str!("../src/main.rs");
+const MANIFEST: &str = include_str!("../Cargo.toml");
const ACTIVE_STATE_SOURCES: &[&str] = &[
include_str!("../src/state_catalog.rs"),
include_str!("../src/state_connection.rs"),
@@ -135,6 +136,85 @@ fn active_state_tree_has_one_shared_database_and_no_legacy_backend() {
}
#[test]
+fn obsolete_provider_sources_and_dependencies_are_absent() {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));
+ for relative in [
+ "src/accounts.rs",
+ "src/custody.rs",
+ "src/error.rs",
+ "src/host_identity.rs",
+ "src/identity_files.rs",
+ "src/logging.rs",
+ "src/nostr_contract.rs",
+ "src/policy.rs",
+ "src/signing_adapter.rs",
+ "src/signer/backend.rs",
+ "src/signer/capability.rs",
+ "src/signer/error.rs",
+ "src/signer/evaluation.rs",
+ "src/signer/manager.rs",
+ "src/signer/mod.rs",
+ "src/signer/model.rs",
+ "src/signer/nip46.rs",
+ "src/signer/test_fixtures.rs",
+ "src/signer/test_support.rs",
+ ] {
+ assert!(
+ !root.join(relative).exists(),
+ "obsolete provider source remains: {relative}"
+ );
+ }
+
+ let manifest: toml::Value = toml::from_str(MANIFEST).expect("Cargo manifest");
+ let dependencies = manifest["dependencies"]
+ .as_table()
+ .expect("dependencies table");
+ for forbidden in [
+ "axum",
+ "keyring",
+ "nostr-sdk",
+ "radroots_identity",
+ "radroots_event",
+ "radroots_signing",
+ "rand",
+ "thiserror",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "uuid",
+ ] {
+ assert!(
+ !dependencies.contains_key(forbidden),
+ "obsolete provider dependency remains: {forbidden}"
+ );
+ }
+ let secrets_features = dependencies["radroots_secrets"]["features"]
+ .as_array()
+ .expect("radroots_secrets features");
+ assert_eq!(secrets_features, &[toml::Value::String("std".to_owned())]);
+ let tokio_features = dependencies["tokio"]["features"]
+ .as_array()
+ .expect("Tokio features");
+ assert!(
+ !tokio_features
+ .iter()
+ .any(|feature| feature.as_str() == Some("process"))
+ );
+ assert!(!dependencies.contains_key("tempfile"));
+
+ let dev_dependencies = manifest["dev-dependencies"]
+ .as_table()
+ .expect("dev-dependencies table");
+ assert!(dev_dependencies.contains_key("tempfile"));
+ for forbidden in ["futures-util", "serial_test", "tokio-tungstenite"] {
+ assert!(
+ !dev_dependencies.contains_key(forbidden),
+ "obsolete development dependency remains: {forbidden}"
+ );
+ }
+}
+
+#[test]
fn binary_uses_only_the_hardened_parser_and_fails_closed_before_dispatch() {
assert!(MAIN_SOURCE.contains("parse_myc_cli_v1_from(std::env::args_os())"));
assert!(!MAIN_SOURCE.contains("MycConfig"));