myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 780++++++++++++++++++++++++++-----------------------------------------------------
MCargo.toml | 29+++++++++++++++++------------
Amigrations/signer/0000_init.down.sql | 8++++++++
Amigrations/signer/0000_init.up.sql | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amigrations/signer/0001_publish_workflows.down.sql | 1+
Amigrations/signer/0001_publish_workflows.up.sql | 16++++++++++++++++
Amigrations/signer/0002_client_metadata.down.sql | 2++
Amigrations/signer/0002_client_metadata.up.sql | 2++
Mrust-toolchain.toml | 2+-
Asrc/accounts.rs | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/backend.rs | 22++++++++++------------
Msrc/app/mod.rs | 2+-
Msrc/app/runtime.rs | 18+++++++++---------
Msrc/audit.rs | 4++--
Msrc/audit_sqlite.rs | 12++++++------
Msrc/bin/myc_repo_local_identity_bootstrap.rs | 19++++---------------
Msrc/cli.rs | 37++++++++++++++++++++-----------------
Msrc/config.rs | 20++++++++++----------
Msrc/control.rs | 80++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/custody.rs | 67++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/discovery.rs | 41+++++++++++++++++++++++------------------
Msrc/error.rs | 16+++++++++++-----
Asrc/host_identity.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/identity_files.rs | 348++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/lib.rs | 6++++++
Msrc/logging.rs | 101+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Asrc/nostr_contract.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operability/mod.rs | 16++++++++--------
Msrc/outbox.rs | 14+++++---------
Msrc/outbox_sqlite.rs | 12+++++-------
Msrc/paths.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/persistence.rs | 16++++++++--------
Msrc/policy.rs | 60++++++++++++++++++++++++++++--------------------------------
Asrc/signer/backend.rs | 1753+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/capability.rs | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/error.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/evaluation.rs | 519+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/manager.rs | 4004+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/migrations.rs | 35+++++++++++++++++++++++++++++++++++
Asrc/signer/mod.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/model.rs | 1594+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/nip46.rs | 2191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/sqlite.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/store.rs | 1097+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/test_fixtures.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signer/test_support.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/signing_adapter.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/sql.rs | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport.rs | 8+++-----
Msrc/transport/nip46.rs | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mtests/discovery_cli.rs | 58+++++++++++++++++++++++++++++-----------------------------
Mtests/logging_run.rs | 14++------------
Mtests/nip46_e2e.rs | 303++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mtests/operability_cli.rs | 4++--
Mtests/operability_e2e.rs | 14+++++++-------
Mtests/operability_server.rs | 2+-
Mtests/persistence_cli.rs | 4++--
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(&not_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(&not_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) {