commit 8418646d0a621227d5528c234ba121bec179f31e
parent f10d32dcbe810ff67ec9121922e2a9dd83c28e0d
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 19:51:17 +0000
myc: migrate to final signing and nip46 crates
- replace retired sibling-path dependencies with final packaged security crates
- keep identity custody, persistence, logging, paths, and sqlite adapters host-owned
- internalize the NIP-46 service while sharing the final protocol implementation
- preserve fail-closed validation and cover restart, permissions, auth, and malformed input
Diffstat:
57 files changed, 15044 insertions(+), 1115 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -24,18 +24,6 @@ dependencies = [
]
[[package]]
-name = "ahash"
-version = "0.8.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
-dependencies = [
- "cfg-if",
- "once_cell",
- "version_check",
- "zerocopy",
-]
-
-[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -51,15 +39,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
-[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -116,29 +95,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
-name = "arraydeque"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
-
-[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
name = "async-utility"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -245,10 +207,10 @@ dependencies = [
]
[[package]]
-name = "base64"
-version = "0.21.7"
+name = "base16ct"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
@@ -301,9 +263,6 @@ name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
-dependencies = [
- "serde_core",
-]
[[package]]
name = "block-buffer"
@@ -391,19 +350,6 @@ dependencies = [
]
[[package]]
-name = "chrono"
-version = "0.4.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
-dependencies = [
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "wasm-bindgen",
- "windows-link",
-]
-
-[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -470,52 +416,10 @@ dependencies = [
]
[[package]]
-name = "config"
-version = "0.14.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf"
-dependencies = [
- "async-trait",
- "convert_case",
- "json5",
- "nom",
- "pathdiff",
- "ron",
- "rust-ini",
- "serde",
- "serde_json",
- "toml",
- "yaml-rust2",
-]
-
-[[package]]
-name = "const-random"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
-dependencies = [
- "const-random-macro",
-]
-
-[[package]]
-name = "const-random-macro"
-version = "0.1.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
-dependencies = [
- "getrandom 0.2.17",
- "once_cell",
- "tiny-keccak",
-]
-
-[[package]]
-name = "convert_case"
-version = "0.6.0"
+name = "const-oid"
+version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
-dependencies = [
- "unicode-segmentation",
-]
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
@@ -592,10 +496,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
-name = "crunchy"
-version = "0.2.4"
+name = "crypto-bigint"
+version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
[[package]]
name = "crypto-common"
@@ -637,6 +547,16 @@ dependencies = [
]
[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -668,15 +588,6 @@ dependencies = [
]
[[package]]
-name = "dlv-list"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
-dependencies = [
- "const-random",
-]
-
-[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -692,12 +603,20 @@ dependencies = [
]
[[package]]
-name = "encoding_rs"
-version = "0.8.35"
+name = "elliptic-curve"
+version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
- "cfg-if",
+ "base16ct",
+ "crypto-bigint",
+ "ff",
+ "generic-array",
+ "group",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
]
[[package]]
@@ -734,6 +653,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -892,6 +821,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -945,13 +875,14 @@ dependencies = [
]
[[package]]
-name = "hashbrown"
-version = "0.14.5"
+name = "group"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
- "ahash",
- "allocator-api2",
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
]
[[package]]
@@ -976,15 +907,6 @@ dependencies = [
[[package]]
name = "hashlink"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
-dependencies = [
- "hashbrown 0.14.5",
-]
-
-[[package]]
-name = "hashlink"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
@@ -1103,30 +1025,6 @@ dependencies = [
]
[[package]]
-name = "iana-time-zone"
-version = "0.1.65"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
-[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1216,6 +1114,16 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
@@ -1300,14 +1208,13 @@ dependencies = [
]
[[package]]
-name = "json5"
-version = "0.4.1"
+name = "k256"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
- "pest",
- "pest_derive",
- "serde",
+ "cfg-if",
+ "elliptic-curve",
]
[[package]]
@@ -1443,12 +1350,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1466,25 +1367,30 @@ dependencies = [
"axum",
"chacha20poly1305",
"clap",
+ "futures-executor",
"futures-util",
"getrandom 0.2.17",
+ "hex",
+ "keyring",
"nostr",
+ "nostr-sdk",
+ "radroots_event",
"radroots_identity",
- "radroots_log",
"radroots_nostr",
- "radroots_nostr_accounts",
"radroots_nostr_connect",
- "radroots_nostr_signer",
- "radroots_runtime_paths",
- "radroots_secret_vault",
- "radroots_sql_core",
+ "radroots_secrets",
+ "radroots_signing",
+ "rand 0.9.2",
"serde",
"serde_json",
+ "sha2",
+ "sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tracing",
+ "tracing-appender",
"tracing-subscriber",
"url",
"uuid",
@@ -1498,23 +1404,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
name = "nostr"
version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"aes",
- "base64 0.22.1",
+ "base64",
"bech32",
"bip39",
"bitcoin_hashes",
@@ -1530,6 +1426,7 @@ dependencies = [
"serde_json",
"unicode-normalization",
"url",
+ "url-fork",
]
[[package]]
@@ -1676,16 +1573,6 @@ dependencies = [
]
[[package]]
-name = "ordered-multimap"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
-dependencies = [
- "dlv-list",
- "hashbrown 0.14.5",
-]
-
-[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1726,12 +1613,6 @@ dependencies = [
]
[[package]]
-name = "pathdiff"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
-
-[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1748,49 +1629,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "pest"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
-dependencies = [
- "memchr",
- "ucd-trie",
-]
-
-[[package]]
-name = "pest_derive"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
-dependencies = [
- "pest",
- "pest_generator",
-]
-
-[[package]]
-name = "pest_generator"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
-dependencies = [
- "pest",
- "pest_meta",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "pest_meta"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
-dependencies = [
- "pest",
- "sha2",
-]
-
-[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1879,7 +1717,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radroots_blossom"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
"mediatype",
"serde",
@@ -1890,21 +1728,22 @@ dependencies = [
[[package]]
name = "radroots_core"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
"rust_decimal",
- "rust_decimal_macros",
"serde",
]
[[package]]
name = "radroots_event"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
"hex",
"jiff-tzdb",
"radroots_blossom",
"radroots_core",
+ "radroots_identity",
+ "radroots_protocol",
"serde",
"serde_json",
"sha2",
@@ -1914,48 +1753,32 @@ dependencies = [
[[package]]
name = "radroots_event_codec"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
"radroots_blossom",
"radroots_core",
"radroots_event",
+ "radroots_identity",
+ "radroots_protocol",
+ "secp256k1",
"serde",
"serde_json",
]
[[package]]
name = "radroots_identity"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
- "nostr",
- "radroots_protected_store",
- "radroots_runtime",
- "radroots_runtime_paths",
- "radroots_secret_vault",
+ "k256",
"serde",
- "serde_json",
- "thiserror 1.0.69",
- "tracing",
-]
-
-[[package]]
-name = "radroots_log"
-version = "1.0.0-alpha.1"
-dependencies = [
- "chrono",
- "serde_json",
- "thiserror 1.0.69",
- "tracing",
- "tracing-appender",
- "tracing-subscriber",
+ "thiserror 2.0.18",
]
[[package]]
name = "radroots_nostr"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
"nostr",
- "nostr-sdk",
"radroots_event",
"radroots_event_codec",
"radroots_identity",
@@ -1965,109 +1788,45 @@ dependencies = [
]
[[package]]
-name = "radroots_nostr_accounts"
-version = "1.0.0-alpha.1"
-dependencies = [
- "radroots_identity",
- "radroots_nostr_signer",
- "radroots_protected_store",
- "radroots_runtime",
- "radroots_secret_vault",
- "serde",
- "serde_json",
- "thiserror 1.0.69",
- "zeroize",
-]
-
-[[package]]
name = "radroots_nostr_connect"
-version = "1.0.0-alpha.1"
-dependencies = [
- "nostr",
- "serde",
- "serde_json",
- "thiserror 1.0.69",
- "url",
-]
-
-[[package]]
-name = "radroots_nostr_signer"
-version = "1.0.0-alpha.1"
+version = "0.1.0-alpha"
dependencies = [
- "hex",
"nostr",
+ "radroots_event",
"radroots_identity",
"radroots_nostr",
- "radroots_nostr_connect",
- "radroots_runtime",
- "radroots_sql_core",
+ "radroots_protocol",
"serde",
"serde_json",
- "sha2",
"thiserror 1.0.69",
"url",
- "uuid",
]
[[package]]
-name = "radroots_protected_store"
-version = "1.0.0-alpha.1"
+name = "radroots_protocol"
+version = "0.1.0-alpha"
dependencies = [
- "chacha20poly1305",
- "getrandom 0.2.17",
- "radroots_secret_vault",
"serde",
- "serde_json",
- "zeroize",
]
[[package]]
-name = "radroots_runtime"
-version = "1.0.0-alpha.1"
+name = "radroots_secrets"
+version = "0.1.0-alpha"
dependencies = [
- "anyhow",
"chacha20poly1305",
- "config",
- "getrandom 0.2.17",
- "radroots_log",
- "radroots_protected_store",
- "radroots_runtime_paths",
- "radroots_secret_vault",
+ "keyring",
"serde",
- "serde_json",
- "tempfile",
- "thiserror 1.0.69",
- "tokio",
- "toml",
- "tracing",
"zeroize",
]
[[package]]
-name = "radroots_runtime_paths"
-version = "1.0.0-alpha.1"
-dependencies = [
- "serde",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "radroots_secret_vault"
-version = "1.0.0-alpha.1"
-dependencies = [
- "keyring",
-]
-
-[[package]]
-name = "radroots_sql_core"
-version = "1.0.0-alpha.1"
+name = "radroots_signing"
+version = "0.1.0-alpha"
dependencies = [
- "chrono",
- "futures-executor",
+ "radroots_event",
+ "radroots_identity",
+ "radroots_protocol",
"serde",
- "serde_json",
- "sqlx",
- "uuid",
]
[[package]]
@@ -2170,28 +1929,6 @@ dependencies = [
]
[[package]]
-name = "ron"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
-dependencies = [
- "base64 0.21.7",
- "bitflags",
- "serde",
- "serde_derive",
-]
-
-[[package]]
-name = "rust-ini"
-version = "0.20.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a"
-dependencies = [
- "cfg-if",
- "ordered-multimap",
-]
-
-[[package]]
name = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2204,16 +1941,6 @@ dependencies = [
]
[[package]]
-name = "rust_decimal_macros"
-version = "1.40.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519"
-dependencies = [
- "quote",
- "syn",
-]
-
-[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2294,6 +2021,19 @@ dependencies = [
]
[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2410,15 +2150,6 @@ dependencies = [
]
[[package]]
-name = "serde_spanned"
-version = "0.6.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
-dependencies = [
- "serde",
-]
-
-[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2513,7 +2244,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
- "base64 0.22.1",
+ "base64",
"bytes",
"cfg-if",
"crc",
@@ -2525,7 +2256,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.16.1",
- "hashlink 0.11.1",
+ "hashlink",
"indexmap",
"log",
"memchr",
@@ -2737,15 +2468,6 @@ dependencies = [
]
[[package]]
-name = "tiny-keccak"
-version = "2.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
-dependencies = [
- "crunchy",
-]
-
-[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2836,47 +2558,6 @@ dependencies = [
]
[[package]]
-name = "toml"
-version = "0.8.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
-dependencies = [
- "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_edit",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "0.6.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.22.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
-dependencies = [
- "indexmap",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_write",
- "winnow",
-]
-
-[[package]]
-name = "toml_write"
-version = "0.1.2"
-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"
@@ -3003,10 +2684,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
-name = "ucd-trie"
-version = "0.1.7"
+name = "unicode-bidi"
+version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-general-category"
@@ -3030,12 +2711,6 @@ dependencies = [
]
[[package]]
-name = "unicode-segmentation"
-version = "1.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
-
-[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3064,13 +2739,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
- "idna",
+ "idna 1.1.0",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
+name = "url-fork"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fa3323c39b8e786154d3000b70ae9af0e9bd746c9791456da0d4a1f68ad89d6"
+dependencies = [
+ "form_urlencoded",
+ "idna 0.5.0",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3261,65 +2948,12 @@ dependencies = [
]
[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-implement"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "windows-interface"
-version = "0.59.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3485,15 +3119,6 @@ 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"
-checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
-dependencies = [
- "memchr",
-]
-
-[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3588,17 +3213,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
-name = "yaml-rust2"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8"
-dependencies = [
- "arraydeque",
- "encoding_rs",
- "hashlink 0.8.4",
-]
-
-[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3720,3 +3334,119 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[patch.unused]]
+name = "radroots_authority"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_event_store"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_geonames"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_log"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_mesh"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_nostr_accounts"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_nostr_runtime"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_nostr_signer"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_nostrdb"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_outbox"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_protected_store"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_protocol_contract_v1"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_replica_schema"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_replica_store"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_replica_sync"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_runtime"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_runtime_paths"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_runtime_store"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_secret_vault"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_sql_core"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_storage"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_storage_sqlite"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_sync"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_test_fixtures"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_trade"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_transport"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_transport_nostr"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_transport_publish_protocol"
+version = "0.1.0-alpha"
+
+[[patch.unused]]
+name = "radroots_transport_reticulum"
+version = "0.1.0-alpha"
diff --git a/Cargo.toml b/Cargo.toml
@@ -3,7 +3,7 @@ name = "myc"
version = "0.1.0"
edition = "2024"
authors = ["Radroots Authors"]
-rust-version = "1.97.0"
+rust-version = "1.97.1"
license = "AGPL-3.0-or-later"
description = "Radroots NIP-46 remote signer for delegated Nostr accounts"
@@ -18,21 +18,27 @@ axum = { version = "0.8", default-features = false, features = ["http1", "json",
chacha20poly1305 = "0.10"
clap = { version = "4.5", features = ["derive"] }
getrandom = "0.2"
-nostr = { version = "0.44.2", features = ["nip04", "nip44", "nip46"] }
-radroots_identity = { version = "=1.0.0-alpha.1", path = "../lib/crates/identity" }
-radroots_log = { version = "=1.0.0-alpha.1", path = "../lib/crates/log" }
-radroots_nostr_accounts = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr_accounts", default-features = false, features = ["std", "memory-vault", "os-keyring"] }
-radroots_nostr = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr", features = ["client", "events"] }
-radroots_nostr_connect = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr_connect" }
-radroots_nostr_signer = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr_signer", features = ["native"] }
-radroots_runtime_paths = { version = "=1.0.0-alpha.1", path = "../lib/crates/runtime_paths" }
-radroots_secret_vault = { version = "=1.0.0-alpha.1", path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] }
-radroots_sql_core = { version = "=1.0.0-alpha.1", path = "../lib/crates/sql_core", features = ["native"] }
+futures-executor = "0.3"
+hex = "0.4"
+keyring = { version = "3.6", 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 = { version = "=0.1.0-alpha" }
+radroots_event = { version = "=0.1.0-alpha", features = ["serde"] }
+radroots_nostr = { version = "=0.1.0-alpha", features = ["events"] }
+radroots_nostr_connect = { version = "=0.1.0-alpha" }
+radroots_secrets = { version = "=0.1.0-alpha", features = ["std", "keyring"] }
+radroots_signing = { version = "=0.1.0-alpha", features = ["std"] }
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"
thiserror = "2.0"
+tempfile = "3.17"
tokio = { version = "1.48", 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"] }
url = "2.5"
uuid = { version = "1.18", features = ["serde", "v7"] }
@@ -40,5 +46,4 @@ zeroize = "1.8"
[dev-dependencies]
futures-util = "0.3.32"
-tempfile = "3.17"
tokio-tungstenite = "0.26.2"
diff --git a/migrations/signer/0000_init.down.sql b/migrations/signer/0000_init.down.sql
@@ -0,0 +1,8 @@
+DROP TABLE IF EXISTS signer_request_audit;
+DROP TABLE IF EXISTS signer_connection_pending_request;
+DROP TABLE IF EXISTS signer_connection_auth_challenge;
+DROP TABLE IF EXISTS signer_connection_relay;
+DROP TABLE IF EXISTS signer_connection_permission_grant;
+DROP TABLE IF EXISTS signer_connection;
+DELETE FROM signer_store_metadata WHERE singleton_id = 1;
+DROP TABLE IF EXISTS signer_store_metadata;
diff --git a/migrations/signer/0000_init.up.sql b/migrations/signer/0000_init.up.sql
@@ -0,0 +1,97 @@
+CREATE TABLE IF NOT EXISTS signer_store_metadata (
+ singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
+ store_version INTEGER NOT NULL,
+ signer_identity_id TEXT,
+ signer_identity_public_key_hex TEXT,
+ signer_identity_json TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT OR IGNORE INTO signer_store_metadata (singleton_id, store_version)
+VALUES (1, 1);
+
+CREATE TABLE IF NOT EXISTS signer_connection (
+ connection_id TEXT PRIMARY KEY,
+ client_public_key_hex TEXT NOT NULL,
+ signer_identity_id TEXT NOT NULL,
+ signer_identity_public_key_hex TEXT NOT NULL,
+ signer_identity_json TEXT NOT NULL,
+ user_identity_id TEXT NOT NULL,
+ user_identity_public_key_hex TEXT NOT NULL,
+ user_identity_json TEXT NOT NULL,
+ connect_secret_hash_algorithm TEXT,
+ connect_secret_hash_digest_hex TEXT,
+ connect_secret_consumed_at_unix INTEGER,
+ requested_permissions_json TEXT NOT NULL,
+ approval_requirement TEXT NOT NULL,
+ approval_state TEXT NOT NULL,
+ auth_state TEXT NOT NULL,
+ status TEXT NOT NULL,
+ status_reason TEXT,
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL,
+ last_authenticated_at_unix INTEGER,
+ last_request_at_unix INTEGER
+);
+
+CREATE INDEX IF NOT EXISTS signer_connection_client_public_key_idx
+ON signer_connection (client_public_key_hex);
+
+CREATE INDEX IF NOT EXISTS signer_connection_user_identity_idx
+ON signer_connection (user_identity_id);
+
+CREATE INDEX IF NOT EXISTS signer_connection_connect_secret_digest_idx
+ON signer_connection (connect_secret_hash_digest_hex)
+WHERE connect_secret_hash_digest_hex IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS signer_connection_status_idx
+ON signer_connection (status);
+
+CREATE TABLE IF NOT EXISTS signer_connection_permission_grant (
+ connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ permission TEXT NOT NULL,
+ granted_at_unix INTEGER NOT NULL,
+ PRIMARY KEY (connection_id, permission)
+);
+
+CREATE INDEX IF NOT EXISTS signer_connection_permission_grant_permission_idx
+ON signer_connection_permission_grant (permission);
+
+CREATE TABLE IF NOT EXISTS signer_connection_relay (
+ connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ ordinal INTEGER NOT NULL,
+ relay_url TEXT NOT NULL,
+ PRIMARY KEY (connection_id, ordinal),
+ UNIQUE (connection_id, relay_url)
+);
+
+CREATE INDEX IF NOT EXISTS signer_connection_relay_url_idx
+ON signer_connection_relay (relay_url);
+
+CREATE TABLE IF NOT EXISTS signer_connection_auth_challenge (
+ connection_id TEXT PRIMARY KEY REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ auth_url TEXT NOT NULL,
+ required_at_unix INTEGER NOT NULL,
+ authorized_at_unix INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS signer_connection_pending_request (
+ connection_id TEXT PRIMARY KEY REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ request_message_json TEXT NOT NULL,
+ created_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS signer_request_audit (
+ request_id TEXT PRIMARY KEY,
+ connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ method TEXT NOT NULL,
+ decision TEXT NOT NULL,
+ message TEXT,
+ created_at_unix INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS signer_request_audit_connection_id_idx
+ON signer_request_audit (connection_id);
+
+CREATE INDEX IF NOT EXISTS signer_request_audit_created_at_idx
+ON signer_request_audit (created_at_unix);
diff --git a/migrations/signer/0001_publish_workflows.down.sql b/migrations/signer/0001_publish_workflows.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS signer_publish_workflow;
diff --git a/migrations/signer/0001_publish_workflows.up.sql b/migrations/signer/0001_publish_workflows.up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS signer_publish_workflow (
+ workflow_id TEXT PRIMARY KEY,
+ connection_id TEXT NOT NULL REFERENCES signer_connection (connection_id) ON DELETE CASCADE,
+ kind TEXT NOT NULL,
+ state TEXT NOT NULL,
+ pending_request_json TEXT,
+ authorized_at_unix INTEGER,
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS signer_publish_workflow_connection_id_idx
+ON signer_publish_workflow (connection_id);
+
+CREATE INDEX IF NOT EXISTS signer_publish_workflow_state_idx
+ON signer_publish_workflow (state);
diff --git a/migrations/signer/0002_client_metadata.down.sql b/migrations/signer/0002_client_metadata.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE signer_connection
+DROP COLUMN client_metadata_json;
diff --git a/migrations/signer/0002_client_metadata.up.sql b/migrations/signer/0002_client_metadata.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE signer_connection
+ADD COLUMN client_metadata_json TEXT;
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
@@ -1,2 +1,2 @@
[toolchain]
-channel = "1.97.0"
+channel = "1.97.1"
diff --git a/src/accounts.rs b/src/accounts.rs
@@ -0,0 +1,420 @@
+//! 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/app/backend.rs b/src/app/backend.rs
@@ -1,10 +1,5 @@
-use nostr::{PublicKey, RelayUrl, UnsignedEvent};
-use radroots_identity::RadrootsIdentityPublic;
-use radroots_nostr_connect::prelude::{
- RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
- RadrootsNostrConnectRequestMessage,
-};
-use radroots_nostr_signer::prelude::{
+use crate::host_identity::RadrootsIdentityPublic;
+use crate::signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerAuthorizationOutcome,
RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities,
@@ -17,6 +12,11 @@ use radroots_nostr_signer::prelude::{
RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerSessionLookup,
RadrootsNostrSignerSignOutput, RadrootsNostrSignerWorkflowId,
};
+use nostr::{PublicKey, RelayUrl, UnsignedEvent};
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage,
+};
use crate::app::MycSignerContext;
use crate::error::MycError;
@@ -44,7 +44,7 @@ impl MycSignerBackend {
fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability {
let public_identity = self.configured_signer_identity();
RadrootsNostrLocalSignerCapability::new(
- public_identity.id.clone(),
+ public_identity.id.to_final().into(),
public_identity,
RadrootsNostrLocalSignerAvailability::SecretBacked,
)
@@ -349,11 +349,9 @@ fn convert_runtime_signer_error(error: MycError) -> RadrootsNostrSignerError {
mod tests {
use std::path::PathBuf;
+ use crate::host_identity::RadrootsIdentity;
+ use crate::signer::prelude::{RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft};
use nostr::Keys;
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft,
- };
use crate::app::MycRuntime;
use crate::config::MycConfig;
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -43,7 +43,7 @@ impl MycApp {
mod tests {
use std::path::PathBuf;
- use radroots_identity::RadrootsIdentity;
+ use crate::host_identity::RadrootsIdentity;
use crate::config::{MycConfig, MycSignerStateBackend};
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -18,6 +18,7 @@ use crate::config::{
use crate::custody::{MycActiveIdentity, MycIdentityProvider};
use crate::discovery::MycDiscoveryContext;
use crate::error::MycError;
+use crate::host_identity::RadrootsIdentityPublic;
use crate::operability::{
MycDeliveryOutboxStatusOutput, MycLiveMetricsHandle, MycLiveMetricsState, MycMetricsSnapshot,
server::run_observability_server,
@@ -27,17 +28,16 @@ use crate::outbox::{
};
use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore;
use crate::policy::MycPolicyContext;
-use crate::transport::{
- MycNip46Service, MycNostrTransport, MycPublishOutcome, MycTransportSnapshot,
-};
-use radroots_identity::RadrootsIdentityPublic;
-use radroots_nostr_signer::prelude::{
+use crate::signer::prelude::{
RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement,
RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager,
RadrootsNostrSignerPublishWorkflowKind, RadrootsNostrSignerPublishWorkflowRecord,
RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerRequestAuditRecord,
RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore,
};
+use crate::transport::{
+ MycNip46Service, MycNostrTransport, MycPublishOutcome, MycTransportSnapshot,
+};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1309,14 +1309,14 @@ mod tests {
use std::path::PathBuf;
use std::sync::Arc;
- use nostr::PublicKey;
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
- use radroots_nostr_signer::prelude::{
+ use crate::host_identity::RadrootsIdentity;
+ use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
+ use crate::signer::prelude::{
RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement,
RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerManager, RadrootsNostrSqliteSignerStore,
};
+ use nostr::PublicKey;
use super::{MycRuntime, startup_identity_path};
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
diff --git a/src/audit.rs b/src/audit.rs
@@ -4,7 +4,7 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
-use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
+use crate::signer::prelude::RadrootsNostrSignerConnectionId;
use serde::{Deserialize, Serialize};
use crate::config::MycAuditConfig;
@@ -816,7 +816,7 @@ fn now_unix_secs() -> u64 {
mod tests {
use std::fs;
- use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
+ use crate::signer::prelude::RadrootsNostrSignerConnectionId;
use crate::config::MycAuditConfig;
diff --git a/src/audit_sqlite.rs b/src/audit_sqlite.rs
@@ -1,8 +1,8 @@
use std::path::{Path, PathBuf};
-use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
-use radroots_sql_core::migrations::{Migration, migrations_run_all_up};
-use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor};
+use crate::signer::prelude::RadrootsNostrSignerConnectionId;
+use crate::sql::migrations::{Migration, migrations_run_all_up};
+use crate::sql::{SqlExecutor, SqlxSqliteExecutor};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
@@ -321,7 +321,7 @@ impl MycOperationAuditSqliteDb {
#[cfg(test)]
fn migrate_down(&self) -> Result<(), MycError> {
- use radroots_sql_core::migrations::migrations_run_all_down;
+ use crate::sql::migrations::migrations_run_all_down;
migrations_run_all_down(&self.executor, MYC_OPERATION_AUDIT_MIGRATIONS).map_err(|source| {
MycError::AuditSql {
@@ -547,8 +547,8 @@ fn parse_delivery_policy(value: &str) -> Result<MycTransportDeliveryPolicy, MycE
#[cfg(test)]
mod tests {
- use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
- use radroots_sql_core::SqlExecutor;
+ use crate::signer::prelude::RadrootsNostrSignerConnectionId;
+ use crate::sql::SqlExecutor;
use serde_json::Value;
use crate::audit::{
diff --git a/src/bin/myc_repo_local_identity_bootstrap.rs b/src/bin/myc_repo_local_identity_bootstrap.rs
@@ -4,11 +4,8 @@ use std::env;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
+use myc::host_identity::RadrootsIdentity;
use myc::identity_files::{load_encrypted_identity, store_encrypted_identity};
-use radroots_identity::RadrootsIdentity;
-use radroots_runtime_paths::{
- RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace,
-};
fn main() -> ExitCode {
match run() {
@@ -52,18 +49,10 @@ struct MycRuntimePaths {
}
fn resolve_runtime_paths(runtime_root: &Path) -> Result<MycRuntimePaths, String> {
- let base_paths = RadrootsPathResolver::current()
- .resolve(
- RadrootsPathProfile::RepoLocal,
- &RadrootsPathOverrides::repo_local(runtime_root),
- )
- .map_err(|err| format!("resolve repo_local runtime roots: {err}"))?;
- let myc_namespace = RadrootsRuntimeNamespace::service("myc")
- .map_err(|err| format!("resolve myc namespace: {err}"))?;
- let myc_paths = base_paths.namespaced(&myc_namespace);
+ let secrets = runtime_root.join("secrets").join("services").join("myc");
Ok(MycRuntimePaths {
- signer_identity_path: myc_paths.secrets.join("signer-identity.json"),
- user_identity_path: myc_paths.secrets.join("user-identity.json"),
+ signer_identity_path: secrets.join("signer-identity.json"),
+ user_identity_path: secrets.join("user-identity.json"),
})
}
diff --git a/src/cli.rs b/src/cli.rs
@@ -2,12 +2,12 @@ use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
-use clap::{Args, Parser, Subcommand, ValueEnum};
-use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
-use radroots_nostr_signer::prelude::{
+use crate::signer::prelude::{
RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionId,
RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord,
};
+use clap::{Args, Parser, Subcommand, ValueEnum};
+use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
use serde::Serialize;
use zeroize::Zeroizing;
@@ -732,7 +732,7 @@ fn granted_permissions_for_approval(
fn load_audit_output(
runtime: &MycRuntime,
- manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager,
+ manager: &crate::signer::prelude::RadrootsNostrSignerManager,
connection_id: Option<&str>,
attempt_id: Option<&str>,
scope: MycAuditScope,
@@ -791,7 +791,7 @@ fn load_audit_output(
fn summarize_audit_output(
runtime: &MycRuntime,
- manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager,
+ manager: &crate::signer::prelude::RadrootsNostrSignerManager,
connection_id: Option<&str>,
attempt_id: Option<&str>,
scope: MycAuditScope,
@@ -809,13 +809,13 @@ fn summarize_audit_output(
let mut signer_request_decisions = MycAuditDecisionCounts::default();
for record in &audit.signer_request_audit {
match record.decision {
- radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => {
+ crate::signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => {
signer_request_decisions.allowed += 1;
}
- radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Denied => {
+ crate::signer::prelude::RadrootsNostrSignerRequestDecision::Denied => {
signer_request_decisions.denied += 1;
}
- radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => {
+ crate::signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => {
signer_request_decisions.challenged += 1;
}
}
@@ -1051,11 +1051,11 @@ fn read_secret_env(name: &str, operation: &str) -> Result<Zeroizing<String>, Myc
mod tests {
use std::path::PathBuf;
+ use crate::host_identity::RadrootsIdentity;
+ use crate::signer::prelude::RadrootsNostrSignerConnectionDraft;
use clap::Parser;
use nostr::Timestamp;
- use radroots_identity::RadrootsIdentity;
use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest;
- use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft;
use serde_json::json;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
@@ -1195,13 +1195,16 @@ mod tests {
radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
"request-1",
RadrootsNostrConnectRequest::SignEvent(
- serde_json::from_value(json!({
- "pubkey": runtime.user_identity().public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": 1,
- "tags": [],
- "content": "hello"
- }))
+ radroots_nostr_connect::message::UnsignedEvent::from_json(
+ &json!({
+ "pubkey": runtime.user_identity().public_key().to_hex(),
+ "created_at": Timestamp::from(1).as_secs(),
+ "kind": 1,
+ "tags": [],
+ "content": "hello"
+ })
+ .to_string(),
+ )
.expect("unsigned event"),
),
),
diff --git a/src/config.rs b/src/config.rs
@@ -3,11 +3,11 @@ use std::fs;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
+use crate::nostr_contract::RadrootsNostrRelayUrl;
+use crate::paths::{RadrootsPathResolver, RadrootsRuntimePathPolicyContract};
+use crate::signer::prelude::RadrootsNostrSignerApprovalRequirement;
use nostr::PublicKey;
-use radroots_nostr::prelude::RadrootsNostrRelayUrl;
use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
-use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement;
-use radroots_runtime_paths::{RadrootsPathResolver, RadrootsRuntimePathPolicyContract};
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
@@ -1718,7 +1718,7 @@ fn validate_identity_source_config(
"{label}.keyring_account_id must be set when backend is `host_vault`"
)));
};
- let _ = radroots_identity::RadrootsIdentityId::parse(account_id).map_err(|_| {
+ let _ = crate::host_identity::RadrootsIdentityId::parse(account_id).map_err(|_| {
MycError::InvalidConfig(format!(
"{label}.keyring_account_id must be a valid nostr public identity id"
))
@@ -1990,7 +1990,7 @@ fn discovery_host_is_local(host: Option<&str>) -> bool {
mod tests {
use std::fs;
- use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform};
+ use crate::paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform};
use super::*;
@@ -2647,16 +2647,16 @@ MYC_UNKNOWN=nope
let config = MycConfig::from_env_str(
r#"
MYC_IDENTITY_SIGNER_BACKEND=host_vault
-MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111
+MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df
MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
MYC_IDENTITY_USER_BACKEND=host_vault
-MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID=2222222222222222222222222222222222222222222222222222222222222222
+MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID=e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af
MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user
MYC_DISCOVERY_ENABLED=true
MYC_DISCOVERY_DOMAIN=myc.example.com
MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com
MYC_IDENTITY_DISCOVERY_APP_BACKEND=host_vault
-MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID=3333333333333333333333333333333333333333333333333333333333333333
+MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df
MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
"#,
)
@@ -2668,7 +2668,7 @@ MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
);
assert_eq!(
config.paths.signer_identity_keyring_account_id.as_deref(),
- Some("1111111111111111111111111111111111111111111111111111111111111111")
+ Some("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
);
assert_eq!(
config.paths.user_identity_backend,
@@ -2869,7 +2869,7 @@ MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=17
MYC_PATHS_STATE_DIR=/tmp/myc state
MYC_IDENTITY_SIGNER_BACKEND=host_vault
MYC_IDENTITY_SIGNER_PATH=/tmp/ignored-signer.json
-MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111
+MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df
MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
MYC_IDENTITY_SIGNER_PROFILE_PATH=/tmp/signer-profile.json
MYC_IDENTITY_USER_BACKEND=plaintext_file
diff --git a/src/control.rs b/src/control.rs
@@ -1,15 +1,15 @@
use std::str::FromStr;
-use radroots_nostr_connect::prelude::{
- RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
- RadrootsNostrConnectResponse, RadrootsNostrConnectUri,
-};
-use radroots_nostr_signer::prelude::{
+use crate::signer::prelude::{
RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend,
RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord,
RadrootsNostrSignerRequestId, RadrootsNostrSignerWorkflowId,
};
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectResponse, RadrootsNostrConnectUri,
+};
use serde::Serialize;
use crate::app::MycRuntime;
@@ -86,26 +86,30 @@ pub async fn accept_client_uri(
));
}
};
+ let client_public_key =
+ radroots_nostr::key::public_key_to_nostr(client_uri.client_public_key()).map_err(|_| {
+ MycError::InvalidOperation("NIP-46 client public key conversion failed".to_owned())
+ })?;
let request = RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
- secret: Some(client_uri.secret.clone()),
- requested_permissions: client_uri.metadata.requested_permissions.clone(),
- client_metadata: (!client_uri.metadata.is_display_empty())
- .then(|| client_uri.metadata.clone()),
+ remote_signer_public_key: runtime.signer_identity().public_identity().public_key(),
+ secret: Some(client_uri.secret().to_owned()),
+ requested_permissions: client_uri.metadata().requested_permissions().clone(),
+ client_metadata: (!client_uri.metadata().is_display_empty())
+ .then(|| client_uri.metadata().clone()),
};
let backend = runtime.signer_backend();
let Some(approval_requirement) = runtime
.signer_context()
.policy()
- .approval_requirement_for_client(&client_uri.client_public_key)
+ .approval_requirement_for_client(&client_public_key)
else {
return Err(MycError::InvalidOperation(
"client public key denied by policy".to_owned(),
));
};
- let connection = match backend.evaluate_connect_request(client_uri.client_public_key, request)? {
- radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(
+ let connection = match backend.evaluate_connect_request(client_public_key, request)? {
+ crate::signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(
connection,
) => {
if connection.connect_secret_is_consumed() {
@@ -126,7 +130,7 @@ pub async fn accept_client_uri(
}
connection
}
- radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired(
+ crate::signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired(
proposal,
) => {
let requested_permissions = runtime
@@ -139,17 +143,13 @@ pub async fn accept_client_uri(
.with_relays(preferred_relays.clone())
.with_approval_requirement(approval_requirement);
let connection = backend.register_connection(draft)?;
- if approval_requirement
- == RadrootsNostrSignerApprovalRequirement::NotRequired
- {
+ if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired {
let granted_permissions = runtime
.signer_context()
.policy()
.auto_granted_permissions(&connection.requested_permissions);
- let _ = backend.set_granted_permissions(
- &connection.connection_id,
- granted_permissions,
- )?;
+ let _ = backend
+ .set_granted_permissions(&connection.connection_id, granted_permissions)?;
}
Box::new(connection)
}
@@ -158,11 +158,19 @@ pub async fn accept_client_uri(
let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone());
let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string();
let event = handler.build_response_event(
- client_uri.client_public_key,
+ client_public_key,
response_request_id.clone(),
- RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret),
+ RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret().to_owned()),
)?;
- let response_relays = merge_relays(&client_uri.relays, &preferred_relays);
+ let client_relays = client_uri
+ .relays()
+ .iter()
+ .map(|relay| {
+ nostr::RelayUrl::parse(&relay.to_string())
+ .expect("NIP-46 client URI relays were already validated")
+ })
+ .collect::<Vec<_>>();
+ let response_relays = merge_relays(&client_relays, &preferred_relays);
let workflow = workflow_from_transition(
backend.begin_connect_secret_publish_finalization(&connection.connection_id)?,
"connect accept",
@@ -649,7 +657,7 @@ fn workflow_from_transition(
fn build_control_outbox_record(
kind: MycDeliveryOutboxKind,
- event: radroots_nostr::prelude::RadrootsNostrEvent,
+ event: crate::nostr_contract::RadrootsNostrEvent,
relay_urls: &[nostr::RelayUrl],
connection_id: Option<&RadrootsNostrSignerConnectionId>,
request_id: Option<&str>,
@@ -796,10 +804,7 @@ mod tests {
use super::{accept_client_uri, authorize_auth_challenge};
use crate::app::MycRuntime;
use crate::config::{MycConfig, MycConnectionApproval};
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr_connect::prelude::{
- RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectUri,
- };
+ use crate::host_identity::RadrootsIdentity;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
@@ -841,7 +846,7 @@ mod tests {
let manager = runtime.signer_manager().expect("manager");
let connection = manager
.register_connection(
- radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft::new(
+ crate::signer::prelude::RadrootsNostrSignerConnectionDraft::new(
nostr::Keys::generate().public_key(),
runtime.user_public_identity(),
),
@@ -868,13 +873,14 @@ mod tests {
let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| {
config.policy.denied_client_pubkeys = vec![denied_identity.public_key().to_hex()];
});
- let uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri {
- client_public_key: denied_identity.public_key(),
- relays: vec![nostr::RelayUrl::parse("ws://127.0.0.1:65500").expect("relay")],
- secret: "client-secret".to_owned(),
- metadata: RadrootsNostrConnectClientMetadata::default(),
- })
- .to_string();
+ let mut query = url::form_urlencoded::Serializer::new(String::new());
+ query.append_pair("relay", "ws://127.0.0.1:65500");
+ query.append_pair("secret", "client-secret");
+ let uri = format!(
+ "nostrconnect://{}?{}",
+ denied_identity.final_public_key(),
+ query.finish()
+ );
let error = accept_client_uri(&runtime, &uri)
.await
diff --git a/src/custody.rs b/src/custody.rs
@@ -5,17 +5,19 @@ use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
-use nostr::nips::nip44::Version;
-use nostr::nips::{nip04, nip44};
-use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
-use radroots_nostr::prelude::{
+use crate::accounts::{
+ RadrootsNostrAccountsManager, RadrootsSecretVault, RadrootsSecretVaultOsKeyring,
+};
+use crate::host_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
+use crate::nostr_contract::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrExternalSigningRequest,
RadrootsNostrGenericEventBuilder, RadrootsNostrPublicKey,
};
-use radroots_nostr_accounts::prelude::{
- RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsManager,
+use nostr::nips::nip44::Version;
+use nostr::nips::{nip04, nip44};
+use radroots_identity::account::{
+ Record as RadrootsNostrAccountRecord, Status as RadrootsNostrAccountStatus,
};
-use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::runtime::RuntimeFlavor;
@@ -993,19 +995,24 @@ impl MycIdentityProvider {
role: self.role.clone(),
path: account_store_path.clone(),
service_name: service_name.clone(),
- account_id: account.account_id.to_string(),
+ account_id: account.id().to_string(),
})
}
RadrootsNostrAccountStatus::Ready { .. } => manager
- .default_signing_identity()
+ .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!(
@@ -1104,7 +1111,7 @@ impl MycIdentityProvider {
match &self.backend {
MycIdentityProviderBackend::ManagedAccount { manager, .. } => {
manager
- .upsert_identity(&identity, label, true)
+ .upsert_keys(identity.keys(), label, true)
.map_err(|source| MycError::CustodyManager {
role: self.role.clone(),
source,
@@ -1182,7 +1189,7 @@ impl MycIdentityProvider {
let account_id = {
let manager = self.managed_accounts_manager()?;
manager
- .generate_identity(label, make_selected)
+ .generate_keys(label, make_selected)
.map_err(|source| MycError::CustodyManager {
role: self.role.clone(),
source,
@@ -1206,7 +1213,7 @@ impl MycIdentityProvider {
let manager = self.managed_accounts_manager()?;
let identity = RadrootsIdentity::load_from_path_auto(path).map_err(MycError::from)?;
manager
- .upsert_identity(&identity, label, make_selected)
+ .upsert_keys(identity.keys(), label, make_selected)
.map_err(|source| MycError::CustodyManager {
role: self.role.clone(),
source,
@@ -1229,12 +1236,12 @@ impl MycIdentityProvider {
})?;
{
let manager = self.managed_accounts_manager()?;
- manager.set_default_account(&account_id).map_err(|source| {
- MycError::CustodyManager {
+ 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(),
@@ -1254,7 +1261,7 @@ impl MycIdentityProvider {
{
let manager = self.managed_accounts_manager()?;
manager
- .remove_account(&account_id)
+ .remove_account(&account_id.to_final().into())
.map_err(|source| MycError::CustodyManager {
role: self.role.clone(),
source,
@@ -1502,10 +1509,10 @@ impl MycIdentityProvider {
let (selected_account_id, selected_account_label, identity_id, public_key_hex) =
match account_result {
Ok(Some(account)) => (
- Some(account.account_id.to_string()),
- account.label.clone(),
- Some(account.account_id.to_string()),
- Some(account.public_identity.public_key_hex),
+ 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) => {
@@ -1556,7 +1563,7 @@ impl MycIdentityProvider {
role: self.role.clone(),
path: account_store_path.clone(),
service_name: service_name.clone(),
- account_id: account.account_id.to_string(),
+ account_id: account.id().to_string(),
}
.to_string(),
),
@@ -1569,6 +1576,11 @@ impl MycIdentityProvider {
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())),
};
@@ -1634,6 +1646,7 @@ impl MycIdentityProvider {
MycManagedAccountSelectionState::PublicOnly
}
RadrootsNostrAccountStatus::Ready { .. } => MycManagedAccountSelectionState::Ready,
+ _ => MycManagedAccountSelectionState::PublicOnly,
};
Ok(MycManagedAccountsOutput {
@@ -1854,7 +1867,7 @@ fn validate_external_command_public_identity(
),
}
})?;
- let expected_id = RadrootsIdentityId::from(public_key);
+ let expected_id = RadrootsIdentityId::from_public_key(public_key)?;
if identity.id != expected_id {
return Err(MycError::CustodyExternalCommandInvalidIdentity {
role: role.to_owned(),
@@ -1885,12 +1898,12 @@ mod tests {
use std::sync::Mutex;
use std::time::Instant;
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr_accounts::prelude::{
+ use crate::accounts::RadrootsSecretVault;
+ use crate::accounts::{
RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore,
RadrootsNostrSecretVaultMemory,
};
- use radroots_secret_vault::RadrootsSecretVault;
+ use crate::host_identity::RadrootsIdentity;
use super::*;
@@ -2613,7 +2626,7 @@ mod tests {
assert!(matches!(
error,
MycError::Nostr(
- radroots_nostr::prelude::RadrootsNostrError::ExternalSigningEventIdMismatch { .. }
+ crate::nostr_contract::RadrootsNostrError::ExternalSigningEventIdMismatch { .. }
)
));
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -3,14 +3,14 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
-use radroots_nostr::prelude::{
- RadrootsNostrApplicationHandlerSpec, RadrootsNostrError, RadrootsNostrEvent,
- RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl,
+use crate::nostr_contract::{
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrEvent, RadrootsNostrFilter,
+ RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl,
radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag,
radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value,
};
-use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
-use radroots_nostr_signer::prelude::RadrootsNostrSignerRequestId;
+use crate::signer::prelude::RadrootsNostrSignerRequestId;
+use radroots_nostr_connect::prelude::RadrootsNostrConnectUri;
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
@@ -480,11 +480,15 @@ impl MycDiscoveryContext {
}
fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec {
- let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND]);
- spec.identifier = Some(self.handler_identifier.clone());
- spec.metadata = self.metadata.clone();
- spec.relays = self.public_relays.iter().map(ToString::to_string).collect();
- spec.nostrconnect_url = self.nostrconnect_url.clone();
+ let mut spec = RadrootsNostrApplicationHandlerSpec::new(vec![NIP46_RPC_KIND])
+ .with_identifier(self.handler_identifier.clone())
+ .with_relays(self.public_relays.iter().map(ToString::to_string).collect());
+ if let Some(metadata) = self.metadata.clone() {
+ spec = spec.with_metadata(metadata);
+ }
+ if let Some(url) = self.nostrconnect_url.clone() {
+ spec = spec.with_nostr_connect_url(url);
+ }
spec
}
}
@@ -1443,7 +1447,7 @@ async fn fetch_live_nip89_events_for_relay(
Duration::from_secs(context.connect_timeout_secs()),
)
.await
- .map_err(RadrootsNostrError::from)?;
+ .map_err(MycError::from)?;
let mut filter = RadrootsNostrFilter::new()
.author(context.app_identity().public_key())
@@ -2092,12 +2096,13 @@ fn render_nostrconnect_url(
signer_identity: &MycActiveIdentity,
public_relays: &[RadrootsNostrRelayUrl],
) -> Result<String, MycError> {
- let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri {
- remote_signer_public_key: signer_identity.public_key(),
- relays: public_relays.to_vec(),
- secret: None,
- })
- .to_string();
+ let signer_public_key = signer_identity.public_identity().public_key();
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for relay in public_relays {
+ serializer.append_pair("relay", relay.as_str());
+ }
+ let bunker_uri = format!("bunker://{signer_public_key}?{}", serializer.finish());
+ let bunker_uri = RadrootsNostrConnectUri::parse(&bunker_uri)?.to_string();
let encoded_bunker_uri: String =
url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect();
let rendered = template.replace("<nostrconnect>", &encoded_bunker_uri);
@@ -2114,8 +2119,8 @@ mod tests {
use std::fs;
use std::path::{Path, PathBuf};
+ use crate::host_identity::RadrootsIdentity;
use nostr::JsonUtil;
- use radroots_identity::RadrootsIdentity;
use crate::config::MycConfig;
diff --git a/src/error.rs b/src/error.rs
@@ -1,12 +1,12 @@
use std::net::SocketAddr;
use std::path::PathBuf;
-use radroots_identity::IdentityError;
-use radroots_nostr::prelude::RadrootsNostrError;
-use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError;
+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::prelude::RadrootsNostrConnectError;
-use radroots_nostr_signer::prelude::RadrootsNostrSignerError;
-use radroots_sql_core::error::SqlError;
use thiserror::Error;
use crate::config::MycTransportDeliveryPolicy;
@@ -324,6 +324,12 @@ pub enum MycError {
},
}
+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 {
diff --git a/src/host_identity.rs b/src/host_identity.rs
@@ -0,0 +1,327 @@
+//! 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,101 +1,341 @@
+use std::ffi::OsString;
+use std::fs::{self, OpenOptions};
+use std::io::Write;
use std::path::{Path, PathBuf};
-use radroots_identity::{
- IdentityError, RadrootsIdentity, RadrootsIdentityPublic,
- encrypted_identity_wrapping_key_path as shared_encrypted_identity_wrapping_key_path,
- load_encrypted_identity_with_key_slot, load_identity_profile as load_shared_identity_profile,
- rotate_encrypted_identity_with_key_slot, store_encrypted_identity_with_key_slot,
- store_identity_profile as store_shared_identity_profile,
+use chacha20poly1305::aead::{Aead, KeyInit, Payload};
+use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
+use radroots_secrets::envelope::{Nonce, SealMaterial, SealRequest};
+use radroots_secrets::error::Operation;
+use radroots_secrets::id::{BackendKind, KeyVersion};
+use radroots_secrets::wrapping::{
+ BoxFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
};
+use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef};
+use zeroize::Zeroize;
-const MYC_ENCRYPTED_IDENTITY_KEY_SLOT: &str = "myc_identity";
+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 WRAPPED_KEY_VERSION: u8 = 1;
+
+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 {
+ let mut key = self.load_or_create_key()?;
+ let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random();
+ let ciphertext = request.plaintext().expose_secret(|plaintext| {
+ XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt(
+ XNonce::from_slice(&nonce),
+ Payload {
+ msg: plaintext,
+ aad: request.reference().id().as_str().as_bytes(),
+ },
+ )
+ });
+ 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 {
+ let wrapped = request.wrapped().as_bytes();
+ if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != WRAPPED_KEY_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: request.reference().id().as_str().as_bytes(),
+ },
+ );
+ key.zeroize();
+ SecretMaterial::from_slice(
+ &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?,
+ )
+ })
+ }
+}
+
+fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> {
+ Ok(SecretRef::new(
+ SecretId::parse(MYC_IDENTITY_KEY_SLOT)?,
+ BackendKind::External,
+ KeyVersion::new(1)?,
+ ))
+}
+
+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 {
- shared_encrypted_identity_wrapping_key_path(path)
+ 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> {
- store_encrypted_identity_with_key_slot(path, MYC_ENCRYPTED_IDENTITY_KEY_SLOT, identity)
+ 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 envelope = futures_executor::block_on(EncryptedEnvelope::seal(
+ &wrapping,
+ SealRequest::new(
+ identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?,
+ &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 rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> {
- rotate_encrypted_identity_with_key_slot(path, MYC_ENCRYPTED_IDENTITY_KEY_SLOT)
+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 payload = futures_executor::block_on(envelope.open(&wrapping))
+ .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)
}
-pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> {
- load_encrypted_identity_with_key_slot(path, MYC_ENCRYPTED_IDENTITY_KEY_SLOT)
+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> {
- load_shared_identity_profile(path)
+ 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> {
- store_shared_identity_profile(path, identity)
+ 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::*;
- #[test]
- fn encrypted_identity_round_trips() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc.json");
- let identity = RadrootsIdentity::from_secret_key_str(
+ fn identity() -> RadrootsIdentity {
+ RadrootsIdentity::from_secret_key_str(
"1111111111111111111111111111111111111111111111111111111111111111",
)
- .expect("identity");
-
- store_encrypted_identity(&path, &identity).expect("store encrypted identity");
-
- let loaded = load_encrypted_identity(&path).expect("load encrypted identity");
- assert_eq!(loaded.id(), identity.id());
- assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
- assert!(encrypted_identity_wrapping_key_path(&path).is_file());
+ .expect("identity")
}
#[test]
- fn encrypted_identity_rotation_rewraps_key() {
+ fn encrypted_identity_round_trips_and_rotates_wrapping_key() {
let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc.json");
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
-
- store_encrypted_identity(&path, &identity).expect("store encrypted identity");
+ 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 = std::fs::read(&key_path).expect("key before");
-
- rotate_encrypted_identity(&path).expect("rotate encrypted identity");
-
- let after = std::fs::read(&key_path).expect("key after");
- assert_ne!(before, after);
- let loaded = load_encrypted_identity(&path).expect("load rotated identity");
- assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
+ 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()
+ );
}
#[test]
- fn identity_profile_round_trips() {
+ fn public_profile_round_trips() {
let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("profile.json");
- let identity = RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity");
-
+ let path = temp.path().join("identity.json");
+ let identity = identity();
store_identity_profile(&path, &identity).expect("store profile");
-
- let loaded = load_identity_profile(&path).expect("load profile");
- assert_eq!(loaded.id, identity.id());
+ assert_eq!(
+ load_identity_profile(path).expect("load profile").id,
+ identity.id()
+ );
}
}
diff --git a/src/lib.rs b/src/lib.rs
@@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
+pub mod accounts;
pub mod app;
pub mod audit;
mod audit_sqlite;
@@ -9,14 +10,19 @@ pub mod control;
pub mod custody;
pub mod discovery;
pub mod error;
+pub mod host_identity;
pub mod identity_files;
pub mod logging;
+pub mod nostr_contract;
pub mod operability;
pub mod outbox;
mod outbox_sqlite;
mod paths;
pub mod persistence;
pub mod policy;
+pub mod signer;
+mod signing_adapter;
+pub mod sql;
pub mod transport;
pub use app::{
diff --git a/src/logging.rs b/src/logging.rs
@@ -1,22 +1,64 @@
use crate::config::MycLoggingConfig;
use crate::error::MycError;
-use radroots_log::{LogFileLayout, LoggingOptions};
+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> {
- radroots_log::init_logging(LoggingOptions {
- dir: config.output_dir.clone(),
- file_name: "myc.log".to_owned(),
- stdout: config.stdout,
- default_level: Some(config.filter.clone()),
- file_layout: LogFileLayout::StableFileName,
- ..LoggingOptions::default()
- })
- .map_err(|source| MycError::InvalidOperation(format!("failed to initialize logging: {source}")))
+ 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 radroots_log::{LogFileLayout, LoggingOptions};
use std::path::PathBuf;
use crate::config::MycConfig;
@@ -46,40 +88,11 @@ MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10
}
#[test]
- fn logging_options_resolve_bounded_stable_file_path() {
- let config = MycConfig::from_env_str(
- r#"
-MYC_LOGGING_FILTER=info,myc=debug
-MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs
-MYC_LOGGING_STDOUT=false
-MYC_PATHS_STATE_DIR=/tmp/myc
-MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json
-MYC_IDENTITY_USER_PATH=/tmp/user.json
-MYC_DISCOVERY_ENABLED=false
-MYC_TRANSPORT_ENABLED=false
-MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10
- "#,
- )
- .expect("config");
-
- let path = LoggingOptions {
- dir: config.logging.output_dir.clone(),
- file_name: "myc.log".to_owned(),
- stdout: config.logging.stdout,
- default_level: Some(config.logging.filter.clone()),
- file_layout: LogFileLayout::StableFileName,
- ..LoggingOptions::default()
- }
- .resolved_current_log_file_path()
- .expect("resolved log path");
-
- assert_eq!(
- path.parent(),
- Some(PathBuf::from("/tmp/myc-logs").as_path())
- );
+ fn stable_log_path_is_host_owned() {
+ let directory = PathBuf::from("/tmp/myc-logs");
assert_eq!(
- path.file_name().and_then(|value| value.to_str()),
- Some("myc.log")
+ directory.join("myc.log"),
+ PathBuf::from("/tmp/myc-logs/myc.log")
);
}
}
diff --git a/src/nostr_contract.rs b/src/nostr_contract.rs
@@ -0,0 +1,114 @@
+//! 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/operability/mod.rs b/src/operability/mod.rs
@@ -5,14 +5,14 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
-use radroots_nostr::prelude::{RadrootsNostrRelayStatus, RadrootsNostrRelayUrl};
-use radroots_nostr_signer::prelude::{
+use crate::nostr_contract::{RadrootsNostrRelayStatus, RadrootsNostrRelayUrl};
+use crate::signer::prelude::{
RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability,
RadrootsNostrSignerBackend, RadrootsNostrSignerPublishWorkflowRecord,
RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerRequestAuditRecord,
RadrootsNostrSignerRequestDecision,
};
-use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor};
+use crate::sql::{SqlExecutor, SqlxSqliteExecutor};
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
@@ -1648,12 +1648,12 @@ mod tests {
use std::path::Path;
use std::path::PathBuf;
- use nostr::PublicKey;
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr_signer::prelude::{
+ use crate::host_identity::RadrootsIdentity;
+ use crate::signer::prelude::{
RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerRequestDecision,
};
+ use nostr::PublicKey;
use super::{
MYC_SIGNER_STATUS_CONTRACT_VERSION, MycMetricsSnapshot, MycOperationOutcomeCounts,
@@ -1819,7 +1819,7 @@ mod tests {
#[tokio::test(flavor = "current_thread")]
async fn status_full_reports_signer_backend_capabilities() {
- use radroots_nostr_signer::prelude::{
+ use crate::signer::prelude::{
RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft,
};
@@ -1864,7 +1864,7 @@ mod tests {
#[test]
fn status_signer_reports_remote_sessions_without_transport_diagnostics() {
- use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend;
+ use crate::signer::prelude::RadrootsNostrSignerBackend;
let temp = tempfile::tempdir().expect("tempdir");
let mut config = MycConfig::default();
diff --git a/src/outbox.rs b/src/outbox.rs
@@ -2,10 +2,8 @@ use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
-use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrRelayUrl};
-use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId,
-};
+use crate::nostr_contract::{RadrootsNostrEvent, RadrootsNostrRelayUrl};
+use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -248,11 +246,9 @@ pub(crate) fn now_unix_secs() -> u64 {
#[cfg(test)]
mod tests {
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
- use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId,
- };
+ use crate::host_identity::RadrootsIdentity;
+ use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
+ use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId};
use super::{
MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord,
diff --git a/src/outbox_sqlite.rs b/src/outbox_sqlite.rs
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
-use radroots_sql_core::migrations::{Migration, migrations_run_all_up};
-use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor};
+use crate::sql::migrations::{Migration, migrations_run_all_up};
+use crate::sql::{SqlExecutor, SqlxSqliteExecutor};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
@@ -494,11 +494,9 @@ fn usize_from_i64(path: &Path, value: i64, field: &str) -> Result<usize, MycErro
#[cfg(test)]
mod tests {
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
- use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId,
- };
+ use crate::host_identity::RadrootsIdentity;
+ use crate::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
+ use crate::signer::prelude::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerWorkflowId};
use crate::outbox::{
MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus,
diff --git a/src/paths.rs b/src/paths.rs
@@ -1,8 +1,5 @@
use std::path::{Path, PathBuf};
-use radroots_runtime_paths::{
- RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimePathSelection,
-};
use serde::{Deserialize, Serialize};
use crate::{
@@ -27,6 +24,230 @@ const DEFAULT_DISCOVERY_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json";
const MYC_PATHS_PROFILE_ENV: &str = "MYC_PATHS_PROFILE";
const MYC_PATHS_REPO_LOCAL_ROOT_ENV: &str = "MYC_PATHS_REPO_LOCAL_ROOT";
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[allow(dead_code)]
+pub enum RadrootsPlatform {
+ Linux,
+ Macos,
+ Windows,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct RadrootsHostEnvironment {
+ pub home_dir: Option<PathBuf>,
+ pub appdata_dir: Option<PathBuf>,
+ pub localappdata_dir: Option<PathBuf>,
+ pub programdata_dir: Option<PathBuf>,
+}
+
+impl RadrootsHostEnvironment {
+ fn current() -> Self {
+ Self {
+ home_dir: std::env::var_os("HOME").map(PathBuf::from),
+ appdata_dir: std::env::var_os("APPDATA").map(PathBuf::from),
+ localappdata_dir: std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
+ programdata_dir: std::env::var_os("PROGRAMDATA").map(PathBuf::from),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[allow(dead_code)]
+pub enum RadrootsPathProfile {
+ InteractiveUser,
+ ServiceHost,
+ RepoLocal,
+ MobileNative,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsPathResolver {
+ platform: RadrootsPlatform,
+ environment: RadrootsHostEnvironment,
+}
+
+impl RadrootsPathResolver {
+ pub const fn new(platform: RadrootsPlatform, environment: RadrootsHostEnvironment) -> Self {
+ Self {
+ platform,
+ environment,
+ }
+ }
+
+ pub fn current() -> Self {
+ #[cfg(target_os = "windows")]
+ let platform = RadrootsPlatform::Windows;
+ #[cfg(target_os = "macos")]
+ let platform = RadrootsPlatform::Macos;
+ #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
+ let platform = RadrootsPlatform::Linux;
+ Self::new(platform, RadrootsHostEnvironment::current())
+ }
+
+ fn roots(
+ &self,
+ profile: RadrootsPathProfile,
+ repo_local_root: Option<&Path>,
+ ) -> Result<RuntimeRoots, String> {
+ match profile {
+ RadrootsPathProfile::RepoLocal => repo_local_root
+ .map(RuntimeRoots::from_base)
+ .ok_or_else(|| "repo_local requires an explicit root".to_owned()),
+ RadrootsPathProfile::ServiceHost => match self.platform {
+ RadrootsPlatform::Linux | RadrootsPlatform::Macos => Ok(RuntimeRoots {
+ config: PathBuf::from("/etc/radroots"),
+ data: PathBuf::from("/var/lib/radroots"),
+ logs: PathBuf::from("/var/log/radroots"),
+ run: PathBuf::from("/run/radroots"),
+ secrets: PathBuf::from("/etc/radroots/secrets"),
+ }),
+ RadrootsPlatform::Windows => {
+ let base = self
+ .environment
+ .programdata_dir
+ .as_deref()
+ .ok_or_else(|| "PROGRAMDATA is required".to_owned())?
+ .join("Radroots");
+ Ok(RuntimeRoots::from_base(&base))
+ }
+ },
+ RadrootsPathProfile::InteractiveUser | RadrootsPathProfile::MobileNative => {
+ match self.platform {
+ RadrootsPlatform::Linux | RadrootsPlatform::Macos => {
+ let base = self
+ .environment
+ .home_dir
+ .as_deref()
+ .ok_or_else(|| "HOME is required".to_owned())?
+ .join(".radroots");
+ Ok(RuntimeRoots::from_base(&base))
+ }
+ RadrootsPlatform::Windows => {
+ let roaming = self
+ .environment
+ .appdata_dir
+ .as_deref()
+ .ok_or_else(|| "APPDATA is required".to_owned())?
+ .join("Radroots");
+ let local = self
+ .environment
+ .localappdata_dir
+ .as_deref()
+ .ok_or_else(|| "LOCALAPPDATA is required".to_owned())?
+ .join("Radroots");
+ Ok(RuntimeRoots {
+ config: roaming.join("config"),
+ data: local.join("data"),
+ logs: local.join("logs"),
+ run: local.join("run"),
+ secrets: roaming.join("secrets"),
+ })
+ }
+ }
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct RuntimeRoots {
+ config: PathBuf,
+ data: PathBuf,
+ logs: PathBuf,
+ run: PathBuf,
+ secrets: PathBuf,
+}
+
+impl RuntimeRoots {
+ fn from_base(base: &Path) -> Self {
+ Self {
+ config: base.join("config"),
+ data: base.join("data"),
+ logs: base.join("logs"),
+ run: base.join("run"),
+ secrets: base.join("secrets"),
+ }
+ }
+
+ fn service(self, service: &str) -> Self {
+ Self {
+ config: self.config.join("services").join(service),
+ data: self.data.join("services").join(service),
+ logs: self.logs.join("services").join(service),
+ run: self.run.join("services").join(service),
+ secrets: self.secrets.join("services").join(service),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsRuntimePathSelection {
+ pub profile: RadrootsPathProfile,
+ pub repo_local_root: Option<PathBuf>,
+}
+
+impl RadrootsRuntimePathSelection {
+ pub fn caller(profile: RadrootsPathProfile, repo_local_root: Option<PathBuf>) -> Self {
+ Self {
+ profile,
+ repo_local_root,
+ }
+ }
+
+ pub fn from_env(
+ profile_env: &str,
+ root_env: &str,
+ default_profile: RadrootsPathProfile,
+ ) -> Result<Self, String> {
+ let profile = match std::env::var(profile_env).ok().as_deref() {
+ None => default_profile,
+ Some("interactive_user") => RadrootsPathProfile::InteractiveUser,
+ Some("service_host") => RadrootsPathProfile::ServiceHost,
+ Some("repo_local") => RadrootsPathProfile::RepoLocal,
+ Some(value) => return Err(format!("unknown path profile `{value}`")),
+ };
+ let repo_local_root = std::env::var_os(root_env)
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from);
+ if profile == RadrootsPathProfile::RepoLocal && repo_local_root.is_none() {
+ return Err(format!("{root_env} is required for repo_local"));
+ }
+ Ok(Self {
+ profile,
+ repo_local_root,
+ })
+ }
+
+ fn resolve_service_roots(
+ &self,
+ resolver: &RadrootsPathResolver,
+ service: &str,
+ _profile_env: &str,
+ _root_env: &str,
+ ) -> Result<RuntimeRoots, String> {
+ Ok(resolver
+ .roots(self.profile, self.repo_local_root.as_deref())?
+ .service(service))
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsRuntimePathPolicyContract {
+ pub canonical_root_selection: String,
+ pub canonical_subordinate_path_override: String,
+ pub leaf_path_env_posture: String,
+}
+
+impl RadrootsRuntimePathPolicyContract {
+ pub fn new(root: &str, subordinate: &str, leaf: &str) -> Self {
+ Self {
+ canonical_root_selection: root.to_owned(),
+ canonical_subordinate_path_override: subordinate.to_owned(),
+ leaf_path_env_posture: leaf.to_owned(),
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MycPathsConfig {
diff --git a/src/persistence.rs b/src/persistence.rs
@@ -3,13 +3,13 @@ use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
-use nostr::PublicKey;
-use radroots_nostr_signer::prelude::{
+use crate::signer::prelude::{
RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState,
RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind,
RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore,
};
+use nostr::PublicKey;
use serde::{Deserialize, Serialize};
use crate::app::MycRuntimePaths;
@@ -1145,7 +1145,7 @@ fn now_unix_secs() -> u64 {
}
fn signer_store_state_is_empty(
- state: &radroots_nostr_signer::prelude::RadrootsNostrSignerStoreState,
+ state: &crate::signer::prelude::RadrootsNostrSignerStoreState,
) -> bool {
state.signer_identity.is_none()
&& state.connections.is_empty()
@@ -1452,17 +1452,17 @@ fn verify_already_finalized_without_workflow(
mod tests {
use std::path::{Path, PathBuf};
- use nostr::PublicKey;
- use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{
+ use crate::host_identity::RadrootsIdentity;
+ use crate::nostr_contract::{
RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrKind,
};
- use radroots_nostr_signer::prelude::{
+ use crate::signer::prelude::{
RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrFileSignerStore,
RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId,
RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
RadrootsNostrSqliteSignerStore,
};
+ use nostr::PublicKey;
use super::{
MycPersistenceImportSelection, import_json_to_sqlite, signer_store_state_is_empty,
@@ -1551,7 +1551,7 @@ mod tests {
#[test]
fn signer_store_state_is_not_empty_when_only_publish_workflows_are_present() {
- let workflow = radroots_nostr_signer::prelude::RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
+ let workflow = crate::signer::prelude::RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
RadrootsNostrSignerConnectionId::parse("workflow-only-connection")
.expect("workflow connection id"),
17,
diff --git a/src/policy.rs b/src/policy.rs
@@ -2,16 +2,16 @@ 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::prelude::{
RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage,
};
-use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend,
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager,
- RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Policy,
-};
use crate::config::{MycConnectionApproval, MycPolicyConfig};
use crate::error::MycError;
@@ -190,8 +190,7 @@ impl MycPolicyContext {
return Ok(Some(reason));
}
- if connection.auth_state
- == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
+ 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) {
@@ -219,8 +218,7 @@ impl MycPolicyContext {
&self,
connection: &RadrootsNostrSignerConnectionRecord,
) -> Result<(), MycError> {
- if connection.auth_state
- == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
+ if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending
&& self.auth_challenge_is_expired(connection)
{
return Err(MycError::InvalidOperation(
@@ -317,9 +315,7 @@ impl MycPolicyContext {
return false;
}
- if connection.auth_state
- == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
- {
+ if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending {
return false;
}
@@ -411,7 +407,7 @@ impl MycPolicyContext {
) -> bool {
if connection.is_terminal()
|| connection.auth_state
- != radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Authorized
+ != crate::signer::prelude::RadrootsNostrSignerAuthState::Authorized
|| !self.automatic_auth_enabled_for_connection(connection)
{
return false;
@@ -483,7 +479,7 @@ impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for MycPol
backend: &B,
connection: &RadrootsNostrSignerConnectionRecord,
request_message: &RadrootsNostrConnectRequestMessage,
- ) -> Result<Option<String>, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<Option<String>, crate::signer::prelude::RadrootsNostrSignerError> {
self.prepare_request(backend, connection, request_message)
.map_err(myc_policy_signer_error)
}
@@ -562,7 +558,7 @@ fn required_permission_for_request(
RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
Some(RadrootsNostrConnectPermission::with_parameter(
RadrootsNostrConnectMethod::SignEvent,
- format!("kind:{}", unsigned_event.kind.as_u16()),
+ format!("kind:{}", unsigned_event.kind()),
))
}
RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some(
@@ -668,28 +664,26 @@ fn now_unix_secs() -> u64 {
.unwrap_or_default()
}
-fn myc_policy_signer_error(
- error: MycError,
-) -> radroots_nostr_signer::prelude::RadrootsNostrSignerError {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::InvalidState(error.to_string())
+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_identity::RadrootsIdentity;
use radroots_nostr_connect::prelude::{
RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage,
};
- use radroots_nostr_signer::prelude::{
- RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerApprovalRequirement,
- RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
- RadrootsNostrSignerManager,
- };
use serde_json::json;
use std::thread;
use std::time::Duration;
@@ -716,7 +710,9 @@ mod tests {
fn backend_for(manager: &RadrootsNostrSignerManager) -> RadrootsNostrEmbeddedSignerBackend {
RadrootsNostrEmbeddedSignerBackend::new(
manager.clone(),
- identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
+ identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ .keys()
+ .clone(),
)
.expect("backend")
}
@@ -724,7 +720,7 @@ mod tests {
fn register_connection(
manager: &RadrootsNostrSignerManager,
client_public_key: PublicKey,
- ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionRecord {
+ ) -> crate::signer::prelude::RadrootsNostrSignerConnectionRecord {
manager
.register_connection(
RadrootsNostrSignerConnectionDraft::new(
@@ -744,14 +740,14 @@ mod tests {
.expect("register connection")
}
- fn unsigned_event(kind: u16) -> nostr::UnsignedEvent {
- serde_json::from_value(json!({
+ 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")
}
@@ -816,7 +812,7 @@ mod tests {
.into();
let filtered = policy.auto_granted_permissions(&requested_permissions);
- assert_eq!(filtered.to_string(), "sign_event:kind:1,nip04_encrypt");
+ assert_eq!(filtered.to_string(), "nip04_encrypt,sign_event:kind:1");
}
#[test]
@@ -957,7 +953,7 @@ mod tests {
&connection.connection_id,
"request-0",
RadrootsNostrConnectMethod::SignEvent,
- radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed,
+ crate::signer::prelude::RadrootsNostrSignerRequestDecision::Allowed,
None,
)
.expect("record request");
diff --git a/src/signer/backend.rs b/src/signer/backend.rs
@@ -0,0 +1,1753 @@
+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
@@ -0,0 +1,330 @@
+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
@@ -0,0 +1,127 @@
+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
@@ -0,0 +1,519 @@
+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
@@ -0,0 +1,4004 @@
+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/migrations.rs b/src/signer/migrations.rs
@@ -0,0 +1,35 @@
+use crate::sql::SqlExecutor;
+use crate::sql::error::SqlError;
+use crate::sql::migrations::{Migration, migrations_run_all_down, migrations_run_all_up};
+
+pub static MIGRATIONS: &[Migration] = &[
+ Migration {
+ name: "0000_init",
+ up_sql: include_str!("../../migrations/signer/0000_init.up.sql"),
+ down_sql: include_str!("../../migrations/signer/0000_init.down.sql"),
+ },
+ Migration {
+ name: "0001_publish_workflows",
+ up_sql: include_str!("../../migrations/signer/0001_publish_workflows.up.sql"),
+ down_sql: include_str!("../../migrations/signer/0001_publish_workflows.down.sql"),
+ },
+ Migration {
+ name: "0002_client_metadata",
+ up_sql: include_str!("../../migrations/signer/0002_client_metadata.up.sql"),
+ down_sql: include_str!("../../migrations/signer/0002_client_metadata.down.sql"),
+ },
+];
+
+pub fn run_all_up<E>(executor: &E) -> Result<(), SqlError>
+where
+ E: SqlExecutor,
+{
+ migrations_run_all_up(executor, MIGRATIONS)
+}
+
+pub fn run_all_down<E>(executor: &E) -> Result<(), SqlError>
+where
+ E: SqlExecutor,
+{
+ migrations_run_all_down(executor, MIGRATIONS)
+}
diff --git a/src/signer/mod.rs b/src/signer/mod.rs
@@ -0,0 +1,65 @@
+//! 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
@@ -0,0 +1,1594 @@
+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
@@ -0,0 +1,2191 @@
+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/sqlite.rs b/src/signer/sqlite.rs
@@ -0,0 +1,291 @@
+use crate::signer::error::RadrootsNostrSignerError;
+use crate::signer::migrations;
+use crate::sql::{SqlExecutor, SqlxSqliteExecutor};
+use serde::Deserialize;
+use std::path::Path;
+
+#[derive(Deserialize)]
+struct SqliteJournalModeRow {
+ journal_mode: String,
+}
+
+pub struct RadrootsNostrSignerSqliteDb {
+ executor: SqlxSqliteExecutor,
+ file_backed: bool,
+}
+
+impl RadrootsNostrSignerSqliteDb {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self, RadrootsNostrSignerError> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?;
+ }
+ let executor = SqlxSqliteExecutor::open(path)?;
+ let db = Self {
+ executor,
+ file_backed: true,
+ };
+ db.configure()?;
+ db.migrate_up()?;
+ Ok(db)
+ }
+
+ pub fn open_memory() -> Result<Self, RadrootsNostrSignerError> {
+ let executor = SqlxSqliteExecutor::open_memory()?;
+ let db = Self {
+ executor,
+ file_backed: false,
+ };
+ db.configure()?;
+ db.migrate_up()?;
+ Ok(db)
+ }
+
+ pub fn executor(&self) -> &SqlxSqliteExecutor {
+ &self.executor
+ }
+
+ pub fn migrate_up(&self) -> Result<(), RadrootsNostrSignerError> {
+ migrations::run_all_up(&self.executor)?;
+ Ok(())
+ }
+
+ pub fn migrate_down(&self) -> Result<(), RadrootsNostrSignerError> {
+ migrations::run_all_down(&self.executor)?;
+ Ok(())
+ }
+
+ fn configure(&self) -> Result<(), RadrootsNostrSignerError> {
+ let pragma_batch = if self.file_backed {
+ "PRAGMA foreign_keys = ON;
+ PRAGMA synchronous = FULL;
+ PRAGMA wal_autocheckpoint = 1000;
+ PRAGMA busy_timeout = 5000;
+ PRAGMA temp_store = MEMORY;"
+ } else {
+ "PRAGMA foreign_keys = ON;
+ PRAGMA synchronous = NORMAL;
+ PRAGMA busy_timeout = 5000;
+ PRAGMA temp_store = MEMORY;"
+ };
+ let _ = self.executor.exec(pragma_batch, "[]")?;
+ let (journal_mode_sql, expected_journal_mode) = if self.file_backed {
+ ("PRAGMA main.journal_mode = WAL", "wal")
+ } else {
+ ("PRAGMA main.journal_mode = MEMORY", "memory")
+ };
+ let result = self.executor.query_raw(journal_mode_sql, "[]")?;
+ validate_journal_mode_result(&result, expected_journal_mode)
+ }
+}
+
+fn validate_journal_mode_result(
+ result: &str,
+ expected: &'static str,
+) -> Result<(), RadrootsNostrSignerError> {
+ let rows: Vec<SqliteJournalModeRow> = serde_json::from_str(result)?;
+ let [row] = rows.as_slice() else {
+ return Err(
+ RadrootsNostrSignerError::SqliteJournalModeResultCardinality {
+ actual_rows: rows.len(),
+ },
+ );
+ };
+ if row.journal_mode != expected {
+ return Err(RadrootsNostrSignerError::SqliteJournalModeMismatch {
+ expected,
+ actual: row.journal_mode.clone(),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RadrootsNostrSignerSqliteDb, validate_journal_mode_result};
+ use crate::signer::error::RadrootsNostrSignerError;
+ use crate::sql::SqlExecutor;
+ use serde_json::Value;
+
+ fn query_values(
+ db: &RadrootsNostrSignerSqliteDb,
+ sql: &str,
+ ) -> Vec<serde_json::Map<String, Value>> {
+ let raw = db.executor().query_raw(sql, "[]").expect("query");
+ serde_json::from_str::<Vec<serde_json::Map<String, Value>>>(&raw).expect("rows")
+ }
+
+ fn query_single_text(db: &RadrootsNostrSignerSqliteDb, sql: &str, field: &str) -> String {
+ query_values(db, sql)
+ .into_iter()
+ .next()
+ .and_then(|row| row.get(field).cloned())
+ .and_then(|value| value.as_str().map(ToOwned::to_owned))
+ .expect("single text row")
+ }
+
+ fn query_single_i64(db: &RadrootsNostrSignerSqliteDb, sql: &str, field: &str) -> i64 {
+ query_values(db, sql)
+ .into_iter()
+ .next()
+ .and_then(|row| row.get(field).cloned())
+ .and_then(|value| value.as_i64())
+ .expect("single integer row")
+ }
+
+ #[test]
+ fn open_memory_bootstraps_schema_and_migrations_idempotently() {
+ let db = RadrootsNostrSignerSqliteDb::open_memory().expect("open memory db");
+ db.migrate_up().expect("rerun migrations");
+ assert_eq!(
+ query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"),
+ "memory"
+ );
+
+ let tables = query_values(
+ &db,
+ "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
+ );
+ let table_names = tables
+ .into_iter()
+ .filter_map(|row| {
+ row.get("name")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned)
+ })
+ .collect::<Vec<_>>();
+ assert!(table_names.iter().any(|name| name == "__migrations"));
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_store_metadata")
+ );
+ assert!(table_names.iter().any(|name| name == "signer_connection"));
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_connection_permission_grant")
+ );
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_connection_relay")
+ );
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_connection_auth_challenge")
+ );
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_connection_pending_request")
+ );
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_request_audit")
+ );
+ assert!(
+ table_names
+ .iter()
+ .any(|name| name == "signer_publish_workflow")
+ );
+
+ let migration_count = query_single_i64(
+ &db,
+ "SELECT COUNT(*) AS applied_count FROM __migrations",
+ "applied_count",
+ );
+ assert_eq!(migration_count, 3);
+
+ let connection_columns = query_values(&db, "PRAGMA table_info(signer_connection)");
+ assert!(connection_columns.iter().any(|row| {
+ row.get("name").and_then(Value::as_str) == Some("client_metadata_json")
+ }));
+
+ let store_version = query_single_i64(
+ &db,
+ "SELECT store_version FROM signer_store_metadata WHERE singleton_id = 1",
+ "store_version",
+ );
+ assert_eq!(store_version, 1);
+ }
+
+ #[test]
+ fn file_database_uses_wal_and_foreign_keys() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("signer.sqlite");
+ {
+ let db = RadrootsNostrSignerSqliteDb::open(&path).expect("open sqlite file db");
+
+ assert_eq!(
+ query_single_text(&db, "PRAGMA main.journal_mode", "journal_mode"),
+ "wal"
+ );
+ assert_eq!(
+ query_single_i64(&db, "PRAGMA foreign_keys", "foreign_keys"),
+ 1
+ );
+ }
+
+ let reopened = RadrootsNostrSignerSqliteDb::open(&path).expect("reopen sqlite file db");
+ assert_eq!(
+ query_single_text(&reopened, "PRAGMA main.journal_mode", "journal_mode"),
+ "wal"
+ );
+ }
+
+ #[test]
+ fn journal_mode_result_validation_fails_closed() {
+ assert!(matches!(
+ validate_journal_mode_result(r#"[{"journal_mode":"delete"}]"#, "wal"),
+ Err(RadrootsNostrSignerError::SqliteJournalModeMismatch {
+ expected: "wal",
+ actual,
+ }) if actual == "delete"
+ ));
+ assert!(matches!(
+ validate_journal_mode_result("[]", "wal"),
+ Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 0 })
+ ));
+ assert!(matches!(
+ validate_journal_mode_result(
+ r#"[{"journal_mode":"wal"},{"journal_mode":"delete"}]"#,
+ "wal"
+ ),
+ Err(RadrootsNostrSignerError::SqliteJournalModeResultCardinality { actual_rows: 2 })
+ ));
+ }
+
+ #[test]
+ fn migrate_down_and_up_roundtrip_restores_schema() {
+ let db = RadrootsNostrSignerSqliteDb::open_memory().expect("open memory db");
+ db.migrate_down().expect("migrate down");
+
+ let tables = query_values(
+ &db,
+ "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
+ );
+ let table_names = tables
+ .into_iter()
+ .filter_map(|row| {
+ row.get("name")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned)
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(table_names, vec!["__migrations".to_owned()]);
+
+ db.migrate_up().expect("migrate up again");
+ let migration_count = query_single_i64(
+ &db,
+ "SELECT COUNT(*) AS applied_count FROM __migrations",
+ "applied_count",
+ );
+ assert_eq!(migration_count, 3);
+ }
+}
diff --git a/src/signer/store.rs b/src/signer/store.rs
@@ -0,0 +1,1097 @@
+use crate::signer::error::RadrootsNostrSignerError;
+use crate::signer::model::RadrootsNostrSignerStoreState;
+use serde::{Deserialize, de::DeserializeOwned};
+use serde_json::{Value, json};
+use std::fs;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
+
+use crate::host_identity::RadrootsIdentityPublic as PublicIdentity;
+use crate::signer::model::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerApprovalState,
+ RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState,
+ RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionRecord,
+ RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind,
+ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
+};
+use crate::signer::sqlite::RadrootsNostrSignerSqliteDb;
+use crate::sql::SqlExecutor;
+use nostr::RelayUrl;
+use radroots_nostr_connect::{Method, Permission, message::RequestMessage, uri::ClientMetadata};
+use std::collections::BTreeMap;
+
+pub trait RadrootsNostrSignerStore: Send + Sync {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError>;
+ fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>;
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsNostrFileSignerStore {
+ path: PathBuf,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct RadrootsNostrMemorySignerStore {
+ state: Arc<RwLock<RadrootsNostrSignerStoreState>>,
+}
+
+#[derive(Clone)]
+pub struct RadrootsNostrSqliteSignerStore {
+ db: Arc<RadrootsNostrSignerSqliteDb>,
+}
+
+impl RadrootsNostrFileSignerStore {
+ pub fn new(path: impl AsRef<Path>) -> Self {
+ Self {
+ path: path.as_ref().to_path_buf(),
+ }
+ }
+
+ pub fn path(&self) -> &Path {
+ self.path.as_path()
+ }
+}
+
+impl RadrootsNostrMemorySignerStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+impl RadrootsNostrSqliteSignerStore {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self, RadrootsNostrSignerError> {
+ Ok(Self {
+ db: Arc::new(RadrootsNostrSignerSqliteDb::open(path)?),
+ })
+ }
+
+ pub fn open_memory() -> Result<Self, RadrootsNostrSignerError> {
+ Ok(Self {
+ db: Arc::new(RadrootsNostrSignerSqliteDb::open_memory()?),
+ })
+ }
+}
+
+impl RadrootsNostrSignerStore for RadrootsNostrFileSignerStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ if !self.path.exists() {
+ return Ok(RadrootsNostrSignerStoreState::default());
+ }
+ let encoded = fs::read(self.path.as_path())
+ .map_err(|_| RadrootsNostrSignerError::Store("read signer state".into()))?;
+ serde_json::from_slice(encoded.as_slice()).map_err(Into::into)
+ }
+
+ fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> {
+ if self.path.exists() {
+ let _ = self.load()?;
+ }
+ let parent = self
+ .path
+ .parent()
+ .filter(|path| !path.as_os_str().is_empty())
+ .unwrap_or_else(|| Path::new("."));
+ fs::create_dir_all(parent)
+ .map_err(|_| RadrootsNostrSignerError::Store("create signer state directory".into()))?;
+ let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|_| {
+ RadrootsNostrSignerError::Store("create signer state temporary file".into())
+ })?;
+ serde_json::to_writer_pretty(&mut temporary, state)?;
+ temporary
+ .write_all(b"\n")
+ .map_err(|_| RadrootsNostrSignerError::Store("write signer state".into()))?;
+ temporary
+ .as_file()
+ .sync_all()
+ .map_err(|_| RadrootsNostrSignerError::Store("sync signer state".into()))?;
+ set_private_file_permissions(temporary.as_file())?;
+ temporary
+ .persist(self.path.as_path())
+ .map_err(|_| RadrootsNostrSignerError::Store("persist signer state".into()))?;
+ fs::File::open(parent)
+ .and_then(|directory| directory.sync_all())
+ .map_err(|_| RadrootsNostrSignerError::Store("sync signer state directory".into()))?;
+ Ok(())
+ }
+}
+
+fn set_private_file_permissions(file: &fs::File) -> Result<(), RadrootsNostrSignerError> {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ file.set_permissions(fs::Permissions::from_mode(0o600))
+ .map_err(|_| RadrootsNostrSignerError::Store("set signer state permissions".into()))
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = file;
+ Ok(())
+ }
+}
+
+impl RadrootsNostrSignerStore for RadrootsNostrMemorySignerStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ let guard = self
+ .state
+ .read()
+ .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?;
+ Ok(guard.clone())
+ }
+
+ fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> {
+ let mut guard = self
+ .state
+ .write()
+ .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?;
+ *guard = state.clone();
+ Ok(())
+ }
+}
+
+impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ let metadata_rows: Vec<SignerStoreMetadataRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT store_version, signer_identity_json FROM signer_store_metadata WHERE singleton_id = 1",
+ )?;
+ let metadata = match metadata_rows.as_slice() {
+ [row] => row,
+ [] => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite signer metadata row missing".into(),
+ ));
+ }
+ _ => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite signer metadata row is not singular".into(),
+ ));
+ }
+ };
+
+ let mut state = RadrootsNostrSignerStoreState {
+ version: u32::try_from(metadata.store_version).map_err(|_| {
+ RadrootsNostrSignerError::Store(format!(
+ "sqlite signer store version {} is out of range",
+ metadata.store_version
+ ))
+ })?,
+ signer_identity: metadata
+ .signer_identity_json
+ .as_deref()
+ .map(parse_json_field::<PublicIdentity>)
+ .transpose()?,
+ connections: Vec::new(),
+ audit_records: Vec::new(),
+ publish_workflows: Vec::new(),
+ };
+
+ let connection_rows: Vec<SignerConnectionRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, client_public_key_hex, signer_identity_json, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, client_metadata_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix FROM signer_connection ORDER BY created_at_unix, connection_id",
+ )?;
+ let mut connection_indexes = BTreeMap::new();
+ for row in connection_rows {
+ let connection = row.into_record()?;
+ connection_indexes.insert(
+ connection.connection_id.as_str().to_owned(),
+ state.connections.len(),
+ );
+ state.connections.push(connection);
+ }
+
+ let permission_rows: Vec<SignerConnectionPermissionGrantRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, permission, granted_at_unix FROM signer_connection_permission_grant ORDER BY connection_id, granted_at_unix, permission",
+ )?;
+ for row in permission_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "permission grant row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index]
+ .granted_permissions
+ .push(row.into_grant()?);
+ }
+
+ let relay_rows: Vec<SignerConnectionRelayRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, relay_url FROM signer_connection_relay ORDER BY connection_id, ordinal",
+ )?;
+ for row in relay_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "relay row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index].relays.push(
+ RelayUrl::parse(row.relay_url.as_str())
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ );
+ }
+
+ let auth_rows: Vec<SignerConnectionAuthChallengeRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, auth_url, required_at_unix, authorized_at_unix FROM signer_connection_auth_challenge",
+ )?;
+ for row in auth_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "auth challenge row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index].auth_challenge = Some(
+ RadrootsNostrSignerAuthChallenge::new(row.auth_url.as_str(), row.required_at_unix)
+ .map(|mut challenge| {
+ challenge.authorized_at_unix = row.authorized_at_unix;
+ challenge
+ })?,
+ );
+ }
+
+ let pending_rows: Vec<SignerConnectionPendingRequestRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, request_message_json, created_at_unix FROM signer_connection_pending_request",
+ )?;
+ for row in pending_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "pending request row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ let request_message =
+ parse_json_field::<RequestMessage>(row.request_message_json.as_str())?;
+ state.connections[index].pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(request_message, row.created_at_unix)?,
+ );
+ }
+
+ let audit_rows: Vec<SignerRequestAuditRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT request_id, connection_id, method, decision, message, created_at_unix FROM signer_request_audit ORDER BY created_at_unix, request_id",
+ )?;
+ state.audit_records = audit_rows
+ .into_iter()
+ .map(SignerRequestAuditRow::into_record)
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let workflow_rows: Vec<SignerPublishWorkflowRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT workflow_id, connection_id, kind, state, pending_request_json, authorized_at_unix, created_at_unix, updated_at_unix FROM signer_publish_workflow ORDER BY created_at_unix, workflow_id",
+ )?;
+ state.publish_workflows = workflow_rows
+ .into_iter()
+ .map(SignerPublishWorkflowRow::into_record)
+ .collect::<Result<Vec<_>, _>>()?;
+
+ Ok(state)
+ }
+
+ fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> {
+ let executor = self.db.executor();
+ executor.begin()?;
+ let result = (|| -> Result<(), RadrootsNostrSignerError> {
+ exec_json(executor, "DELETE FROM signer_publish_workflow", json!([]))?;
+ exec_json(executor, "DELETE FROM signer_request_audit", json!([]))?;
+ exec_json(executor, "DELETE FROM signer_connection", json!([]))?;
+
+ exec_json(
+ executor,
+ "INSERT INTO signer_store_metadata(singleton_id, store_version, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, updated_at) VALUES(1, ?, ?, ?, ?, datetime('now')) ON CONFLICT(singleton_id) DO UPDATE SET store_version = excluded.store_version, signer_identity_id = excluded.signer_identity_id, signer_identity_public_key_hex = excluded.signer_identity_public_key_hex, signer_identity_json = excluded.signer_identity_json, updated_at = excluded.updated_at",
+ json!([
+ i64::from(state.version),
+ state
+ .signer_identity
+ .as_ref()
+ .map(|identity| identity.id().to_string()),
+ state
+ .signer_identity
+ .as_ref()
+ .map(|identity| identity.public_key().to_hex()),
+ state
+ .signer_identity
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()?,
+ ]),
+ )?;
+
+ for connection in &state.connections {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection(connection_id, client_public_key_hex, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, user_identity_id, user_identity_public_key_hex, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, client_metadata_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ connection.client_public_key.to_hex(),
+ connection.signer_identity.id().to_string(),
+ connection.signer_identity.public_key().to_hex(),
+ serde_json::to_string(&connection.signer_identity)?,
+ connection.user_identity.id().to_string(),
+ connection.user_identity.public_key().to_hex(),
+ serde_json::to_string(&connection.user_identity)?,
+ connection
+ .connect_secret_hash
+ .as_ref()
+ .map(|hash| secret_digest_algorithm_label(hash)),
+ connection
+ .connect_secret_hash
+ .as_ref()
+ .map(|hash| hash.digest_hex.clone()),
+ connection.connect_secret_consumed_at_unix,
+ serde_json::to_string(&connection.requested_permissions)?,
+ connection
+ .client_metadata
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()?,
+ approval_requirement_label(connection.approval_requirement),
+ approval_state_label(connection.approval_state),
+ auth_state_label(connection.auth_state),
+ connection_status_label(connection.status),
+ connection.status_reason.clone(),
+ connection.created_at_unix,
+ connection.updated_at_unix,
+ connection.last_authenticated_at_unix,
+ connection.last_request_at_unix,
+ ]),
+ )?;
+
+ for grant in &connection.granted_permissions {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_permission_grant(connection_id, permission, granted_at_unix) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ grant.permission.to_string(),
+ grant.granted_at_unix,
+ ]),
+ )?;
+ }
+
+ for (ordinal, relay) in connection.relays.iter().enumerate() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_relay(connection_id, ordinal, relay_url) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ i64::try_from(ordinal).map_err(|_| {
+ RadrootsNostrSignerError::Store(format!(
+ "relay ordinal for connection `{}` is out of range",
+ connection.connection_id
+ ))
+ })?,
+ relay.as_str(),
+ ]),
+ )?;
+ }
+
+ if let Some(challenge) = connection.auth_challenge.as_ref() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_auth_challenge(connection_id, auth_url, required_at_unix, authorized_at_unix) VALUES(?, ?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ challenge.auth_url,
+ challenge.required_at_unix,
+ challenge.authorized_at_unix,
+ ]),
+ )?;
+ }
+
+ if let Some(pending_request) = connection.pending_request.as_ref() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_pending_request(connection_id, request_message_json, created_at_unix) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ serde_json::to_string(&pending_request.request_message)?,
+ pending_request.created_at_unix,
+ ]),
+ )?;
+ }
+ }
+
+ for audit in &state.audit_records {
+ exec_json(
+ executor,
+ "INSERT INTO signer_request_audit(request_id, connection_id, method, decision, message, created_at_unix) VALUES(?, ?, ?, ?, ?, ?)",
+ json!([
+ audit.request_id.as_str(),
+ audit.connection_id.as_str(),
+ audit.method.to_string(),
+ request_decision_label(audit.decision),
+ audit.message.clone(),
+ audit.created_at_unix,
+ ]),
+ )?;
+ }
+
+ for workflow in &state.publish_workflows {
+ exec_json(
+ executor,
+ "INSERT INTO signer_publish_workflow(workflow_id, connection_id, kind, state, pending_request_json, authorized_at_unix, created_at_unix, updated_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
+ json!([
+ workflow.workflow_id.as_str(),
+ workflow.connection_id.as_str(),
+ publish_workflow_kind_label(workflow.kind),
+ publish_workflow_state_label(workflow.state),
+ workflow
+ .pending_request
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()?,
+ workflow.authorized_at_unix,
+ workflow.created_at_unix,
+ workflow.updated_at_unix,
+ ]),
+ )?;
+ }
+
+ Ok(())
+ })();
+
+ match result {
+ Ok(()) => {
+ executor.commit()?;
+ Ok(())
+ }
+ Err(error) => {
+ let _ = executor.rollback();
+ Err(error)
+ }
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerStoreMetadataRow {
+ store_version: i64,
+ signer_identity_json: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerConnectionRow {
+ connection_id: String,
+ client_public_key_hex: String,
+ signer_identity_json: String,
+ user_identity_json: String,
+ connect_secret_hash_algorithm: Option<String>,
+ connect_secret_hash_digest_hex: Option<String>,
+ connect_secret_consumed_at_unix: Option<u64>,
+ requested_permissions_json: String,
+ client_metadata_json: Option<String>,
+ approval_requirement: String,
+ approval_state: String,
+ auth_state: String,
+ status: String,
+ status_reason: Option<String>,
+ created_at_unix: u64,
+ updated_at_unix: u64,
+ last_authenticated_at_unix: Option<u64>,
+ last_request_at_unix: Option<u64>,
+}
+
+impl SignerConnectionRow {
+ fn into_record(self) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerConnectionRecord {
+ connection_id: self.connection_id.parse()?,
+ client_public_key: parse_public_key_hex(self.client_public_key_hex.as_str())?,
+ signer_identity: parse_json_field(self.signer_identity_json.as_str())?,
+ user_identity: parse_json_field(self.user_identity_json.as_str())?,
+ connect_secret_hash: match (
+ self.connect_secret_hash_algorithm.as_deref(),
+ self.connect_secret_hash_digest_hex,
+ ) {
+ (None, None) => None,
+ (Some(algorithm), Some(digest_hex)) => Some(RadrootsNostrSignerConnectSecretHash {
+ algorithm: parse_secret_digest_algorithm(algorithm)?,
+ digest_hex,
+ }),
+ _ => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite connection secret hash columns are inconsistent".into(),
+ ));
+ }
+ },
+ connect_secret_consumed_at_unix: self.connect_secret_consumed_at_unix,
+ requested_permissions: parse_json_field(self.requested_permissions_json.as_str())?,
+ client_metadata: self
+ .client_metadata_json
+ .as_deref()
+ .map(parse_json_field::<ClientMetadata>)
+ .transpose()?,
+ granted_permissions: Vec::new(),
+ relays: Vec::new(),
+ approval_requirement: parse_approval_requirement(self.approval_requirement.as_str())?,
+ approval_state: parse_approval_state(self.approval_state.as_str())?,
+ auth_state: parse_auth_state(self.auth_state.as_str())?,
+ auth_challenge: None,
+ pending_request: None,
+ status: parse_connection_status(self.status.as_str())?,
+ status_reason: self.status_reason,
+ created_at_unix: self.created_at_unix,
+ updated_at_unix: self.updated_at_unix,
+ last_authenticated_at_unix: self.last_authenticated_at_unix,
+ last_request_at_unix: self.last_request_at_unix,
+ })
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerConnectionPermissionGrantRow {
+ connection_id: String,
+ permission: String,
+ granted_at_unix: u64,
+}
+
+impl SignerConnectionPermissionGrantRow {
+ fn into_grant(self) -> Result<RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerPermissionGrant {
+ permission: self
+ .permission
+ .parse::<Permission>()
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ granted_at_unix: self.granted_at_unix,
+ })
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerConnectionRelayRow {
+ connection_id: String,
+ relay_url: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerConnectionAuthChallengeRow {
+ connection_id: String,
+ auth_url: String,
+ required_at_unix: u64,
+ authorized_at_unix: Option<u64>,
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerConnectionPendingRequestRow {
+ connection_id: String,
+ request_message_json: String,
+ created_at_unix: u64,
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerRequestAuditRow {
+ request_id: String,
+ connection_id: String,
+ method: String,
+ decision: String,
+ message: Option<String>,
+ created_at_unix: u64,
+}
+
+impl SignerRequestAuditRow {
+ fn into_record(
+ self,
+ ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerRequestAuditRecord {
+ request_id: self.request_id.parse()?,
+ connection_id: self.connection_id.parse()?,
+ method: self
+ .method
+ .parse::<Method>()
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ decision: parse_request_decision(self.decision.as_str())?,
+ message: self.message,
+ created_at_unix: self.created_at_unix,
+ })
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SignerPublishWorkflowRow {
+ workflow_id: String,
+ connection_id: String,
+ kind: String,
+ state: String,
+ pending_request_json: Option<String>,
+ authorized_at_unix: Option<u64>,
+ created_at_unix: u64,
+ updated_at_unix: u64,
+}
+
+impl SignerPublishWorkflowRow {
+ fn into_record(
+ self,
+ ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerPublishWorkflowRecord {
+ workflow_id: self.workflow_id.parse()?,
+ connection_id: self.connection_id.parse()?,
+ kind: parse_publish_workflow_kind(self.kind.as_str())?,
+ state: parse_publish_workflow_state(self.state.as_str())?,
+ pending_request: self
+ .pending_request_json
+ .as_deref()
+ .map(parse_json_field::<RadrootsNostrSignerPendingRequest>)
+ .transpose()?,
+ authorized_at_unix: self.authorized_at_unix,
+ created_at_unix: self.created_at_unix,
+ updated_at_unix: self.updated_at_unix,
+ })
+ }
+}
+
+fn query_rows<T: DeserializeOwned>(
+ db: &RadrootsNostrSignerSqliteDb,
+ sql: &str,
+) -> Result<Vec<T>, RadrootsNostrSignerError> {
+ let raw = db.executor().query_raw(sql, "[]")?;
+ serde_json::from_str(&raw).map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+fn exec_json(
+ executor: &impl crate::sql::SqlExecutor,
+ sql: &str,
+ params: Value,
+) -> Result<(), RadrootsNostrSignerError> {
+ let _ = executor.exec(sql, params.to_string().as_str())?;
+ Ok(())
+}
+
+fn parse_json_field<T: DeserializeOwned>(value: &str) -> Result<T, RadrootsNostrSignerError> {
+ serde_json::from_str(value).map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsNostrSignerError> {
+ nostr::PublicKey::parse(value)
+ .or_else(|_| nostr::PublicKey::from_hex(value))
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+fn approval_requirement_label(value: RadrootsNostrSignerApprovalRequirement) -> &'static str {
+ match value {
+ RadrootsNostrSignerApprovalRequirement::NotRequired => "not_required",
+ RadrootsNostrSignerApprovalRequirement::ExplicitUser => "explicit_user",
+ }
+}
+
+fn parse_approval_requirement(
+ value: &str,
+) -> Result<RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerApprovalRequirement::NotRequired),
+ "explicit_user" => Ok(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite approval requirement `{other}`"
+ ))),
+ }
+}
+
+fn approval_state_label(value: RadrootsNostrSignerApprovalState) -> &'static str {
+ match value {
+ RadrootsNostrSignerApprovalState::NotRequired => "not_required",
+ RadrootsNostrSignerApprovalState::Pending => "pending",
+ RadrootsNostrSignerApprovalState::Approved => "approved",
+ RadrootsNostrSignerApprovalState::Rejected => "rejected",
+ }
+}
+
+fn parse_approval_state(
+ value: &str,
+) -> Result<RadrootsNostrSignerApprovalState, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerApprovalState::NotRequired),
+ "pending" => Ok(RadrootsNostrSignerApprovalState::Pending),
+ "approved" => Ok(RadrootsNostrSignerApprovalState::Approved),
+ "rejected" => Ok(RadrootsNostrSignerApprovalState::Rejected),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite approval state `{other}`"
+ ))),
+ }
+}
+
+fn auth_state_label(value: RadrootsNostrSignerAuthState) -> &'static str {
+ match value {
+ RadrootsNostrSignerAuthState::NotRequired => "not_required",
+ RadrootsNostrSignerAuthState::Pending => "pending",
+ RadrootsNostrSignerAuthState::Authorized => "authorized",
+ }
+}
+
+fn parse_auth_state(value: &str) -> Result<RadrootsNostrSignerAuthState, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerAuthState::NotRequired),
+ "pending" => Ok(RadrootsNostrSignerAuthState::Pending),
+ "authorized" => Ok(RadrootsNostrSignerAuthState::Authorized),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite auth state `{other}`"
+ ))),
+ }
+}
+
+fn connection_status_label(value: RadrootsNostrSignerConnectionStatus) -> &'static str {
+ match value {
+ RadrootsNostrSignerConnectionStatus::Pending => "pending",
+ RadrootsNostrSignerConnectionStatus::Active => "active",
+ RadrootsNostrSignerConnectionStatus::Rejected => "rejected",
+ RadrootsNostrSignerConnectionStatus::Revoked => "revoked",
+ }
+}
+
+fn parse_connection_status(
+ value: &str,
+) -> Result<RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerError> {
+ match value {
+ "pending" => Ok(RadrootsNostrSignerConnectionStatus::Pending),
+ "active" => Ok(RadrootsNostrSignerConnectionStatus::Active),
+ "rejected" => Ok(RadrootsNostrSignerConnectionStatus::Rejected),
+ "revoked" => Ok(RadrootsNostrSignerConnectionStatus::Revoked),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite connection status `{other}`"
+ ))),
+ }
+}
+
+fn request_decision_label(value: RadrootsNostrSignerRequestDecision) -> &'static str {
+ match value {
+ RadrootsNostrSignerRequestDecision::Allowed => "allowed",
+ RadrootsNostrSignerRequestDecision::Denied => "denied",
+ RadrootsNostrSignerRequestDecision::Challenged => "challenged",
+ }
+}
+
+fn parse_request_decision(
+ value: &str,
+) -> Result<RadrootsNostrSignerRequestDecision, RadrootsNostrSignerError> {
+ match value {
+ "allowed" => Ok(RadrootsNostrSignerRequestDecision::Allowed),
+ "denied" => Ok(RadrootsNostrSignerRequestDecision::Denied),
+ "challenged" => Ok(RadrootsNostrSignerRequestDecision::Challenged),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite request decision `{other}`"
+ ))),
+ }
+}
+
+fn publish_workflow_kind_label(value: RadrootsNostrSignerPublishWorkflowKind) -> &'static str {
+ match value {
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => {
+ "connect_secret_finalization"
+ }
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => {
+ "auth_replay_finalization"
+ }
+ }
+}
+
+fn parse_publish_workflow_kind(
+ value: &str,
+) -> Result<RadrootsNostrSignerPublishWorkflowKind, RadrootsNostrSignerError> {
+ match value {
+ "connect_secret_finalization" => {
+ Ok(RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization)
+ }
+ "auth_replay_finalization" => {
+ Ok(RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization)
+ }
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite publish workflow kind `{other}`"
+ ))),
+ }
+}
+
+fn publish_workflow_state_label(value: RadrootsNostrSignerPublishWorkflowState) -> &'static str {
+ match value {
+ RadrootsNostrSignerPublishWorkflowState::PendingPublish => "pending_publish",
+ RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize => {
+ "published_pending_finalize"
+ }
+ }
+}
+
+fn parse_publish_workflow_state(
+ value: &str,
+) -> Result<RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerError> {
+ match value {
+ "pending_publish" => Ok(RadrootsNostrSignerPublishWorkflowState::PendingPublish),
+ "published_pending_finalize" => {
+ Ok(RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize)
+ }
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite publish workflow state `{other}`"
+ ))),
+ }
+}
+
+fn secret_digest_algorithm_label(hash: &RadrootsNostrSignerConnectSecretHash) -> &'static str {
+ match hash.algorithm {
+ crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256 => "sha256",
+ }
+}
+
+fn parse_secret_digest_algorithm(
+ value: &str,
+) -> Result<crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm, RadrootsNostrSignerError>
+{
+ match value {
+ "sha256" => Ok(crate::signer::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite secret digest algorithm `{other}`"
+ ))),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::signer::model::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge,
+ RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
+ RadrootsNostrSignerConnectionId, RadrootsNostrSignerPendingRequest,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowRecord,
+ RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision,
+ RadrootsNostrSignerRequestId,
+ };
+ use crate::signer::test_support::{
+ api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key,
+ primary_relay, secondary_relay,
+ };
+ use radroots_nostr_connect::{
+ Method, Permission, Request, message::RequestMessage, permission::Permissions,
+ uri::ClientMetadata,
+ };
+ use std::thread;
+
+ #[test]
+ fn production_source_has_no_dead_code_allowance() {
+ let forbidden = ["#[allow(", "dead_code", ")]"].concat();
+ assert!(!include_str!("store.rs").contains(forbidden.as_str()));
+ }
+
+ #[test]
+ fn file_store_round_trip_and_path_accessor() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("signer.json");
+ let store = RadrootsNostrFileSignerStore::new(path.as_path());
+
+ assert_eq!(store.path(), path.as_path());
+ store
+ .save(&RadrootsNostrSignerStoreState::default())
+ .expect("save");
+ let loaded = store.load().expect("load");
+ assert_eq!(
+ loaded.version,
+ RadrootsNostrSignerStoreState::default().version
+ );
+ assert!(loaded.connections.is_empty());
+ }
+
+ #[test]
+ fn file_store_load_missing_and_reports_parse_errors() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let missing = RadrootsNostrFileSignerStore::new(temp.path().join("missing.json"));
+ let loaded = missing.load().expect("missing load");
+ assert!(loaded.connections.is_empty());
+
+ let path = temp.path().join("invalid.json");
+ std::fs::write(&path, "{").expect("write invalid json");
+ let store = RadrootsNostrFileSignerStore::new(path.as_path());
+ let err = store.load().expect_err("invalid json");
+ assert!(err.to_string().starts_with("store error:"));
+ }
+
+ #[test]
+ fn file_store_save_reports_parse_error() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("invalid-save.json");
+ std::fs::write(&path, "{").expect("write invalid json");
+ let store = RadrootsNostrFileSignerStore::new(path.as_path());
+ let err = store
+ .save(&RadrootsNostrSignerStoreState::default())
+ .expect_err("invalid save");
+ assert!(err.to_string().starts_with("store error:"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn file_store_save_reports_write_error() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("signer.json");
+ let json =
+ serde_json::to_string(&RadrootsNostrSignerStoreState::default()).expect("serialize");
+ std::fs::write(&path, json).expect("write json");
+ let store = RadrootsNostrFileSignerStore::new(path.as_path());
+
+ let mut perms = std::fs::metadata(temp.path())
+ .expect("dir metadata")
+ .permissions();
+ perms.set_mode(0o500);
+ std::fs::set_permissions(temp.path(), perms).expect("set perms");
+
+ let err = store
+ .save(&RadrootsNostrSignerStoreState::default())
+ .expect_err("read-only save");
+ assert!(err.to_string().starts_with("store error:"));
+
+ let mut perms = std::fs::metadata(temp.path())
+ .expect("dir metadata")
+ .permissions();
+ perms.set_mode(0o700);
+ std::fs::set_permissions(temp.path(), perms).expect("restore perms");
+ }
+
+ #[test]
+ fn memory_store_round_trip_and_poison_errors() {
+ let store = RadrootsNostrMemorySignerStore::new();
+ let state = RadrootsNostrSignerStoreState::default();
+ store.save(&state).expect("save");
+ let loaded = store.load().expect("load");
+ assert_eq!(loaded.version, state.version);
+
+ let shared = store.state.clone();
+ let _ = thread::spawn(move || {
+ let _guard = shared.write().expect("write");
+ panic!("poison memory store");
+ })
+ .join();
+
+ let load = store.load().expect_err("poisoned load");
+ let save = store.save(&state).expect_err("poisoned save");
+ assert!(load.to_string().contains("memory store lock poisoned"));
+ assert!(save.to_string().contains("memory store lock poisoned"));
+ }
+
+ fn sample_request_message(id: &str) -> RequestMessage {
+ RequestMessage::new(id, Request::Ping)
+ }
+
+ fn sample_sqlite_state() -> RadrootsNostrSignerStoreState {
+ let signer_identity = fixture_alice_identity();
+ let user_identity = fixture_bob_identity();
+ let connection_id = RadrootsNostrSignerConnectionId::parse("conn-sqlite").expect("id");
+ let mut connection = RadrootsNostrSignerConnectionRecord::new(
+ connection_id.clone(),
+ signer_identity.clone(),
+ RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity)
+ .with_connect_secret("sqlite-secret")
+ .with_client_metadata(ClientMetadata {
+ requested_permissions: Permissions::default(),
+ name: Some("Example Client".to_owned()),
+ url: Some("https://client.example.com/".to_owned()),
+ image: Some("https://client.example.com/icon.png".to_owned()),
+ })
+ .with_relays(vec![primary_relay(), secondary_relay()])
+ .with_requested_permissions(
+ vec![
+ Permission::new(Method::Ping),
+ Permission::with_parameter(Method::SignEvent, "kind:1"),
+ ]
+ .into(),
+ )
+ .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
+ 100,
+ );
+ connection.approval_state =
+ crate::signer::model::RadrootsNostrSignerApprovalState::Approved;
+ connection.auth_state = RadrootsNostrSignerAuthState::Pending;
+ connection.status = crate::signer::model::RadrootsNostrSignerConnectionStatus::Active;
+ connection.status_reason = Some("approved by operator".to_owned());
+ connection.updated_at_unix = 140;
+ connection.last_authenticated_at_unix = Some(130);
+ connection.last_request_at_unix = Some(135);
+ connection.mark_connect_secret_consumed(125);
+ connection.granted_permissions = vec![
+ RadrootsNostrSignerPermissionGrant::new(Permission::new(Method::Ping), 110),
+ RadrootsNostrSignerPermissionGrant::new(
+ Permission::with_parameter(Method::SignEvent, "kind:1"),
+ 111,
+ ),
+ ];
+ connection.auth_challenge = Some(
+ RadrootsNostrSignerAuthChallenge::new(
+ format!("{}/challenge", api_primary_https()).as_str(),
+ 120,
+ )
+ .expect("challenge"),
+ );
+ connection.pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(sample_request_message("req-sqlite"), 121)
+ .expect("pending request"),
+ );
+
+ RadrootsNostrSignerStoreState {
+ version: 1,
+ signer_identity: Some(signer_identity),
+ connections: vec![connection.clone()],
+ audit_records: vec![RadrootsNostrSignerRequestAuditRecord::new(
+ RadrootsNostrSignerRequestId::parse("audit-1").expect("request id"),
+ connection_id,
+ Method::Ping,
+ RadrootsNostrSignerRequestDecision::Allowed,
+ Some("permitted".to_owned()),
+ 150,
+ )],
+ publish_workflows: vec![
+ RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
+ connection.connection_id.clone(),
+ 151,
+ ),
+ RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
+ connection.connection_id.clone(),
+ RadrootsNostrSignerPendingRequest::new(
+ sample_request_message("req-replay"),
+ 152,
+ )
+ .expect("auth replay pending request"),
+ 153,
+ ),
+ ],
+ }
+ }
+
+ #[test]
+ fn sqlite_store_round_trip_on_memory_backend() {
+ let store = RadrootsNostrSqliteSignerStore::open_memory().expect("open memory store");
+ let state = sample_sqlite_state();
+
+ store.save(&state).expect("save sqlite state");
+ let loaded = store.load().expect("load sqlite state");
+
+ assert_eq!(
+ serde_json::to_value(&loaded).expect("serialize loaded"),
+ serde_json::to_value(&state).expect("serialize state")
+ );
+ }
+
+ #[test]
+ fn sqlite_store_persists_to_disk_and_recovers_after_reopen() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("signer.sqlite");
+ let state = sample_sqlite_state();
+
+ let store = RadrootsNostrSqliteSignerStore::open(&path).expect("open sqlite store");
+ store.save(&state).expect("save sqlite state");
+
+ let reopened = RadrootsNostrSqliteSignerStore::open(&path).expect("reopen sqlite store");
+ let loaded = reopened.load().expect("load reopened sqlite state");
+
+ assert_eq!(
+ serde_json::to_value(&loaded).expect("serialize loaded"),
+ serde_json::to_value(&state).expect("serialize state")
+ );
+ }
+}
diff --git a/src/signer/test_fixtures.rs b/src/signer/test_fixtures.rs
@@ -0,0 +1,107 @@
+#![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
@@ -0,0 +1,85 @@
+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
@@ -0,0 +1,86 @@
+//! 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::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,
+ 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_secs();
+ if now > request.policy().deadline_unix() {
+ return Err(Error::new(SigningErrorKind::DeadlineExceeded));
+ }
+ if request.draft().expected_pubkey() != &self.public_identity().public_key() {
+ return Err(Error::new(SigningErrorKind::AuthorizationDenied));
+ }
+ report_progress(&request, SignProgressStage::Validating)?;
+
+ let kind = u16::try_from(request.draft().kind_u32())
+ .map_err(|_| Error::new(SigningErrorKind::InvalidArgument))?;
+ let tags = request
+ .draft()
+ .tags_as_vec()
+ .into_iter()
+ .map(Tag::parse)
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|source| Error::with_source(SigningErrorKind::InvalidArgument, source))?;
+ let unsigned = EventBuilder::new(Kind::Custom(kind), request.draft().content())
+ .tags(tags)
+ .custom_created_at(Timestamp::from(request.draft().created_at_u64()))
+ .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_secs() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |duration| duration.as_secs())
+}
diff --git a/src/sql.rs b/src/sql.rs
@@ -0,0 +1,308 @@
+//! Myc-owned synchronous SQLite adapter for service persistence.
+
+use std::path::Path;
+use std::sync::{Arc, Mutex};
+
+use serde::Serialize;
+use serde_json::{Map, Value, json};
+use sqlx::sqlite::{SqliteArguments, SqliteConnectOptions, SqliteConnection, SqliteRow};
+use sqlx::{Column, Connection, Row, TypeInfo, ValueRef};
+
+#[derive(Debug, Clone, Serialize)]
+pub enum SqlError {
+ InvalidArgument(String),
+ NotFound(String),
+ SerializationError(String),
+ InvalidQuery(String),
+ Internal,
+ UnsupportedPlatform,
+}
+
+impl std::fmt::Display for SqlError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidArgument(value) => write!(formatter, "invalid argument: {value}"),
+ Self::NotFound(value) => write!(formatter, "{value} not found"),
+ Self::SerializationError(value) => write!(formatter, "serialization error: {value}"),
+ Self::InvalidQuery(value) => write!(formatter, "invalid query: {value}"),
+ Self::Internal => formatter.write_str("internal error"),
+ Self::UnsupportedPlatform => formatter.write_str("unsupported on this platform"),
+ }
+ }
+}
+
+impl std::error::Error for SqlError {}
+
+impl From<serde_json::Error> for SqlError {
+ fn from(error: serde_json::Error) -> Self {
+ Self::SerializationError(error.to_string())
+ }
+}
+
+impl From<sqlx::Error> for SqlError {
+ fn from(error: sqlx::Error) -> Self {
+ Self::InvalidQuery(error.to_string())
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct ExecOutcome {
+ pub changes: i64,
+ pub last_insert_id: i64,
+}
+
+pub trait SqlExecutor: Send + Sync {
+ fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError>;
+ fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError>;
+ fn begin(&self) -> Result<(), SqlError>;
+ fn commit(&self) -> Result<(), SqlError>;
+ fn rollback(&self) -> Result<(), SqlError>;
+}
+
+impl<T> SqlExecutor for &T
+where
+ T: SqlExecutor + ?Sized,
+{
+ fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> {
+ (**self).exec(sql, params_json)
+ }
+
+ fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> {
+ (**self).query_raw(sql, params_json)
+ }
+
+ fn begin(&self) -> Result<(), SqlError> {
+ (**self).begin()
+ }
+
+ fn commit(&self) -> Result<(), SqlError> {
+ (**self).commit()
+ }
+
+ fn rollback(&self) -> Result<(), SqlError> {
+ (**self).rollback()
+ }
+}
+
+pub struct SqlxSqliteExecutor {
+ connection: Arc<Mutex<SqliteConnection>>,
+}
+
+impl SqlxSqliteExecutor {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self, SqlError> {
+ Self::connect(
+ SqliteConnectOptions::new()
+ .filename(path)
+ .create_if_missing(true),
+ )
+ }
+
+ pub fn open_memory() -> Result<Self, SqlError> {
+ Self::connect(SqliteConnectOptions::new().in_memory(true))
+ }
+
+ fn connect(options: SqliteConnectOptions) -> Result<Self, SqlError> {
+ let connection = futures_executor::block_on(SqliteConnection::connect_with(&options))?;
+ Ok(Self {
+ connection: Arc::new(Mutex::new(connection)),
+ })
+ }
+}
+
+impl SqlExecutor for SqlxSqliteExecutor {
+ fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> {
+ let binds = parse_params(params_json)?;
+ let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?;
+ if binds.is_empty() {
+ let result = futures_executor::block_on(
+ sqlx::raw_sql(sqlx::AssertSqlSafe(sql)).execute(&mut *connection),
+ )?;
+ return Ok(ExecOutcome {
+ changes: i64::try_from(result.rows_affected()).map_err(|_| SqlError::Internal)?,
+ last_insert_id: result.last_insert_rowid(),
+ });
+ }
+ let query = bind_params(sqlx::query(sqlx::AssertSqlSafe(sql)), binds);
+ let result = futures_executor::block_on(query.execute(&mut *connection))?;
+ Ok(ExecOutcome {
+ changes: i64::try_from(result.rows_affected()).map_err(|_| SqlError::Internal)?,
+ last_insert_id: result.last_insert_rowid(),
+ })
+ }
+
+ fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> {
+ let query = bind_params(
+ sqlx::query(sqlx::AssertSqlSafe(sql)),
+ parse_params(params_json)?,
+ );
+ let rows = {
+ let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?;
+ futures_executor::block_on(query.fetch_all(&mut *connection))?
+ };
+ let rows = rows
+ .iter()
+ .map(row_to_json)
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Value::from(rows).to_string())
+ }
+
+ fn begin(&self) -> Result<(), SqlError> {
+ self.exec_transaction("BEGIN")
+ }
+
+ fn commit(&self) -> Result<(), SqlError> {
+ self.exec_transaction("COMMIT")
+ }
+
+ fn rollback(&self) -> Result<(), SqlError> {
+ self.exec_transaction("ROLLBACK")
+ }
+}
+
+impl SqlxSqliteExecutor {
+ fn exec_transaction(&self, statement: &str) -> Result<(), SqlError> {
+ let mut connection = self.connection.lock().map_err(|_| SqlError::Internal)?;
+ futures_executor::block_on(
+ sqlx::query(sqlx::AssertSqlSafe(statement)).execute(&mut *connection),
+ )?;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct Migration {
+ pub name: &'static str,
+ pub up_sql: &'static str,
+ pub down_sql: &'static str,
+}
+
+pub fn migrations_run_all_up(
+ executor: &impl SqlExecutor,
+ migrations: &[Migration],
+) -> Result<(), SqlError> {
+ ensure_migrations_table(executor)?;
+ for migration in migrations {
+ let rows: Vec<Value> = serde_json::from_str(&executor.query_raw(
+ "select 1 as applied from __migrations where name = ? limit 1",
+ &json!([migration.name]).to_string(),
+ )?)?;
+ if rows.is_empty() {
+ executor.begin()?;
+ let result = (|| {
+ executor.exec(migration.up_sql, "[]")?;
+ executor.exec(
+ "insert or ignore into __migrations(name) values(?)",
+ &json!([migration.name]).to_string(),
+ )?;
+ Ok::<_, SqlError>(())
+ })();
+ if let Err(error) = result {
+ let _ = executor.rollback();
+ return Err(error);
+ }
+ executor.commit()?;
+ }
+ }
+ Ok(())
+}
+
+pub fn migrations_run_all_down(
+ executor: &impl SqlExecutor,
+ migrations: &[Migration],
+) -> Result<(), SqlError> {
+ ensure_migrations_table(executor)?;
+ executor.begin()?;
+ for migration in migrations.iter().rev() {
+ executor.exec(
+ "delete from __migrations where name = ?",
+ &json!([migration.name]).to_string(),
+ )?;
+ executor.exec(migration.down_sql, "[]")?;
+ }
+ executor.commit()
+}
+
+fn ensure_migrations_table(executor: &impl SqlExecutor) -> Result<(), SqlError> {
+ executor.exec(
+ "create table if not exists __migrations(id integer primary key, name text not null unique, applied_at text not null default (datetime('now')))",
+ "[]",
+ )?;
+ Ok(())
+}
+
+#[derive(Debug)]
+enum BindValue {
+ Null,
+ Integer(i64),
+ Real(f64),
+ Text(String),
+}
+
+fn parse_params(params_json: &str) -> Result<Vec<BindValue>, SqlError> {
+ serde_json::from_str::<Vec<Value>>(params_json)?
+ .into_iter()
+ .map(|value| match value {
+ Value::Null => Ok(BindValue::Null),
+ Value::Bool(value) => Ok(BindValue::Integer(i64::from(value))),
+ Value::Number(value) if value.is_i64() => {
+ Ok(BindValue::Integer(value.as_i64().expect("checked")))
+ }
+ Value::Number(value) if value.is_u64() => value
+ .as_u64()
+ .and_then(|value| i64::try_from(value).ok())
+ .map(BindValue::Integer)
+ .ok_or_else(|| SqlError::InvalidArgument("integer bind exceeds i64".into())),
+ Value::Number(value) => value
+ .as_f64()
+ .map(BindValue::Real)
+ .ok_or_else(|| SqlError::InvalidArgument("unsupported number".into())),
+ Value::String(value) => Ok(BindValue::Text(value)),
+ _ => Err(SqlError::InvalidArgument("unsupported bind value".into())),
+ })
+ .collect()
+}
+
+fn bind_params<'q>(
+ mut query: sqlx::query::Query<'q, sqlx::Sqlite, SqliteArguments>,
+ params: Vec<BindValue>,
+) -> sqlx::query::Query<'q, sqlx::Sqlite, SqliteArguments> {
+ for param in params {
+ query = match param {
+ BindValue::Null => query.bind(Option::<String>::None),
+ BindValue::Integer(value) => query.bind(value),
+ BindValue::Real(value) => query.bind(value),
+ BindValue::Text(value) => query.bind(value),
+ };
+ }
+ query
+}
+
+fn row_to_json(row: &SqliteRow) -> Result<Value, SqlError> {
+ let mut object = Map::new();
+ for (index, column) in row.columns().iter().enumerate() {
+ let raw = row.try_get_raw(index)?;
+ let value = if raw.is_null() {
+ Value::Null
+ } else {
+ match raw.type_info().name() {
+ "INTEGER" | "BOOLEAN" => Value::from(row.try_get::<i64, _>(index)?),
+ "REAL" => Value::from(row.try_get::<f64, _>(index)?),
+ "TEXT" | "DATE" | "TIME" | "DATETIME" => {
+ Value::from(row.try_get::<String, _>(index)?)
+ }
+ "BLOB" => Value::Null,
+ other => return Err(SqlError::InvalidQuery(other.to_owned())),
+ }
+ };
+ object.insert(column.name().to_owned(), value);
+ }
+ Ok(Value::Object(object))
+}
+
+pub mod error {
+ pub use super::SqlError;
+}
+
+pub mod migrations {
+ pub use super::{Migration, migrations_run_all_down, migrations_run_all_up};
+}
diff --git a/src/transport.rs b/src/transport.rs
@@ -3,7 +3,7 @@ pub mod nip46;
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
-use radroots_nostr::prelude::{
+use crate::nostr_contract::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrOutput,
RadrootsNostrRelayUrl,
};
@@ -513,9 +513,7 @@ mod tests {
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
- use radroots_nostr::prelude::{
- RadrootsNostrEventId, RadrootsNostrOutput, RadrootsNostrRelayUrl,
- };
+ use crate::nostr_contract::{RadrootsNostrEventId, RadrootsNostrOutput, RadrootsNostrRelayUrl};
use tokio::time::Instant;
use crate::config::{MycTransportConfig, MycTransportDeliveryPolicy};
@@ -525,7 +523,7 @@ mod tests {
fn signer_identity() -> MycActiveIdentity {
MycActiveIdentity::new(
- radroots_identity::RadrootsIdentity::from_secret_key_str(
+ crate::host_identity::RadrootsIdentity::from_secret_key_str(
"1111111111111111111111111111111111111111111111111111111111111111",
)
.expect("identity"),
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -2,21 +2,21 @@ use std::collections::{HashSet, VecDeque};
use std::future::Future;
use std::sync::Arc;
-use radroots_nostr::prelude::{
+use crate::nostr_contract::{
RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey,
RadrootsNostrRelayPoolNotification, RadrootsNostrRelayUrl,
};
-use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequestMessage,
- RadrootsNostrConnectResponse,
-};
-use radroots_nostr_signer::prelude::{
+use crate::signer::prelude::{
RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionStatus,
RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Signer,
RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestEvaluation,
RadrootsNostrSignerRequestId, RadrootsNostrSignerSessionLookup, RadrootsNostrSignerWorkflowId,
};
+use radroots_nostr_connect::prelude::{
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequestMessage,
+ RadrootsNostrConnectResponse,
+};
use tokio::sync::broadcast;
use crate::app::MycSignerContext;
@@ -91,12 +91,12 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
client_public_key: &RadrootsNostrPublicKey,
ciphertext: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.signer_identity()
.nip44_decrypt(client_public_key, ciphertext)
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
@@ -104,28 +104,28 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
client_public_key: &RadrootsNostrPublicKey,
payload: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.signer_identity()
.nip44_encrypt(client_public_key, payload.to_owned())
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
- fn user_identity(&self) -> radroots_identity::RadrootsIdentityPublic {
+ fn user_identity(&self) -> crate::host_identity::RadrootsIdentityPublic {
self.signer.user_public_identity()
}
fn sign_user_event(
&self,
unsigned_event: nostr::UnsignedEvent,
- ) -> Result<RadrootsNostrEvent, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<RadrootsNostrEvent, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.user_identity()
.sign_unsigned_event(unsigned_event, "managed user sign_event")
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
@@ -133,12 +133,12 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
public_key: &RadrootsNostrPublicKey,
plaintext: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.user_identity()
.nip04_encrypt(public_key, plaintext.to_owned())
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
@@ -146,12 +146,12 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
public_key: &RadrootsNostrPublicKey,
ciphertext: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.user_identity()
.nip04_decrypt(public_key, ciphertext)
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
@@ -159,12 +159,12 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
public_key: &RadrootsNostrPublicKey,
plaintext: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.user_identity()
.nip44_encrypt(public_key, plaintext.to_owned())
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
@@ -172,12 +172,12 @@ impl RadrootsNostrSignerNip46Signer for MycNip46Signer {
&self,
public_key: &RadrootsNostrPublicKey,
ciphertext: &str,
- ) -> Result<String, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
+ ) -> Result<String, crate::signer::prelude::RadrootsNostrSignerError> {
self.signer
.user_identity()
.nip44_decrypt(public_key, ciphertext)
.map_err(|error| {
- radroots_nostr_signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
+ crate::signer::prelude::RadrootsNostrSignerError::Sign(error.to_string())
})
}
}
@@ -233,7 +233,7 @@ impl MycNip46Handler {
client_public_key: RadrootsNostrPublicKey,
request_id: impl Into<String>,
response: RadrootsNostrConnectResponse,
- ) -> Result<radroots_nostr::prelude::RadrootsNostrGenericEventBuilder, MycError> {
+ ) -> Result<crate::nostr_contract::RadrootsNostrGenericEventBuilder, MycError> {
self.handler
.build_response_event(client_public_key, request_id, response)
.map_err(Into::into)
@@ -833,21 +833,22 @@ impl MycNip46Service {
#[cfg(test)]
mod tests {
+ use crate::nostr_contract::{RadrootsNostrTag, radroots_nostr_kind};
+ use crate::signer::prelude::{
+ RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus,
+ RadrootsNostrSignerHandledRequest,
+ };
use nostr::nips::nip04;
use nostr::nips::nip44;
use nostr::nips::nip44::Version;
- use nostr::{EventBuilder, Keys, PublicKey, SecretKey, Timestamp, UnsignedEvent};
- use radroots_nostr::prelude::{RadrootsNostrTag, radroots_nostr_kind};
+ use nostr::{EventBuilder, Keys, PublicKey, SecretKey, Timestamp};
+ use radroots_nostr_connect::message::UnsignedEvent;
use radroots_nostr_connect::prelude::{
RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod,
RadrootsNostrConnectPermission, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope,
};
- use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus,
- RadrootsNostrSignerHandledRequest,
- };
use serde_json::json;
use crate::app::MycRuntime;
@@ -856,8 +857,8 @@ mod tests {
use super::MycNip46Handler;
fn write_identity(path: &std::path::Path, secret_key: &str) {
- let identity =
- radroots_identity::RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
+ let identity = crate::host_identity::RadrootsIdentity::from_secret_key_str(secret_key)
+ .expect("identity");
crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
}
@@ -956,13 +957,16 @@ mod tests {
}
fn unsigned_event(pubkey: PublicKey, kind: u16, content: &str) -> UnsignedEvent {
- serde_json::from_value(json!({
- "pubkey": pubkey.to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": kind,
- "tags": [],
- "content": content
- }))
+ UnsignedEvent::from_json(
+ &json!({
+ "pubkey": pubkey.to_hex(),
+ "created_at": Timestamp::from(1).as_secs(),
+ "kind": kind,
+ "tags": [],
+ "content": content
+ })
+ .to_string(),
+ )
.expect("unsigned event")
}
@@ -977,7 +981,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: requested_permissions.into(),
client_metadata: None,
@@ -1055,7 +1062,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: Some("s3cr3t".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -1097,7 +1107,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: Default::default(),
client_metadata: None,
@@ -1134,7 +1147,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-1",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: Some("s3cr3t".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -1148,7 +1164,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-2",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: Some("s3cr3t".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -1174,7 +1193,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: Some("s3cr3t".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -1207,7 +1229,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-reused",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: Some("s3cr3t".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -1243,7 +1268,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-1",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: Default::default(),
client_metadata: None,
@@ -1259,7 +1287,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-2",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: Default::default(),
client_metadata: None,
@@ -1288,7 +1319,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect-3",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: Default::default(),
client_metadata: None,
@@ -1310,7 +1344,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![sign_event_permission(1)].into(),
client_metadata: None,
@@ -1330,11 +1367,11 @@ mod tests {
.expect("connection");
assert_eq!(
connection.status,
- radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionStatus::Pending
+ crate::signer::prelude::RadrootsNostrSignerConnectionStatus::Pending
);
assert_eq!(
connection.approval_state,
- radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalState::Pending
+ crate::signer::prelude::RadrootsNostrSignerApprovalState::Pending
);
assert!(connection.granted_permissions().as_slice().is_empty());
}
@@ -1405,7 +1442,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![
RadrootsNostrConnectPermission::new(
@@ -1427,11 +1467,11 @@ mod tests {
let connection = connection_for(&runtime, trusted_client_keys.public_key());
assert_eq!(
connection.granted_permissions().to_string(),
- "sign_event:kind:1,nip04_encrypt"
+ "nip04_encrypt,sign_event:kind:1"
);
assert_eq!(
connection.requested_permissions.to_string(),
- "sign_event:kind:1,nip04_encrypt"
+ "nip04_encrypt,sign_event:kind:1"
);
}
@@ -1455,7 +1495,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![sign_event_permission(1)].into(),
client_metadata: None,
@@ -1548,7 +1591,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![sign_event_permission(1)].into(),
client_metadata: None,
@@ -1625,7 +1671,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![sign_event_permission(1)].into(),
client_metadata: None,
@@ -1684,7 +1733,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![RadrootsNostrConnectPermission::new(
RadrootsNostrConnectMethod::SwitchRelays,
@@ -1707,7 +1759,9 @@ mod tests {
.expect("get public key");
assert_eq!(
public_key,
- RadrootsNostrConnectResponse::UserPublicKey(runtime.user_identity().public_key())
+ RadrootsNostrConnectResponse::UserPublicKey(
+ runtime.user_identity().public_identity().public_key()
+ )
);
let pong = handler
@@ -1733,7 +1787,15 @@ mod tests {
assert_eq!(
relays,
RadrootsNostrConnectResponse::RelayList(
- runtime.transport().expect("transport").relays().to_vec()
+ runtime
+ .transport()
+ .expect("transport")
+ .relays()
+ .iter()
+ .map(|relay| {
+ radroots_nostr_connect::uri::RelayUrl::parse(relay.as_str()).expect("relay")
+ })
+ .collect()
)
);
@@ -1750,8 +1812,17 @@ mod tests {
capability,
RadrootsNostrConnectResponse::RemoteSessionCapability(
radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability {
- user_public_key: runtime.user_identity().public_key(),
- relays: runtime.transport().expect("transport").relays().to_vec(),
+ user_public_key: runtime.user_identity().public_identity().public_key(),
+ relays: runtime
+ .transport()
+ .expect("transport")
+ .relays()
+ .iter()
+ .map(|relay| {
+ radroots_nostr_connect::uri::RelayUrl::parse(relay.as_str())
+ .expect("relay")
+ })
+ .collect(),
permissions: vec![RadrootsNostrConnectPermission::new(
RadrootsNostrConnectMethod::SwitchRelays,
)]
@@ -1771,7 +1842,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: runtime.signer_identity().public_key(),
+ remote_signer_public_key: runtime
+ .signer_identity()
+ .public_identity()
+ .public_key(),
secret: None,
requested_permissions: vec![sign_event_permission(1)].into(),
client_metadata: None,
@@ -1817,6 +1891,7 @@ mod tests {
let RadrootsNostrConnectResponse::SignedEvent(event) = response else {
panic!("unexpected sign_event response");
};
+ let event: nostr::Event = serde_json::from_str(&event.as_json()).expect("signed event");
assert_eq!(event.pubkey, runtime.user_identity().public_key());
assert_eq!(event.kind.as_u16(), 1);
assert_eq!(event.content, "hello world");
@@ -1900,7 +1975,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-nip04-encrypt",
RadrootsNostrConnectRequest::Nip04Encrypt {
- public_key: client_keys().public_key(),
+ public_key: radroots_nostr::key::public_key_from_nostr(
+ client_keys().public_key(),
+ )
+ .expect("identity public key"),
plaintext: "hello from myc".to_owned(),
},
),
@@ -1931,7 +2009,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-nip04-decrypt",
RadrootsNostrConnectRequest::Nip04Decrypt {
- public_key: client_keys().public_key(),
+ public_key: radroots_nostr::key::public_key_from_nostr(
+ client_keys().public_key(),
+ )
+ .expect("identity public key"),
ciphertext: client_ciphertext,
},
),
@@ -1962,7 +2043,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-nip44-encrypt",
RadrootsNostrConnectRequest::Nip44Encrypt {
- public_key: client_keys().public_key(),
+ public_key: radroots_nostr::key::public_key_from_nostr(
+ client_keys().public_key(),
+ )
+ .expect("identity public key"),
plaintext: "hello from myc".to_owned(),
},
),
@@ -1994,7 +2078,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-nip44-decrypt",
RadrootsNostrConnectRequest::Nip44Decrypt {
- public_key: client_keys().public_key(),
+ public_key: radroots_nostr::key::public_key_from_nostr(
+ client_keys().public_key(),
+ )
+ .expect("identity public key"),
ciphertext: client_ciphertext,
},
),
@@ -2024,7 +2111,10 @@ mod tests {
RadrootsNostrConnectRequestMessage::new(
"req-nip04-decrypt",
RadrootsNostrConnectRequest::Nip04Decrypt {
- public_key: client_keys().public_key(),
+ public_key: radroots_nostr::key::public_key_from_nostr(
+ client_keys().public_key(),
+ )
+ .expect("identity public key"),
ciphertext: "invalid".to_owned(),
},
),
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -7,14 +7,14 @@ use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
-use nostr::filter::MatchEventOptions;
-use nostr::{ClientMessage, Event, Filter, JsonUtil, PublicKey, RelayMessage, SubscriptionId};
-use radroots_identity::RadrootsIdentity;
-use radroots_nostr::prelude::{
+use myc::host_identity::RadrootsIdentity;
+use myc::nostr_contract::{
RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrMetadata,
radroots_nostr_build_application_handler_event,
};
-use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
+use nostr::filter::MatchEventOptions;
+use nostr::{ClientMessage, Event, Filter, JsonUtil, PublicKey, RelayMessage, SubscriptionId};
+use radroots_nostr_connect::prelude::RadrootsNostrConnectUri;
use serde_json::Value;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Mutex, Notify, mpsc, oneshot};
@@ -626,19 +626,19 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
&[relay.url()],
);
- let mut first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- first_spec.identifier = Some("myc".to_owned());
- first_spec.relays = vec!["wss://relay-a.example.com".to_owned()];
+ let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-a.example.com".to_owned()]);
publish_handler_event(relay.url(), &app_identity, &first_spec).await?;
- let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- second_spec.identifier = Some("myc".to_owned());
- second_spec.relays = vec!["wss://relay-b.example.com".to_owned()];
+ let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-b.example.com".to_owned()]);
let metadata = RadrootsNostrMetadata {
name: Some("conflict".to_owned()),
..RadrootsNostrMetadata::default()
};
- second_spec.metadata = Some(metadata);
+ second_spec = second_spec.with_metadata(metadata);
publish_handler_event(relay.url(), &app_identity, &second_spec).await?;
relay
@@ -1163,21 +1163,21 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul
&[relay_a.url(), relay_b.url()],
);
- let mut matched_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- matched_spec.identifier = Some("myc".to_owned());
- matched_spec.relays = vec![relay_a.url().to_owned(), relay_b.url().to_owned()];
- let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri {
- remote_signer_public_key: signer_identity.public_key(),
- relays: vec![
- relay_a.url().parse().expect("relay a url"),
- relay_b.url().parse().expect("relay b url"),
- ],
- secret: None,
- })
+ let mut matched_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec![relay_a.url().to_owned(), relay_b.url().to_owned()]);
+ let mut bunker_query = url::form_urlencoded::Serializer::new(String::new());
+ bunker_query.append_pair("relay", relay_a.url());
+ bunker_query.append_pair("relay", relay_b.url());
+ let bunker_uri = RadrootsNostrConnectUri::parse(&format!(
+ "bunker://{}?{}",
+ signer_identity.final_public_key(),
+ bunker_query.finish()
+ ))?
.to_string();
let encoded_bunker_uri: String =
url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect();
- matched_spec.nostrconnect_url = Some(format!(
+ matched_spec = matched_spec.with_nostr_connect_url(format!(
"https://signer.example.com/connect?uri={encoded_bunker_uri}"
));
let matched_metadata = RadrootsNostrMetadata {
@@ -1188,17 +1188,17 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul
picture: Some("https://signer.example.com/logo.png".to_owned()),
..RadrootsNostrMetadata::default()
};
- matched_spec.metadata = Some(matched_metadata);
+ matched_spec = matched_spec.with_metadata(matched_metadata);
publish_handler_event(relay_a.url(), &app_identity, &matched_spec).await?;
- let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- drifted_spec.identifier = Some("myc".to_owned());
- drifted_spec.relays = vec!["wss://stale.example.com".to_owned()];
+ let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://stale.example.com".to_owned()]);
let drifted_metadata = RadrootsNostrMetadata {
name: Some("stale".to_owned()),
..RadrootsNostrMetadata::default()
};
- drifted_spec.metadata = Some(drifted_metadata);
+ drifted_spec = drifted_spec.with_metadata(drifted_metadata);
publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?;
relay_a
diff --git a/tests/logging_run.rs b/tests/logging_run.rs
@@ -1,5 +1,4 @@
-use radroots_identity::RadrootsIdentity;
-use radroots_log::{LogFileLayout, LoggingOptions};
+use myc::host_identity::RadrootsIdentity;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::thread;
@@ -82,16 +81,7 @@ MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10\n",
)
.expect("write env");
- let expected_log_path = LoggingOptions {
- dir: Some(logs_dir.clone()),
- file_name: "myc.log".to_owned(),
- stdout: false,
- default_level: Some("info,myc=info".to_owned()),
- file_layout: LogFileLayout::StableFileName,
- ..LoggingOptions::default()
- }
- .resolved_current_log_file_path()
- .expect("resolved current log path");
+ let expected_log_path = logs_dir.join("myc.log");
let mut child = Command::new(env!("CARGO_BIN_EXE_myc"))
.arg("--env-file")
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -5,6 +5,16 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use myc::control;
+use myc::host_identity::RadrootsIdentity;
+use myc::nostr_contract::{
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrGenericEventBuilder,
+ RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, RadrootsNostrTag,
+ radroots_nostr_build_application_handler_event,
+};
+use myc::signer::prelude::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState,
+ RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionStatus,
+};
use myc::{
MycActiveIdentity, MycConfig, MycConnectionApproval, MycDeliveryOutboxKind,
MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDiscoveryContext, MycDiscoveryLiveStatus,
@@ -26,20 +36,10 @@ use nostr::{
ClientMessage, Event, EventBuilder, Filter, JsonUtil, Keys, Kind, PublicKey, RelayMessage,
SecretKey, SubscriptionId, Tag, Timestamp, UnsignedEvent,
};
-use radroots_identity::RadrootsIdentity;
-use radroots_nostr::prelude::{
- RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrGenericEventBuilder,
- RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, RadrootsNostrTag,
- radroots_nostr_build_application_handler_event,
-};
use radroots_nostr_connect::prelude::{
RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientMetadata,
- RadrootsNostrConnectClientUri, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage,
- RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
-};
-use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState,
- RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionStatus,
+ RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
};
use tempfile::TempDir;
use tokio::net::{TcpListener, TcpStream};
@@ -49,6 +49,60 @@ use tokio_tungstenite::tungstenite::Message;
type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+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_unsigned_event(
+ public_key: PublicKey,
+ created_at_unix: u64,
+ kind: u16,
+ content: &str,
+) -> radroots_nostr_connect::message::UnsignedEvent {
+ radroots_nostr_connect::message::UnsignedEvent::from_json(
+ &serde_json::json!({
+ "pubkey": public_key.to_hex(),
+ "created_at": created_at_unix,
+ "kind": kind,
+ "tags": [],
+ "content": content,
+ })
+ .to_string(),
+ )
+ .expect("unsigned event")
+}
+
+fn connect_client_uri(
+ identity: &RadrootsIdentity,
+ relays: &[&str],
+ secret: &str,
+ metadata: &RadrootsNostrConnectClientMetadata,
+) -> TestResult<String> {
+ let mut query = url::form_urlencoded::Serializer::new(String::new());
+ for relay in relays {
+ query.append_pair("relay", relay);
+ }
+ query.append_pair("secret", secret);
+ if !metadata.requested_permissions().is_empty() {
+ query.append_pair("perms", &metadata.requested_permissions().to_string());
+ }
+ if let Some(name) = metadata.name() {
+ query.append_pair("name", name);
+ }
+ if let Some(url) = metadata.url() {
+ query.append_pair("url", url);
+ }
+ if let Some(image) = metadata.image() {
+ query.append_pair("image", image);
+ }
+ let uri = format!(
+ "nostrconnect://{}?{}",
+ identity.final_public_key(),
+ query.finish()
+ );
+ Ok(RadrootsNostrConnectUri::parse(&uri)?.to_string())
+}
+
const RELAY_EVENT_TIMEOUT: Duration = Duration::from_secs(15);
const EXTERNAL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(15);
const RUNTIME_STATE_TIMEOUT: Duration = Duration::from_secs(15);
@@ -562,7 +616,7 @@ fn connect_request_message_with_metadata(
RadrootsNostrConnectRequestMessage::new(
request_id,
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: signer_public_key,
+ remote_signer_public_key: connect_public_key(signer_public_key),
secret: Some(secret.to_owned()),
requested_permissions: Default::default(),
client_metadata,
@@ -597,6 +651,22 @@ fn build_request_event_with_recipient(
created_at_unix: u64,
) -> Event {
let payload = serde_json::to_string(&request_message).expect("request payload");
+ build_request_event_payload(
+ client_identity,
+ signer_public_key,
+ recipient_public_key,
+ payload.as_str(),
+ created_at_unix,
+ )
+}
+
+fn build_request_event_payload(
+ client_identity: &RadrootsIdentity,
+ signer_public_key: PublicKey,
+ recipient_public_key: PublicKey,
+ payload: &str,
+ created_at_unix: u64,
+) -> Event {
let ciphertext = nip44::encrypt(
client_identity.keys().secret_key(),
&signer_public_key,
@@ -932,7 +1002,7 @@ async fn live_listener_rejects_denied_clients_without_registering_connection() -
assert_eq!(response.id, "denied-connect");
let parsed = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope(
&RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: signer_public_key,
+ remote_signer_public_key: connect_public_key(signer_public_key),
secret: Some("denied-secret".to_owned()),
requested_permissions: Default::default(),
client_metadata: None,
@@ -999,10 +1069,16 @@ async fn live_listener_discards_malformed_and_replayed_request_events() -> TestR
);
publish_event(relay.url(), &wrong_recipient).await?;
- let invalid_request_id = build_request_event(
+ let invalid_request_id = build_request_event_payload(
&client_identity,
signer_public_key,
- connect_request_message("", signer_public_key, "invalid-request-id-secret"),
+ signer_public_key,
+ &serde_json::json!({
+ "id": "",
+ "method": "connect",
+ "params": [signer_public_key.to_hex(), "invalid-request-id-secret"]
+ })
+ .to_string(),
base_created_at + 2,
);
publish_event(relay.url(), &invalid_request_id).await?;
@@ -1091,7 +1167,7 @@ async fn live_listener_enforces_signing_ceiling_and_switch_relay_permission() ->
relay.wait_for_subscription_count(1).await?;
let connect_request = RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: signer_public_key,
+ remote_signer_public_key: connect_public_key(signer_public_key),
secret: None,
requested_permissions: "get_public_key,sign_event:1,sign_event:7,switch_relays".parse()?,
client_metadata: None,
@@ -1151,20 +1227,13 @@ async fn live_listener_enforces_signing_ceiling_and_switch_relay_permission() ->
&get_public_key_request.method(),
decrypt_response(&client_identity, signer_public_key, &responses[1]),
)?,
- RadrootsNostrConnectResponse::UserPublicKey(user_public_key)
+ RadrootsNostrConnectResponse::UserPublicKey(connect_public_key(user_public_key))
);
- let unsigned_event = |kind: u16, content: &str| -> TestResult<UnsignedEvent> {
- Ok(serde_json::from_value(serde_json::json!({
- "pubkey": user_public_key.to_hex(),
- "created_at": base_created_at,
- "kind": kind,
- "tags": [],
- "content": content
- }))?)
+ let unsigned_event = |kind: u16, content: &str| {
+ connect_unsigned_event(user_public_key, base_created_at, kind, content)
};
- let allowed_sign_request =
- RadrootsNostrConnectRequest::SignEvent(unsigned_event(1, "allowed")?);
+ let allowed_sign_request = RadrootsNostrConnectRequest::SignEvent(unsigned_event(1, "allowed"));
publish_event(
relay.url(),
&build_request_event(
@@ -1188,10 +1257,11 @@ async fn live_listener_enforces_signing_ceiling_and_switch_relay_permission() ->
let RadrootsNostrConnectResponse::SignedEvent(signed_event) = allowed_response else {
panic!("expected signed event response");
};
+ let signed_event: Event = serde_json::from_str(&signed_event.as_json())?;
assert_eq!(signed_event.pubkey, user_public_key);
signed_event.verify()?;
- let denied_sign_request = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7, "denied")?);
+ let denied_sign_request = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7, "denied"));
publish_event(
relay.url(),
&build_request_event(
@@ -2854,7 +2924,7 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
RadrootsNostrConnectRequestMessage::new(
"trusted-connect",
RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: signer_public_key,
+ remote_signer_public_key: connect_public_key(signer_public_key),
secret: None,
requested_permissions: "sign_event:1".parse().expect("requested permissions"),
client_metadata: None,
@@ -2871,7 +2941,7 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
let connect_parsed =
radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope(
&RadrootsNostrConnectRequest::Connect {
- remote_signer_public_key: signer_public_key,
+ remote_signer_public_key: connect_public_key(signer_public_key),
secret: None,
requested_permissions: "sign_event:1".parse().expect("requested permissions"),
client_metadata: None,
@@ -2890,16 +2960,12 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
signer_public_key,
RadrootsNostrConnectRequestMessage::new(
request_id,
- RadrootsNostrConnectRequest::SignEvent(
- serde_json::from_value(serde_json::json!({
- "pubkey": runtime.user_identity().public_key().to_hex(),
- "created_at": created_at_unix,
- "kind": 1,
- "tags": [],
- "content": request_id
- }))
- .expect("unsigned event"),
- ),
+ RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(
+ runtime.user_identity().public_key(),
+ created_at_unix,
+ 1,
+ request_id,
+ )),
),
created_at_unix,
)
@@ -2915,16 +2981,12 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
.await?;
let first_auth = decrypt_response(&client_identity, signer_public_key, &response_events[1]);
let first_auth = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectRequest::SignEvent(
- serde_json::from_value(serde_json::json!({
- "pubkey": runtime.user_identity().public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": 1,
- "tags": [],
- "content": "trusted-sign-1"
- }))
- .expect("unsigned event"),
- )
+ &RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(
+ runtime.user_identity().public_key(),
+ Timestamp::from(1).as_secs(),
+ 1,
+ "trusted-sign-1",
+ ))
.method(),
first_auth,
)?;
@@ -2954,16 +3016,12 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
decrypt_response(&client_identity, signer_public_key, &response_events[2]);
let replay_parsed =
radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectRequest::SignEvent(
- serde_json::from_value(serde_json::json!({
- "pubkey": runtime.user_identity().public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": 1,
- "tags": [],
- "content": "trusted-sign-1"
- }))
- .expect("unsigned event"),
- )
+ &RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(
+ runtime.user_identity().public_key(),
+ Timestamp::from(1).as_secs(),
+ 1,
+ "trusted-sign-1",
+ ))
.method(),
replay_response,
)?;
@@ -2984,16 +3042,12 @@ async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> {
.await?;
let second_auth = decrypt_response(&client_identity, signer_public_key, &response_events[3]);
let second_auth = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectRequest::SignEvent(
- serde_json::from_value(serde_json::json!({
- "pubkey": runtime.user_identity().public_key().to_hex(),
- "created_at": Timestamp::from(1).as_secs(),
- "kind": 1,
- "tags": [],
- "content": "trusted-sign-2"
- }))
- .expect("unsigned event"),
- )
+ &RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(
+ runtime.user_identity().public_key(),
+ Timestamp::from(1).as_secs(),
+ 1,
+ "trusted-sign-2",
+ ))
.method(),
second_auth,
)?;
@@ -3023,18 +3077,18 @@ async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds(
.queue_publish_outcomes(signer_public_key, &[false, true])
.await;
- let client_uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri {
- client_public_key: client_identity.public_key(),
- relays: vec![nostr::RelayUrl::parse(relay.url())?],
- secret: "client-secret".to_owned(),
- metadata: RadrootsNostrConnectClientMetadata {
- requested_permissions: Default::default(),
- name: Some(" Connect Accept Client ".to_owned()),
- url: Some("https://connect.example/".to_owned()),
- image: Some("https://connect.example/icon.png".to_owned()),
- },
- })
- .to_string();
+ let client_metadata = RadrootsNostrConnectClientMetadata {
+ requested_permissions: Default::default(),
+ name: Some(" Connect Accept Client ".to_owned()),
+ url: Some("https://connect.example/".to_owned()),
+ image: Some("https://connect.example/icon.png".to_owned()),
+ };
+ let client_uri = connect_client_uri(
+ &client_identity,
+ &[relay.url()],
+ "client-secret",
+ &client_metadata,
+ )?;
let failed = control::accept_client_uri(&runtime, &client_uri)
.await
@@ -3199,16 +3253,12 @@ async fn connect_accept_succeeds_with_any_delivery_policy_when_one_relay_acknowl
.queue_publish_outcomes(signer_public_key, &[true])
.await;
- let client_uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri {
- client_public_key: client_identity.public_key(),
- relays: vec![
- nostr::RelayUrl::parse(relay_a.url())?,
- nostr::RelayUrl::parse(relay_b.url())?,
- ],
- secret: "delivery-any-secret".to_owned(),
- metadata: RadrootsNostrConnectClientMetadata::default(),
- })
- .to_string();
+ let client_uri = connect_client_uri(
+ &client_identity,
+ &[relay_a.url(), relay_b.url()],
+ "delivery-any-secret",
+ &RadrootsNostrConnectClientMetadata::default(),
+ )?;
let accepted = control::accept_client_uri(&runtime, &client_uri).await?;
assert_eq!(accepted.response_relays.len(), 2);
@@ -3274,16 +3324,12 @@ async fn connect_accept_rejects_when_quorum_delivery_policy_is_not_met() -> Test
.queue_publish_outcomes(signer_public_key, &[false])
.await;
- let client_uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri {
- client_public_key: client_identity.public_key(),
- relays: vec![
- nostr::RelayUrl::parse(relay_a.url())?,
- nostr::RelayUrl::parse(relay_b.url())?,
- ],
- secret: "delivery-quorum-secret".to_owned(),
- metadata: RadrootsNostrConnectClientMetadata::default(),
- })
- .to_string();
+ let client_uri = connect_client_uri(
+ &client_identity,
+ &[relay_a.url(), relay_b.url()],
+ "delivery-quorum-secret",
+ &RadrootsNostrConnectClientMetadata::default(),
+ )?;
let error = control::accept_client_uri(&runtime, &client_uri)
.await
@@ -4372,16 +4418,17 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()>
.expect("app identity path"),
)?;
- let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- drifted_spec.identifier = Some("myc".to_owned());
- drifted_spec.relays = vec!["wss://wrong.example.com".to_owned()];
- drifted_spec.nostrconnect_url =
- Some("https://wrong.example.com/connect?uri=nostrconnect%3A%2F%2Fstale".to_owned());
+ let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://wrong.example.com".to_owned()]);
+ drifted_spec = drifted_spec.with_nostr_connect_url(
+ "https://wrong.example.com/connect?uri=nostrconnect%3A%2F%2Fstale".to_owned(),
+ );
let metadata = RadrootsNostrMetadata {
name: Some("stale".to_owned()),
..RadrootsNostrMetadata::default()
};
- drifted_spec.metadata = Some(metadata);
+ drifted_spec = drifted_spec.with_metadata(metadata);
publish_handler_event(relay.url(), &app_identity, &drifted_spec).await?;
relay
.wait_for_published_events_by_author(app_identity.public_key(), 1)
@@ -4456,9 +4503,9 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma
.expect("matched event");
publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?;
- let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- drifted_spec.identifier = Some("myc".to_owned());
- drifted_spec.relays = vec!["wss://stale.example.com".to_owned()];
+ let drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://stale.example.com".to_owned()]);
publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?;
relay_a
@@ -4655,14 +4702,14 @@ async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestR
.expect("app identity path"),
)?;
- let mut first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- first_spec.identifier = Some("myc".to_owned());
- first_spec.relays = vec!["wss://relay-a.example.com".to_owned()];
+ let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-a.example.com".to_owned()]);
publish_handler_event(relay.url(), &app_identity, &first_spec).await?;
- let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- second_spec.identifier = Some("myc".to_owned());
- second_spec.relays = vec!["wss://relay-b.example.com".to_owned()];
+ let second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-b.example.com".to_owned()]);
publish_handler_event(relay.url(), &app_identity, &second_spec).await?;
relay
@@ -4701,14 +4748,14 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu
.expect("matched event");
publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?;
- let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- drifted_spec.identifier = Some("myc".to_owned());
- drifted_spec.relays = vec!["wss://stale.example.com".to_owned()];
+ let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://stale.example.com".to_owned()]);
let drifted_metadata = RadrootsNostrMetadata {
name: Some("stale".to_owned()),
..RadrootsNostrMetadata::default()
};
- drifted_spec.metadata = Some(drifted_metadata);
+ drifted_spec = drifted_spec.with_metadata(drifted_metadata);
publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?;
relay_a
@@ -4882,14 +4929,14 @@ async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestR
.expect("app identity path"),
)?;
- let mut first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- first_spec.identifier = Some("myc".to_owned());
- first_spec.relays = vec!["wss://relay-a.example.com".to_owned()];
+ let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-a.example.com".to_owned()]);
publish_handler_event(relay.url(), &app_identity, &first_spec).await?;
- let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
- second_spec.identifier = Some("myc".to_owned());
- second_spec.relays = vec!["wss://relay-b.example.com".to_owned()];
+ let second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133])
+ .with_identifier("myc".to_owned())
+ .with_relays(vec!["wss://relay-b.example.com".to_owned()]);
publish_handler_event(relay.url(), &app_identity, &second_spec).await?;
relay
diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs
@@ -2,13 +2,13 @@ use std::fs;
use std::path::Path;
use std::process::Command;
+use myc::host_identity::RadrootsIdentity;
+use myc::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
use myc::{
MYC_SIGNER_STATUS_CONTRACT_VERSION, MycActiveIdentity, MycDeliveryOutboxKind,
MycDeliveryOutboxRecord, MycOperationAuditKind, MycOperationAuditOutcome,
MycOperationAuditRecord, MycRuntime,
};
-use radroots_identity::RadrootsIdentity;
-use radroots_nostr::prelude::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind};
use serde_json::{Value, json};
fn write_test_identity(path: &Path, secret_key: &str) {
diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs
@@ -2,19 +2,19 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
+use myc::host_identity::RadrootsIdentity;
+use myc::nostr_contract::{
+ RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrRelayUrl,
+};
+use myc::signer::prelude::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft,
+};
use myc::{
MycActiveIdentity, MycConfig, MycDeliveryOutboxKind, MycDeliveryOutboxRecord,
MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime,
MycRuntimeAuditBackend, MycRuntimeStatus, MycSignerStateBackend, MycTransportDeliveryPolicy,
collect_status_full,
};
-use radroots_identity::RadrootsIdentity;
-use radroots_nostr::prelude::{
- RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrRelayUrl,
-};
-use radroots_nostr_signer::prelude::{
- RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft,
-};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::sleep;
diff --git a/tests/operability_server.rs b/tests/operability_server.rs
@@ -2,8 +2,8 @@ use std::net::{SocketAddr, TcpListener as StdTcpListener};
use std::path::{Path, PathBuf};
use std::time::Duration;
+use myc::host_identity::RadrootsIdentity;
use myc::{MycConfig, MycRuntime, MycTransportDeliveryPolicy};
-use radroots_identity::RadrootsIdentity;
use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs
@@ -1,13 +1,13 @@
use std::path::Path;
use std::process::Command;
+use myc::host_identity::RadrootsIdentity;
+use myc::signer::prelude::RadrootsNostrSignerConnectionDraft;
use myc::{
MycConfig, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord,
MycRuntime, MycRuntimeAuditBackend, MycSignerStateBackend,
};
use nostr::PublicKey;
-use radroots_identity::RadrootsIdentity;
-use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft;
use serde_json::Value;
fn write_identity(path: &Path, secret_key: &str) {