myc

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

commit 3d3f43afffa0aa1acb25fadbd68f6910501d244b
parent 5f0bc868701ae6fe97b749d9924df66077c1e41e
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 22:08:55 +0000

provider: remove obsolete signer stack

Diffstat:
MAGENTS.md | 6++++++
MCargo.lock | 964++-----------------------------------------------------------------------------
MCargo.toml | 21+++------------------
MREADME | 8++++++++
Mradroots.lib.source-lock.v1.toml | 2+-
Dsrc/accounts.rs | 420-------------------------------------------------------------------------------
Dsrc/custody.rs | 2863-------------------------------------------------------------------------------
Dsrc/error.rs | 438-------------------------------------------------------------------------------
Dsrc/host_identity.rs | 327-------------------------------------------------------------------------------
Dsrc/identity_files.rs | 563-------------------------------------------------------------------------------
Dsrc/logging.rs | 86-------------------------------------------------------------------------------
Dsrc/nostr_contract.rs | 114-------------------------------------------------------------------------------
Dsrc/policy.rs | 924-------------------------------------------------------------------------------
Dsrc/signer/backend.rs | 1753-------------------------------------------------------------------------------
Dsrc/signer/capability.rs | 330-------------------------------------------------------------------------------
Dsrc/signer/error.rs | 127-------------------------------------------------------------------------------
Dsrc/signer/evaluation.rs | 519-------------------------------------------------------------------------------
Dsrc/signer/manager.rs | 4004-------------------------------------------------------------------------------
Dsrc/signer/mod.rs | 65-----------------------------------------------------------------
Dsrc/signer/model.rs | 1594-------------------------------------------------------------------------------
Dsrc/signer/nip46.rs | 2191-------------------------------------------------------------------------------
Dsrc/signer/test_fixtures.rs | 107-------------------------------------------------------------------------------
Dsrc/signer/test_support.rs | 85-------------------------------------------------------------------------------
Dsrc/signing_adapter.rs | 88-------------------------------------------------------------------------------
Mtests/build_policy.rs | 2+-
Mtests/services_hardening_legacy_removal.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
26 files changed, 123 insertions(+), 17558 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -71,6 +71,12 @@ exact canonical bytes, and returns only a sealed redacted result. Verification is not publication and performs no provider execution or database mutation. +- Step 136 removes the orphaned prototype provider tree and its keyring, + managed-account, plaintext/adjacent-key, child-process, implicit-identity, + generic remote-session, legacy logging/client, and unused dependency + surfaces. Do not restore those files, dependencies, or Tokio process + capability; `radroots_nostr_connect` remains only as the active NIP-46 + protocol dependency. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/Cargo.lock b/Cargo.lock @@ -115,37 +115,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "async-utility" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" -dependencies = [ - "futures-util", - "gloo-timers", - "tokio", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-wsocket" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" -dependencies = [ - "async-utility", - "futures", - "futures-util", - "js-sys", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-tungstenite", - "url", - "wasm-bindgen", - "web-sys", -] - -[[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -155,12 +124,6 @@ dependencies = [ ] [[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - -[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -173,54 +136,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -330,12 +245,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -463,32 +372,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -513,15 +396,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] name = "crossbeam-queue" version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -543,7 +417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "subtle", "zeroize", ] @@ -555,7 +429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -566,28 +440,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] -name = "dbus" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "dbus", - "openssl", - "zeroize", -] - -[[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -598,15 +450,6 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -654,7 +497,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core 0.6.4", + "rand_core", "sec1", "subtle", "zeroize", @@ -719,7 +562,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -764,21 +607,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -958,25 +786,13 @@ dependencies = [ ] [[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -1128,7 +944,6 @@ dependencies = [ "hyper", "pin-project-lite", "tokio", - "tower-service", ] [[package]] @@ -1308,8 +1123,6 @@ version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -1377,23 +1190,6 @@ dependencies = [ ] [[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "linux-keyutils", - "log", - "openssl", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", -] - -[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1412,16 +1208,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] name = "libsqlite3-sys" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1433,16 +1219,6 @@ dependencies = [ ] [[package]] -name = "linux-keyutils" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1470,27 +1246,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "lru" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] name = "mediatype" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1509,12 +1264,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" [[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] name = "mio" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1529,54 +1278,33 @@ dependencies = [ name = "myc" version = "0.1.0" dependencies = [ - "axum", "base64", "chacha20poly1305", "clap", "futures-executor", - "futures-util", "hex", "jsonschema", - "keyring", "nostr", - "nostr-sdk", - "radroots_event", - "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_runtime_paths", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", - "radroots_signing", "radroots_storage", - "rand 0.9.2", "rustix", "serde", "serde_json", - "serial_test", "sha2", "sqlx", "tempfile", - "thiserror 2.0.18", "tokio", - "tokio-tungstenite", "toml", - "tracing", - "tracing-appender", - "tracing-subscriber", "url", - "uuid", "zeroize", ] [[package]] -name = "negentropy" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" - -[[package]] name = "nostr" version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1603,68 +1331,6 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" -dependencies = [ - "lru", - "nostr", - "tokio", -] - -[[package]] -name = "nostr-gossip" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" -dependencies = [ - "nostr", -] - -[[package]] -name = "nostr-relay-pool" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" -dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-sdk" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" -dependencies = [ - "async-utility", - "nostr", - "nostr-database", - "nostr-gossip", - "nostr-relay-pool", - "tokio", - "tracing", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1704,12 +1370,6 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] name = "num-integer" version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1767,54 +1427,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-src" -version = "300.6.0+3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1856,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -1909,12 +1521,6 @@ dependencies = [ ] [[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2082,7 +1688,6 @@ version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" dependencies = [ "chacha20poly1305", - "keyring", "serde", "sha2", "subtle", @@ -2129,38 +1734,24 @@ dependencies = [ ] [[package]] -name = "radroots_signing" +name = "radroots_storage" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" dependencies = [ - "hex", "radroots_event", "radroots_event_codec", - "radroots_identity", "radroots_protocol", - "serde", + "radroots_trade", + "radroots_transport", "sha2", ] [[package]] -name = "radroots_storage" +name = "radroots_trade" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" dependencies = [ - "radroots_event", - "radroots_event_codec", - "radroots_protocol", - "radroots_trade", - "radroots_transport", - "sha2", -] - -[[package]] -name = "radroots_trade" -version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" -dependencies = [ - "radroots_core", + "radroots_core", "radroots_event", "radroots_identity", ] @@ -2183,18 +1774,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -2204,17 +1785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -2227,15 +1798,6 @@ dependencies = [ ] [[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2311,20 +1873,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] name = "rust_decimal" version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2350,40 +1898,6 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2435,7 +1949,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand 0.8.5", + "rand", "secp256k1-sys", "serde", ] @@ -2450,42 +1964,6 @@ dependencies = [ ] [[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2535,17 +2013,6 @@ dependencies = [ ] [[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] name = "serde_spanned" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2555,42 +2022,6 @@ dependencies = [ ] [[package]] -name = "serial_test" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" -dependencies = [ - "futures-executor", - "futures-util", - "log", - "once_cell", - "parking_lot", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2602,31 +2033,12 @@ dependencies = [ ] [[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2823,12 +2235,6 @@ dependencies = [ ] [[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2893,46 +2299,6 @@ dependencies = [ ] [[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] name = "tinystr" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2967,7 +2333,6 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2985,28 +2350,6 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - -[[package]] name = "tokio-stream" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3018,22 +2361,6 @@ dependencies = [ ] [[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite", - "webpki-roots 0.26.11", -] - -[[package]] name = "tokio-util" version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3089,33 +2416,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3128,18 +2428,6 @@ dependencies = [ ] [[package]] -name = "tracing-appender" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" -dependencies = [ - "crossbeam-channel", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3157,36 +2445,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -3196,25 +2454,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3264,12 +2503,6 @@ dependencies = [ ] [[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3295,12 +2528,6 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3313,18 +2540,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] name = "uuid-simd" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3335,12 +2550,6 @@ dependencies = [ ] [[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3406,16 +2615,6 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "wasm-bindgen-macro" version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3492,24 +2691,6 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3543,25 +2724,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3579,31 +2742,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3613,96 +2759,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3874,20 +2972,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -30,44 +30,29 @@ default = ["service-host"] service-host = [] [dependencies] -axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } base64 = "0.22" chacha20poly1305 = "0.10" clap = { version = "4.5", features = ["derive"] } futures-executor = "0.3" hex = "0.4" jsonschema = { version = "0.48.1", default-features = false } -keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service"] } nostr = { version = "0.44.2", features = ["nip04", "nip44", "nip46", "nip49"] } -nostr-sdk = { version = "0.44.1" } -radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } -radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["serde"] } radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] } radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } -radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] } -radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } +radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" sqlx = { version = "0.9.0", default-features = false, features = ["derive", "sqlite-bundled"] } -rand = "0.9" rustix = { version = "1", features = ["fs", "process", "std"] } -thiserror = "2.0" -tempfile = "3.17" -tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } -tracing = "0.1" -tracing-appender = "0.2" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } toml = "0.8" url = "2.5" -uuid = { version = "1.18", features = ["serde", "v7"] } zeroize = "1.8" [dev-dependencies] -futures-util = "0.3.32" -serial_test = "3" -tokio-tungstenite = "0.26.2" +tempfile = "3.17" diff --git a/README b/README @@ -131,6 +131,14 @@ known. NIP-44 v2 plaintext is admitted only through its exact 65,408-byte implementation ceiling. Verified output remains sealed and redacted, and neither verification nor signing constitutes publication. +The obsolete prototype provider tree has been removed. Myc ships no account +keyring, managed-account selector, plaintext or adjacent-key file adapter, +child-process signer, implicit host identity, generic remote-session signer, +or legacy logging/client wrapper. Its removed production dependencies and +Tokio process capability are absent from the locked graph; the active typed +provider contract, encrypted envelope, credential resolver, local-signer +transport, and independent verifier are the only provider boundaries. + Signer-request admission validates bounded client, request, event, method, canonical request, injected operation entropy, and injected time evidence before storage. Stable domain-separated operation and correlation identities diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e version = "0.1.0-alpha" source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379" lockfile = "Cargo.lock" -lockfile_sha256 = "07cc49b4ef22923aadf33b2d6f245f7c84db9df9bd666df9a838dd401d7e418c" +lockfile_sha256 = "04f566ee4c444c81002f090ac77e61b77000e8aeb1a337ae38447e2f0913a3b9" diff --git a/src/accounts.rs b/src/accounts.rs @@ -1,420 +0,0 @@ -//! Myc-owned managed-account persistence and secret custody. -//! -//! Public account values come from `radroots_identity`; selection, persistence, -//! keyring access, and secret-bearing Nostr keys remain owned by the service. - -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use nostr::{Keys, SecretKey}; -use radroots_identity::account::{Record, Status}; -use radroots_identity::{AccountId, PublicIdentity, PublicKey}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use zeroize::Zeroizing; - -const STORE_VERSION: u32 = 1; - -#[derive(Debug, Error)] -pub enum AccountsError { - #[error("identity error: {0}")] - Identity(String), - #[error("store error: {0}")] - Store(String), - #[error("vault error: {0}")] - Vault(String), - #[error("account not found: {0}")] - AccountNotFound(String), - #[error("invalid account state: {0}")] - InvalidState(String), - #[error("public key does not match secret key")] - PublicKeyMismatch, -} - -#[derive(Debug, Error)] -pub enum SecretVaultError { - #[error("secret backend failed")] - Backend, -} - -pub trait SecretVault: Send + Sync { - fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError>; - fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError>; - fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError>; -} - -#[derive(Debug, Clone, Default)] -pub struct MemorySecretVault { - entries: Arc<RwLock<std::collections::BTreeMap<String, String>>>, -} - -impl MemorySecretVault { - pub fn new() -> Self { - Self::default() - } -} - -impl SecretVault for MemorySecretVault { - fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError> { - self.entries - .write() - .map_err(|_| SecretVaultError::Backend)? - .insert(slot.to_owned(), secret.to_owned()); - Ok(()) - } - - fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError> { - Ok(self - .entries - .read() - .map_err(|_| SecretVaultError::Backend)? - .get(slot) - .cloned()) - } - - fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError> { - self.entries - .write() - .map_err(|_| SecretVaultError::Backend)? - .remove(slot); - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct OsKeyringSecretVault { - service_name: String, -} - -impl OsKeyringSecretVault { - pub fn new(service_name: impl Into<String>) -> Self { - Self { - service_name: service_name.into(), - } - } - - fn entry(&self, slot: &str) -> Result<keyring::Entry, SecretVaultError> { - keyring::Entry::new(self.service_name.as_str(), slot).map_err(|_| SecretVaultError::Backend) - } -} - -impl SecretVault for OsKeyringSecretVault { - fn store_secret(&self, slot: &str, secret: &str) -> Result<(), SecretVaultError> { - self.entry(slot)? - .set_password(secret) - .map_err(|_| SecretVaultError::Backend) - } - - fn load_secret(&self, slot: &str) -> Result<Option<String>, SecretVaultError> { - match self.entry(slot)?.get_password() { - Ok(secret) => Ok(Some(secret)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(_) => Err(SecretVaultError::Backend), - } - } - - fn remove_secret(&self, slot: &str) -> Result<(), SecretVaultError> { - match self.entry(slot)?.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(_) => Err(SecretVaultError::Backend), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountStoreState { - version: u32, - default_account_id: Option<AccountId>, - accounts: Vec<Record>, -} - -impl Default for AccountStoreState { - fn default() -> Self { - Self { - version: STORE_VERSION, - default_account_id: None, - accounts: Vec::new(), - } - } -} - -pub trait AccountStore: Send + Sync { - fn load(&self) -> Result<AccountStoreState, AccountsError>; - fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError>; -} - -#[derive(Debug, Clone)] -pub struct FileAccountStore { - path: PathBuf, -} - -impl FileAccountStore { - pub fn new(path: impl AsRef<Path>) -> Self { - Self { - path: path.as_ref().to_path_buf(), - } - } -} - -impl AccountStore for FileAccountStore { - fn load(&self) -> Result<AccountStoreState, AccountsError> { - if !self.path.exists() { - return Ok(AccountStoreState::default()); - } - let bytes = - std::fs::read(&self.path).map_err(|_| AccountsError::Store("read failed".into()))?; - serde_json::from_slice(&bytes).map_err(|_| AccountsError::Store("invalid JSON".into())) - } - - fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError> { - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent) - .map_err(|_| AccountsError::Store("create directory failed".into()))?; - } - let bytes = serde_json::to_vec_pretty(state) - .map_err(|_| AccountsError::Store("serialization failed".into()))?; - let temporary = self.path.with_extension("json.tmp"); - std::fs::write(&temporary, bytes) - .map_err(|_| AccountsError::Store("write failed".into()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600)) - .map_err(|_| AccountsError::Store("permission update failed".into()))?; - } - std::fs::rename(&temporary, &self.path) - .map_err(|_| AccountsError::Store("atomic replace failed".into())) - } -} - -#[derive(Debug, Clone, Default)] -pub struct MemoryAccountStore { - state: Arc<RwLock<AccountStoreState>>, -} - -impl MemoryAccountStore { - pub fn new() -> Self { - Self::default() - } -} - -impl AccountStore for MemoryAccountStore { - fn load(&self) -> Result<AccountStoreState, AccountsError> { - self.state - .read() - .map(|state| state.clone()) - .map_err(|_| AccountsError::Store("memory store lock poisoned".into())) - } - - fn save(&self, state: &AccountStoreState) -> Result<(), AccountsError> { - *self - .state - .write() - .map_err(|_| AccountsError::Store("memory store lock poisoned".into()))? = - state.clone(); - Ok(()) - } -} - -#[derive(Clone)] -pub struct AccountsManager { - store: Arc<dyn AccountStore>, - vault: Arc<dyn SecretVault>, - state: Arc<RwLock<AccountStoreState>>, -} - -impl AccountsManager { - pub fn new( - store: Arc<dyn AccountStore>, - vault: Arc<dyn SecretVault>, - ) -> Result<Self, AccountsError> { - let state = store.load()?; - if state.version != STORE_VERSION { - return Err(AccountsError::InvalidState( - "unsupported account store version".into(), - )); - } - Ok(Self { - store, - vault, - state: Arc::new(RwLock::new(state)), - }) - } - - pub fn new_file_backed_with_vault( - path: impl AsRef<Path>, - vault: impl SecretVault + 'static, - ) -> Result<Self, AccountsError> { - Self::new(Arc::new(FileAccountStore::new(path)), Arc::new(vault)) - } - - pub fn default_account(&self) -> Result<Option<Record>, AccountsError> { - let state = self.read_state()?; - Ok(state.default_account_id.and_then(|id| { - state - .accounts - .iter() - .find(|account| account.id() == id) - .cloned() - })) - } - - pub fn default_account_id(&self) -> Result<Option<AccountId>, AccountsError> { - Ok(self.read_state()?.default_account_id) - } - - pub fn list_accounts(&self) -> Result<Vec<Record>, AccountsError> { - Ok(self.read_state()?.accounts.clone()) - } - - pub fn default_account_status(&self) -> Result<Status, AccountsError> { - let Some(account) = self.default_account()? else { - return Ok(Status::NotConfigured); - }; - if self.vault.load_secret(&account.id().to_string())?.is_some() { - Ok(Status::Ready { account }) - } else { - Ok(Status::PublicOnly { account }) - } - } - - pub fn default_signing_keys(&self) -> Result<Option<Keys>, AccountsError> { - let Some(account) = self.default_account()? else { - return Ok(None); - }; - let Some(secret) = self.vault.load_secret(&account.id().to_string())? else { - return Ok(None); - }; - let secret = Zeroizing::new(secret); - let key = SecretKey::parse(secret.as_str()) - .map_err(|_| AccountsError::InvalidState("invalid stored secret".into()))?; - let keys = Keys::new(key); - if keys.public_key().to_hex() != account.public_identity().public_key().to_hex() { - return Err(AccountsError::PublicKeyMismatch); - } - Ok(Some(keys)) - } - - pub fn upsert_keys( - &self, - keys: &Keys, - label: Option<String>, - make_default: bool, - ) -> Result<AccountId, AccountsError> { - let public_key = PublicKey::from_hex(&keys.public_key().to_hex()) - .map_err(|error| AccountsError::Identity(error.to_string()))?; - let public_identity = PublicIdentity::new(public_key); - let account_id = AccountId::from_public_identity(&public_identity); - let secret = Zeroizing::new(keys.secret_key().to_secret_hex()); - self.vault - .store_secret(&account_id.to_string(), secret.as_str())?; - self.update_state(|state| { - let now = now_unix_secs(); - if let Some(record) = state - .accounts - .iter_mut() - .find(|record| record.id() == account_id) - { - let created = record.created_at_unix(); - *record = Record::try_from_parts( - account_id, - public_identity.clone(), - label.clone(), - created, - now, - ) - .map_err(|error| AccountsError::Identity(error.to_string()))?; - } else { - state - .accounts - .push(Record::new(public_identity, label.clone(), now)); - } - if state.default_account_id.is_none() || make_default { - state.default_account_id = Some(account_id); - } - Ok(()) - })?; - Ok(account_id) - } - - pub fn generate_keys( - &self, - label: Option<String>, - make_default: bool, - ) -> Result<AccountId, AccountsError> { - self.upsert_keys(&Keys::generate(), label, make_default) - } - - pub fn set_default_account(&self, account_id: &AccountId) -> Result<(), AccountsError> { - self.update_state(|state| { - if !state - .accounts - .iter() - .any(|record| record.id() == *account_id) - { - return Err(AccountsError::AccountNotFound(account_id.to_string())); - } - state.default_account_id = Some(*account_id); - Ok(()) - }) - } - - pub fn remove_account(&self, account_id: &AccountId) -> Result<(), AccountsError> { - self.update_state(|state| { - let before = state.accounts.len(); - state.accounts.retain(|record| record.id() != *account_id); - if before == state.accounts.len() { - return Err(AccountsError::AccountNotFound(account_id.to_string())); - } - if state.default_account_id == Some(*account_id) { - state.default_account_id = None; - } - Ok(()) - })?; - self.vault.remove_secret(&account_id.to_string())?; - Ok(()) - } - - fn read_state( - &self, - ) -> Result<std::sync::RwLockReadGuard<'_, AccountStoreState>, AccountsError> { - self.state - .read() - .map_err(|_| AccountsError::Store("account state lock poisoned".into())) - } - - fn update_state( - &self, - update: impl FnOnce(&mut AccountStoreState) -> Result<(), AccountsError>, - ) -> Result<(), AccountsError> { - let mut state = self - .state - .write() - .map_err(|_| AccountsError::Store("account state lock poisoned".into()))?; - let mut next = state.clone(); - update(&mut next)?; - self.store.save(&next)?; - *state = next; - Ok(()) - } -} - -impl From<SecretVaultError> for AccountsError { - fn from(_: SecretVaultError) -> Self { - Self::Vault("secret backend failed".into()) - } -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) -} - -pub type RadrootsNostrAccountsManager = AccountsManager; -pub type RadrootsNostrAccountsError = AccountsError; -pub type RadrootsNostrMemoryAccountStore = MemoryAccountStore; -pub type RadrootsNostrSecretVaultMemory = MemorySecretVault; -pub type RadrootsSecretVaultOsKeyring = OsKeyringSecretVault; -pub use SecretVault as RadrootsSecretVault; diff --git a/src/custody.rs b/src/custody.rs @@ -1,2863 +0,0 @@ -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use crate::accounts::{ - RadrootsNostrAccountsManager, RadrootsSecretVault, RadrootsSecretVaultOsKeyring, -}; -use crate::host_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic}; -use crate::nostr_contract::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrExternalSigningRequest, - RadrootsNostrGenericEventBuilder, RadrootsNostrPublicKey, -}; -use nostr::nips::nip44::Version; -use nostr::nips::{nip04, nip44}; -use radroots_identity::account::{ - Record as RadrootsNostrAccountRecord, Status as RadrootsNostrAccountStatus, -}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; -use tokio::runtime::RuntimeFlavor; -use tokio::time::Instant as TokioInstant; -use zeroize::Zeroizing; - -use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec}; -use crate::error::MycError; -use crate::identity_files::{ - load_encrypted_identity, load_identity_profile, rotate_encrypted_identity, - store_encrypted_identity, store_identity_profile, -}; - -#[derive(Clone)] -pub struct MycActiveIdentity { - public_identity: RadrootsIdentityPublic, - public_key: RadrootsNostrPublicKey, - operations: Arc<dyn MycIdentityOperations>, -} - -fn store_plaintext_identity( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), MycError> { - identity.save_json(path).map_err(MycError::from) -} - -fn store_secret_text(path: impl AsRef<Path>, value: &str) -> Result<(), MycError> { - let path = path.as_ref(); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { - path: parent.to_path_buf(), - source, - })?; - } - - fs::write(path, value).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - set_secret_permissions(path)?; - Ok(()) -} - -fn set_secret_permissions(path: &Path) -> Result<(), MycError> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let permissions = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(path, permissions).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MycManagedAccountSelectionState { - NotConfigured, - PublicOnly, - Ready, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct MycIdentityStatusOutput { - pub backend: MycIdentityBackend, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option<PathBuf>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub keyring_account_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub keyring_service_name: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_path: Option<PathBuf>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherited_from: Option<String>, - pub resolved: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_account_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_account_label: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_account_state: Option<MycManagedAccountSelectionState>, - pub default_shared_secret_backend: MycIdentityBackend, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_shared_secret_backends: Vec<MycIdentityBackend>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub runtime_specific_custody_modes: Vec<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_vault_policy: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub identity_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub public_key_hex: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycManagedAccountsOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub account_store_path: PathBuf, - pub keyring_service_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub selected_account_id: Option<String>, - pub selected_account_state: MycManagedAccountSelectionState, - pub accounts: Vec<RadrootsNostrAccountRecord>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycManagedAccountMutationOutput { - pub role: String, - pub action: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account_id: Option<String>, - pub state: MycManagedAccountsOutput, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycCustodyExportOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub format: String, - pub out: PathBuf, - pub identity_id: String, - pub public_key_hex: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycCustodyImportOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub format: String, - pub account_id: String, - pub status: MycIdentityStatusOutput, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycCustodyRotateOutput { - pub role: String, - pub backend: MycIdentityBackend, - pub action: String, - pub status: MycIdentityStatusOutput, -} - -const MYC_CUSTODY_FORMAT_NIP49: &str = "nip49"; - -#[derive(Clone)] -pub struct MycIdentityProvider { - role: String, - source: MycIdentitySourceSpec, - backend: MycIdentityProviderBackend, -} - -#[derive(Clone)] -enum MycIdentityProviderBackend { - EncryptedFile { - path: PathBuf, - }, - PlaintextFile { - path: PathBuf, - }, - HostVault { - account_id: RadrootsIdentityId, - service_name: String, - profile_path: Option<PathBuf>, - vault: Arc<dyn RadrootsSecretVault>, - }, - ManagedAccount { - account_store_path: PathBuf, - service_name: String, - manager: RadrootsNostrAccountsManager, - }, - ExternalCommand { - command_path: PathBuf, - timeout: Duration, - executor: Arc<dyn MycExternalCommandExecutor>, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum MycExternalCommandOperation { - Describe, - SignEvent, - Nip04Encrypt, - Nip04Decrypt, - Nip44Encrypt, - Nip44Decrypt, -} - -#[derive(Serialize)] -#[serde(untagged)] -enum MycExternalCommandUnsignedEvent<'a> { - CallerSupplied(&'a nostr::UnsignedEvent), - CheckedProtocol(&'a RadrootsNostrExternalSigningRequest), -} - -#[derive(Serialize)] -struct MycExternalCommandRequest<'a> { - version: u8, - operation: MycExternalCommandOperation, - #[serde(default, skip_serializing_if = "Option::is_none")] - unsigned_event: Option<MycExternalCommandUnsignedEvent<'a>>, - #[serde(default, skip_serializing_if = "Option::is_none")] - public_key_hex: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - content: Option<String>, -} - -#[cfg(test)] -#[derive(Debug, Clone, Deserialize)] -struct MycExternalCommandRequestWire { - version: u8, - operation: MycExternalCommandOperation, - #[serde(default)] - unsigned_event: Option<nostr::UnsignedEvent>, - #[serde(default)] - public_key_hex: Option<String>, - #[serde(default)] - content: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MycExternalCommandResponse { - #[serde(default)] - identity: Option<RadrootsIdentityPublic>, - #[serde(default)] - event: Option<nostr::Event>, - #[serde(default)] - content: Option<String>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone)] -struct MycExternalCommandOutput { - success: bool, - status: Option<i32>, - stdout: Vec<u8>, - stderr: Vec<u8>, -} - -#[derive(Debug)] -enum MycExternalCommandExecuteError { - Io(std::io::Error), - TimedOut, - OutputLimitExceeded { - stream: &'static str, - limit_bytes: usize, - }, -} - -trait MycExternalCommandExecutor: Send + Sync { - fn execute( - &self, - command_path: &Path, - request_json: &[u8], - timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError>; -} - -#[derive(Debug, Default)] -struct MycProcessCommandExecutor; - -const MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES: usize = 1024 * 1024; -const MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES: usize = 64 * 1024; - -impl MycProcessCommandExecutor { - fn execute_on_runtime( - command_path: &Path, - request_json: &[u8], - timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - match tokio::runtime::Handle::try_current() { - Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => { - tokio::task::block_in_place(|| { - handle.block_on(Self::execute_async(command_path, request_json, timeout)) - }) - } - Ok(_) => std::thread::scope(|scope| { - scope - .spawn(|| Self::execute_on_new_runtime(command_path, request_json, timeout)) - .join() - .map_err(|_| { - MycExternalCommandExecuteError::Io(std::io::Error::other( - "external custody command worker panicked", - )) - })? - }), - Err(_) => Self::execute_on_new_runtime(command_path, request_json, timeout), - } - } - - fn execute_on_new_runtime( - command_path: &Path, - request_json: &[u8], - timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(MycExternalCommandExecuteError::Io)? - .block_on(Self::execute_async(command_path, request_json, timeout)) - } - - async fn execute_async( - command_path: &Path, - request_json: &[u8], - timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - let mut child = tokio::process::Command::new(command_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(MycExternalCommandExecuteError::Io)?; - let mut stdin = child.stdin.take().ok_or_else(|| { - MycExternalCommandExecuteError::Io(std::io::Error::other( - "external custody command stdin was unavailable", - )) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - MycExternalCommandExecuteError::Io(std::io::Error::other( - "external custody command stdout was unavailable", - )) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - MycExternalCommandExecuteError::Io(std::io::Error::other( - "external custody command stderr was unavailable", - )) - })?; - let request_json = Zeroizing::new(request_json.to_vec()); - let mut stdin_task = tokio::spawn(async move { - stdin - .write_all(&request_json) - .await - .map_err(MycExternalCommandExecuteError::Io)?; - stdin - .shutdown() - .await - .map_err(MycExternalCommandExecuteError::Io) - }); - let mut stdout_task = tokio::spawn(read_limited_external_command_output( - stdout, - "stdout", - MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES, - )); - let mut stderr_task = tokio::spawn(read_limited_external_command_output( - stderr, - "stderr", - MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES, - )); - let deadline = TokioInstant::now() + timeout; - - let status = match tokio::time::timeout_at(deadline, child.wait()).await { - Ok(Ok(status)) => status, - Ok(Err(source)) => { - abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); - return Err(MycExternalCommandExecuteError::Io(source)); - } - Err(_) => { - let _ = child.kill().await; - let _ = child.wait().await; - abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); - return Err(MycExternalCommandExecuteError::TimedOut); - } - }; - - let collect_output = async { - let (stdin_result, stdout_result, stderr_result) = - tokio::join!(&mut stdin_task, &mut stdout_task, &mut stderr_task); - flatten_external_command_task(stdin_result)?; - let stdout = flatten_external_command_task(stdout_result)?; - let stderr = flatten_external_command_task(stderr_result)?; - Ok((stdout, stderr)) - }; - let (stdout, stderr) = match tokio::time::timeout_at(deadline, collect_output).await { - Ok(result) => result?, - Err(_) => { - abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); - return Err(MycExternalCommandExecuteError::TimedOut); - } - }; - - Ok(MycExternalCommandOutput { - success: status.success(), - status: status.code(), - stdout, - stderr, - }) - } -} - -impl MycExternalCommandExecutor for MycProcessCommandExecutor { - fn execute( - &self, - command_path: &Path, - request_json: &[u8], - timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - Self::execute_on_runtime(command_path, request_json, timeout) - } -} - -async fn read_limited_external_command_output<R>( - mut reader: R, - stream: &'static str, - limit_bytes: usize, -) -> Result<Vec<u8>, MycExternalCommandExecuteError> -where - R: AsyncRead + Unpin, -{ - let mut output = Vec::with_capacity(limit_bytes.min(8 * 1024)); - let mut buffer = [0_u8; 8 * 1024]; - let mut exceeded_limit = false; - loop { - let count = reader - .read(&mut buffer) - .await - .map_err(MycExternalCommandExecuteError::Io)?; - if count == 0 { - break; - } - let remaining = limit_bytes.saturating_sub(output.len()); - output.extend_from_slice(&buffer[..count.min(remaining)]); - exceeded_limit |= count > remaining; - } - if exceeded_limit { - return Err(MycExternalCommandExecuteError::OutputLimitExceeded { - stream, - limit_bytes, - }); - } - Ok(output) -} - -fn flatten_external_command_task<T>( - result: Result<Result<T, MycExternalCommandExecuteError>, tokio::task::JoinError>, -) -> Result<T, MycExternalCommandExecuteError> { - result.map_err(|_| { - MycExternalCommandExecuteError::Io(std::io::Error::other( - "external custody command I/O task failed", - )) - })? -} - -fn abort_external_command_io_tasks<T, U, V>( - stdin_task: &tokio::task::JoinHandle<T>, - stdout_task: &tokio::task::JoinHandle<U>, - stderr_task: &tokio::task::JoinHandle<V>, -) { - stdin_task.abort(); - stdout_task.abort(); - stderr_task.abort(); -} - -trait MycIdentityOperations: Send + Sync { - fn nostr_client(&self) -> RadrootsNostrClient; - fn nostr_client_owned(&self) -> RadrootsNostrClient; - fn sign_protocol_event_builder( - &self, - builder: RadrootsNostrGenericEventBuilder, - operation: &str, - ) -> Result<RadrootsNostrEvent, MycError>; - fn sign_unsigned_event( - &self, - unsigned_event: nostr::UnsignedEvent, - operation: &str, - ) -> Result<nostr::Event, MycError>; - fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError>; - fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError>; - fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError>; - fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError>; -} - -struct MycLoadedIdentityOperations { - identity: Arc<RadrootsIdentity>, -} - -impl MycLoadedIdentityOperations { - fn new(identity: RadrootsIdentity) -> Self { - Self { - identity: Arc::new(identity), - } - } -} - -impl MycIdentityOperations for MycLoadedIdentityOperations { - fn nostr_client(&self) -> RadrootsNostrClient { - RadrootsNostrClient::from_identity(self.identity.as_ref()) - } - - fn nostr_client_owned(&self) -> RadrootsNostrClient { - RadrootsNostrClient::from_identity_owned((*self.identity).clone()) - } - - fn sign_protocol_event_builder( - &self, - builder: RadrootsNostrGenericEventBuilder, - operation: &str, - ) -> Result<RadrootsNostrEvent, MycError> { - builder - .sign_with_keys(self.identity.keys()) - .map_err(|error| { - MycError::InvalidOperation(format!("failed to sign {operation} event: {error}")) - }) - } - - fn sign_unsigned_event( - &self, - unsigned_event: nostr::UnsignedEvent, - operation: &str, - ) -> Result<nostr::Event, MycError> { - unsigned_event - .sign_with_keys(self.identity.keys()) - .map_err(|error| { - MycError::InvalidOperation(format!("failed to sign {operation}: {error}")) - }) - } - - fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError> { - nip04::encrypt(self.identity.keys().secret_key(), public_key, plaintext) - .map_err(|error| MycError::Nip46Encrypt(error.to_string())) - } - - fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError> { - nip04::decrypt(self.identity.keys().secret_key(), public_key, ciphertext) - .map_err(|error| MycError::Nip46Decrypt(error.to_string())) - } - - fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError> { - nip44::encrypt( - self.identity.keys().secret_key(), - public_key, - plaintext, - Version::V2, - ) - .map_err(|error| MycError::Nip46Encrypt(error.to_string())) - } - - fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError> { - nip44::decrypt(self.identity.keys().secret_key(), public_key, ciphertext) - .map_err(|error| MycError::Nip46Decrypt(error.to_string())) - } -} - -struct MycExternalCommandIdentityOperations { - role: String, - command_path: PathBuf, - timeout: Duration, - public_key: RadrootsNostrPublicKey, - executor: Arc<dyn MycExternalCommandExecutor>, -} - -impl MycExternalCommandIdentityOperations { - fn new( - role: String, - command_path: PathBuf, - timeout: Duration, - public_key: RadrootsNostrPublicKey, - executor: Arc<dyn MycExternalCommandExecutor>, - ) -> Self { - Self { - role, - command_path, - timeout, - public_key, - executor, - } - } - - fn execute( - &self, - request: &MycExternalCommandRequest<'_>, - ) -> Result<MycExternalCommandResponse, MycError> { - let request_json = serde_json::to_vec(request)?; - let output = self - .executor - .execute(&self.command_path, &request_json, self.timeout) - .map_err(|error| match error { - MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo { - role: self.role.clone(), - path: self.command_path.clone(), - source, - }, - MycExternalCommandExecuteError::TimedOut => { - MycError::CustodyExternalCommandTimedOut { - role: self.role.clone(), - path: self.command_path.clone(), - timeout_secs: self.timeout.as_secs(), - } - } - MycExternalCommandExecuteError::OutputLimitExceeded { - stream, - limit_bytes, - } => MycError::CustodyExternalCommandOutputLimit { - role: self.role.clone(), - path: self.command_path.clone(), - stream, - limit_bytes, - }, - })?; - if !output.success { - return Err(MycError::CustodyExternalCommandFailed { - role: self.role.clone(), - path: self.command_path.clone(), - status: output - .status - .map(|status| status.to_string()) - .unwrap_or_else(|| "terminated by signal".to_owned()), - diagnostic: if output.stderr.is_empty() { - "helper returned no diagnostic output" - } else { - "helper diagnostic output was redacted" - }, - }); - } - let response: MycExternalCommandResponse = - serde_json::from_slice(&output.stdout).map_err(|source| { - MycError::CustodyExternalCommandParse { - role: self.role.clone(), - path: self.command_path.clone(), - source, - } - })?; - if response.error.is_some() { - return Err(MycError::CustodyExternalCommandFailed { - role: self.role.clone(), - path: self.command_path.clone(), - status: "0".to_owned(), - diagnostic: "helper reported a protocol error", - }); - } - Ok(response) - } -} - -impl MycIdentityOperations for MycExternalCommandIdentityOperations { - fn nostr_client(&self) -> RadrootsNostrClient { - RadrootsNostrClient::new_signerless() - } - - fn nostr_client_owned(&self) -> RadrootsNostrClient { - self.nostr_client() - } - - fn sign_protocol_event_builder( - &self, - builder: RadrootsNostrGenericEventBuilder, - operation: &str, - ) -> Result<RadrootsNostrEvent, MycError> { - let request = builder.into_external_signing_request(self.public_key)?; - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::SignEvent, - unsigned_event: Some(MycExternalCommandUnsignedEvent::CheckedProtocol(&request)), - public_key_hex: None, - content: None, - })?; - let event = response.event.ok_or_else(|| { - MycError::InvalidOperation(format!( - "external signer command did not return a signed event for {operation}" - )) - })?; - request.complete(event).map_err(Into::into) - } - - fn sign_unsigned_event( - &self, - mut unsigned_event: nostr::UnsignedEvent, - operation: &str, - ) -> Result<nostr::Event, MycError> { - if unsigned_event.pubkey != self.public_key || unsigned_event.verify_id().is_err() { - return Err(MycError::CustodyExternalCommandUnsignedEventInvalid { - role: self.role.clone(), - path: self.command_path.clone(), - operation: operation.to_owned(), - }); - } - let expected_event_id = unsigned_event.id(); - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::SignEvent, - unsigned_event: Some(MycExternalCommandUnsignedEvent::CallerSupplied( - &unsigned_event, - )), - public_key_hex: None, - content: None, - })?; - let event = response.event.ok_or_else(|| { - MycError::InvalidOperation(format!( - "external signer command did not return a signed event for {operation}" - )) - })?; - if event.pubkey != self.public_key || event.id != expected_event_id { - return Err(MycError::CustodyExternalCommandSignedEventMismatch { - role: self.role.clone(), - path: self.command_path.clone(), - operation: operation.to_owned(), - }); - } - event - .verify() - .map_err(|_| MycError::CustodyExternalCommandSignedEventInvalid { - role: self.role.clone(), - path: self.command_path.clone(), - operation: operation.to_owned(), - })?; - Ok(event) - } - - fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError> { - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::Nip04Encrypt, - unsigned_event: None, - public_key_hex: Some(public_key.to_hex()), - content: Some(plaintext), - })?; - response.content.ok_or_else(|| { - MycError::InvalidOperation( - "external signer command did not return NIP-04 ciphertext".to_owned(), - ) - }) - } - - fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError> { - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::Nip04Decrypt, - unsigned_event: None, - public_key_hex: Some(public_key.to_hex()), - content: Some(ciphertext.to_owned()), - })?; - response.content.ok_or_else(|| { - MycError::InvalidOperation( - "external signer command did not return NIP-04 cleartext".to_owned(), - ) - }) - } - - fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: String, - ) -> Result<String, MycError> { - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::Nip44Encrypt, - unsigned_event: None, - public_key_hex: Some(public_key.to_hex()), - content: Some(plaintext), - })?; - response.content.ok_or_else(|| { - MycError::InvalidOperation( - "external signer command did not return NIP-44 ciphertext".to_owned(), - ) - }) - } - - fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, MycError> { - let response = self.execute(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::Nip44Decrypt, - unsigned_event: None, - public_key_hex: Some(public_key.to_hex()), - content: Some(ciphertext.to_owned()), - })?; - response.content.ok_or_else(|| { - MycError::InvalidOperation( - "external signer command did not return NIP-44 cleartext".to_owned(), - ) - }) - } -} - -impl MycIdentityProvider { - pub fn from_source( - role: impl Into<String>, - source: MycIdentitySourceSpec, - external_command_timeout: Duration, - ) -> Result<Self, MycError> { - let role = role.into(); - let backend = match source.backend { - MycIdentityBackend::EncryptedFile => { - let path = source.path.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity encrypted_file backend requires a path" - )) - })?; - MycIdentityProviderBackend::EncryptedFile { path } - } - MycIdentityBackend::PlaintextFile => { - let path = source.path.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity plaintext_file backend requires a path" - )) - })?; - MycIdentityProviderBackend::PlaintextFile { path } - } - MycIdentityBackend::HostVault => { - let account_id = RadrootsIdentityId::parse( - source.keyring_account_id.as_deref().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity host_vault backend requires keyring_account_id" - )) - })?, - ) - .map_err(|_| { - MycError::InvalidConfig(format!( - "{role} identity host_vault backend requires a valid keyring_account_id" - )) - })?; - let service_name = source.keyring_service_name.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity host_vault backend requires keyring_service_name" - )) - })?; - Self::vault_provider(role.as_str(), &source, account_id, service_name)? - } - MycIdentityBackend::ManagedAccount => { - let account_store_path = source.path.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity managed_account backend requires a path" - )) - })?; - let service_name = source.keyring_service_name.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity managed_account backend requires keyring_service_name" - )) - })?; - Self::managed_account_provider(role.as_str(), account_store_path, service_name)? - } - MycIdentityBackend::ExternalCommand => { - let command_path = source.path.clone().ok_or_else(|| { - MycError::InvalidConfig(format!( - "{role} identity external_command backend requires a path" - )) - })?; - MycIdentityProviderBackend::ExternalCommand { - command_path, - timeout: external_command_timeout, - executor: Arc::new(MycProcessCommandExecutor), - } - } - }; - - Ok(Self { - role, - source, - backend, - }) - } - - pub fn load_identity(&self) -> Result<RadrootsIdentity, MycError> { - match &self.backend { - MycIdentityProviderBackend::EncryptedFile { path } => { - Ok(load_encrypted_identity(path)?) - } - MycIdentityProviderBackend::PlaintextFile { path } => { - RadrootsIdentity::load_from_path_auto(path).map_err(Into::into) - } - MycIdentityProviderBackend::HostVault { - account_id, - service_name, - profile_path, - vault, - } => { - let secret_key_hex = vault - .load_secret(account_id.as_str()) - .map_err(|source| MycError::CustodyVault { - role: self.role.clone(), - source: source.into(), - })? - .ok_or_else(|| MycError::CustodySecretNotFound { - role: self.role.clone(), - service_name: service_name.clone(), - account_id: account_id.to_string(), - })?; - let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; - if identity.id() != *account_id { - return Err(MycError::CustodySecretIdentityMismatch { - role: self.role.clone(), - service_name: service_name.clone(), - account_id: account_id.to_string(), - resolved_identity_id: identity.id().to_string(), - }); - } - if let Some(profile_path) = profile_path { - let profile_identity = load_identity_profile(profile_path)?; - if profile_identity.id != *account_id { - return Err(MycError::CustodyProfileIdentityMismatch { - role: self.role.clone(), - path: profile_path.clone(), - account_id: account_id.to_string(), - profile_identity_id: profile_identity.id.to_string(), - }); - } - if let Some(profile) = profile_identity.profile { - identity.set_profile(profile); - } - } - Ok(identity) - } - MycIdentityProviderBackend::ManagedAccount { - account_store_path, - service_name, - manager, - } => match manager.default_account_status().map_err(|source| { - MycError::CustodyManager { - role: self.role.clone(), - source, - } - })? { - RadrootsNostrAccountStatus::NotConfigured => { - Err(MycError::CustodyManagedAccountNotConfigured { - role: self.role.clone(), - path: account_store_path.clone(), - }) - } - RadrootsNostrAccountStatus::PublicOnly { account } => { - Err(MycError::CustodyManagedAccountPublicOnly { - role: self.role.clone(), - path: account_store_path.clone(), - service_name: service_name.clone(), - account_id: account.id().to_string(), - }) - } - RadrootsNostrAccountStatus::Ready { .. } => manager - .default_signing_keys() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })? - .map(RadrootsIdentity::new) - .ok_or_else(|| MycError::CustodyManagedAccountNotConfigured { - role: self.role.clone(), - path: account_store_path.clone(), - }), - _ => Err(MycError::InvalidOperation(format!( - "{} managed account backend returned an unsupported account status", - self.role - ))), - }, - MycIdentityProviderBackend::ExternalCommand { command_path, .. } => { - Err(MycError::InvalidOperation(format!( - "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process", - self.role, - command_path.display() - ))) - } - } - } - - pub fn load_active_identity(&self) -> Result<MycActiveIdentity, MycError> { - match &self.backend { - MycIdentityProviderBackend::ExternalCommand { - command_path, - timeout, - executor, - } => { - let (public_identity, public_key) = - self.load_external_command_identity(command_path, *timeout, executor.as_ref())?; - Ok(MycActiveIdentity::from_operations( - public_identity.clone(), - public_key, - Arc::new(MycExternalCommandIdentityOperations::new( - self.role.clone(), - command_path.clone(), - *timeout, - public_key, - executor.clone(), - )), - )) - } - _ => self.load_identity().map(MycActiveIdentity::new), - } - } - - pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput { - match &self.backend { - MycIdentityProviderBackend::ManagedAccount { .. } => { - self.managed_account_status(Ok(()), self.selected_managed_account_record_result()) - } - _ => self.status_with_public_identity(identity.public_identity()), - } - } - - pub fn probe_status(&self) -> MycIdentityStatusOutput { - match &self.backend { - MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status( - self.load_identity_public().as_ref().map(|_| ()), - self.selected_managed_account_record_result(), - ), - _ => match self.load_identity_public() { - Ok(identity) => self.status_with_public_identity(&identity), - Err(error) => self.status_with_error(&error), - }, - } - } - - pub fn source(&self) -> &MycIdentitySourceSpec { - &self.source - } - - pub fn status_output(&self) -> MycIdentityStatusOutput { - self.probe_status() - } - - pub fn export_nip49( - &self, - out: impl AsRef<std::path::Path>, - password: &str, - ) -> Result<MycCustodyExportOutput, MycError> { - self.ensure_secret_materialized_operation("export NIP-49 secrets")?; - let out = out.as_ref(); - let identity = self.load_identity()?; - let payload = identity.encrypt_secret_key_ncryptsec(password)?; - store_secret_text(out, payload.as_str())?; - Ok(MycCustodyExportOutput { - role: self.role.clone(), - backend: self.source.backend, - format: MYC_CUSTODY_FORMAT_NIP49.to_owned(), - out: out.to_path_buf(), - identity_id: identity.id().to_string(), - public_key_hex: identity.public_key_hex(), - }) - } - - pub fn import_nip49( - &self, - path: impl AsRef<std::path::Path>, - password: &str, - label: Option<String>, - ) -> Result<MycCustodyImportOutput, MycError> { - self.ensure_secret_materialized_operation("import NIP-49 secrets")?; - let identity = load_identity_from_nip49_file(path.as_ref(), password)?; - let account_id = identity.id().to_string(); - match &self.backend { - MycIdentityProviderBackend::ManagedAccount { manager, .. } => { - manager - .upsert_keys(identity.keys(), label, true) - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })?; - } - _ => { - if let Some(label) = label { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `{}` does not support --label for `import-nip49` (got `{label}`)", - self.role, - self.source.backend.as_str(), - ))); - } - self.store_identity(&identity)?; - } - } - Ok(MycCustodyImportOutput { - role: self.role.clone(), - backend: self.source.backend, - format: MYC_CUSTODY_FORMAT_NIP49.to_owned(), - account_id, - status: self.probe_status(), - }) - } - - pub fn rotate_secret_storage(&self) -> Result<MycCustodyRotateOutput, MycError> { - match &self.backend { - MycIdentityProviderBackend::EncryptedFile { path } => { - rotate_encrypted_identity(path)?; - } - MycIdentityProviderBackend::PlaintextFile { .. } => { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `plaintext_file` does not support `custody rotate`; migrate to `encrypted_file`, `host_vault`, or `managed_account` first", - self.role - ))); - } - MycIdentityProviderBackend::HostVault { .. } => { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `host_vault` does not define an in-process `custody rotate` action; rotate or re-provision the secret through the host vault itself", - self.role - ))); - } - MycIdentityProviderBackend::ManagedAccount { .. } => { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `managed_account` does not define an in-process `custody rotate` action; rotate the selected account through the configured host vault policy", - self.role - ))); - } - MycIdentityProviderBackend::ExternalCommand { command_path, .. } => { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process and cannot rotate local storage", - self.role, - command_path.display(), - ))); - } - } - - Ok(MycCustodyRotateOutput { - role: self.role.clone(), - backend: self.source.backend, - action: "rotate".to_owned(), - status: self.probe_status(), - }) - } - - pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> { - self.managed_accounts_output() - } - - pub fn generate_managed_account( - &self, - label: Option<String>, - make_selected: bool, - ) -> Result<MycManagedAccountMutationOutput, MycError> { - let account_id = { - let manager = self.managed_accounts_manager()?; - manager - .generate_keys(label, make_selected) - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })? - }; - Ok(MycManagedAccountMutationOutput { - role: self.role.clone(), - action: "generate".to_owned(), - account_id: Some(account_id.to_string()), - state: self.managed_accounts_output()?, - }) - } - - pub fn import_managed_account_file( - &self, - path: impl AsRef<std::path::Path>, - label: Option<String>, - make_selected: bool, - ) -> Result<MycManagedAccountMutationOutput, MycError> { - let account_id = { - let manager = self.managed_accounts_manager()?; - let identity = RadrootsIdentity::load_from_path_auto(path).map_err(MycError::from)?; - manager - .upsert_keys(identity.keys(), label, make_selected) - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })? - }; - Ok(MycManagedAccountMutationOutput { - role: self.role.clone(), - action: "import_file".to_owned(), - account_id: Some(account_id.to_string()), - state: self.managed_accounts_output()?, - }) - } - - pub fn select_managed_account( - &self, - account_id: &str, - ) -> Result<MycManagedAccountMutationOutput, MycError> { - let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| { - MycError::InvalidOperation(format!("invalid managed account id `{account_id}`")) - })?; - { - let manager = self.managed_accounts_manager()?; - manager - .set_default_account(&account_id.to_final().into()) - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })?; - } - Ok(MycManagedAccountMutationOutput { - role: self.role.clone(), - action: "select".to_owned(), - account_id: Some(account_id.to_string()), - state: self.managed_accounts_output()?, - }) - } - - pub fn remove_managed_account( - &self, - account_id: &str, - ) -> Result<MycManagedAccountMutationOutput, MycError> { - let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| { - MycError::InvalidOperation(format!("invalid managed account id `{account_id}`")) - })?; - { - let manager = self.managed_accounts_manager()?; - manager - .remove_account(&account_id.to_final().into()) - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })?; - } - Ok(MycManagedAccountMutationOutput { - role: self.role.clone(), - action: "remove".to_owned(), - account_id: Some(account_id.to_string()), - state: self.managed_accounts_output()?, - }) - } - - fn store_identity(&self, identity: &RadrootsIdentity) -> Result<(), MycError> { - match &self.backend { - MycIdentityProviderBackend::EncryptedFile { path } => { - Ok(store_encrypted_identity(path, identity)?) - } - MycIdentityProviderBackend::PlaintextFile { path } => { - store_plaintext_identity(path, identity) - } - MycIdentityProviderBackend::HostVault { - account_id, - service_name, - profile_path, - vault, - } => { - let identity_id = identity.id(); - if identity_id != *account_id { - return Err(MycError::CustodySecretIdentityMismatch { - role: self.role.clone(), - service_name: service_name.clone(), - account_id: account_id.to_string(), - resolved_identity_id: identity_id.to_string(), - }); - } - let secret_key_hex = Zeroizing::new(identity.secret_key_hex()); - vault - .store_secret(account_id.as_str(), secret_key_hex.as_str()) - .map_err(|source| MycError::CustodyVault { - role: self.role.clone(), - source: source.into(), - })?; - if let Some(profile_path) = profile_path { - store_identity_profile(profile_path, identity)?; - } - Ok(()) - } - MycIdentityProviderBackend::ManagedAccount { .. } => { - Err(MycError::InvalidOperation(format!( - "{} identity backend `managed_account` requires account-store lifecycle helpers instead of direct identity writes", - self.role - ))) - } - MycIdentityProviderBackend::ExternalCommand { command_path, .. } => { - Err(MycError::InvalidOperation(format!( - "{} identity backend `external_command` at {} does not support direct secret writes", - self.role, - command_path.display(), - ))) - } - } - } - - fn ensure_secret_materialized_operation(&self, operation: &str) -> Result<(), MycError> { - if let MycIdentityProviderBackend::ExternalCommand { command_path, .. } = &self.backend { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `external_command` at {} does not support `{operation}` because secret material never enters the myc process", - self.role, - command_path.display(), - ))); - } - Ok(()) - } - - fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> { - match &self.backend { - MycIdentityProviderBackend::ExternalCommand { - command_path, - timeout, - executor, - } => self - .load_external_command_identity(command_path, *timeout, executor.as_ref()) - .map(|(identity, _)| identity), - _ => self.load_identity().map(|identity| identity.to_public()), - } - } - - fn load_external_command_identity( - &self, - command_path: &Path, - timeout: Duration, - executor: &dyn MycExternalCommandExecutor, - ) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> { - let request_json = serde_json::to_vec(&MycExternalCommandRequest { - version: 1, - operation: MycExternalCommandOperation::Describe, - unsigned_event: None, - public_key_hex: None, - content: None, - })?; - let output = executor - .execute(command_path, &request_json, timeout) - .map_err(|error| match error { - MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo { - role: self.role.clone(), - path: command_path.to_path_buf(), - source, - }, - MycExternalCommandExecuteError::TimedOut => { - MycError::CustodyExternalCommandTimedOut { - role: self.role.clone(), - path: command_path.to_path_buf(), - timeout_secs: timeout.as_secs(), - } - } - MycExternalCommandExecuteError::OutputLimitExceeded { - stream, - limit_bytes, - } => MycError::CustodyExternalCommandOutputLimit { - role: self.role.clone(), - path: command_path.to_path_buf(), - stream, - limit_bytes, - }, - })?; - if !output.success { - return Err(MycError::CustodyExternalCommandFailed { - role: self.role.clone(), - path: command_path.to_path_buf(), - status: output - .status - .map(|status| status.to_string()) - .unwrap_or_else(|| "terminated by signal".to_owned()), - diagnostic: if output.stderr.is_empty() { - "helper returned no diagnostic output" - } else { - "helper diagnostic output was redacted" - }, - }); - } - let response: MycExternalCommandResponse = - serde_json::from_slice(&output.stdout).map_err(|source| { - MycError::CustodyExternalCommandParse { - role: self.role.clone(), - path: command_path.to_path_buf(), - source, - } - })?; - if response.error.is_some() { - return Err(MycError::CustodyExternalCommandFailed { - role: self.role.clone(), - path: command_path.to_path_buf(), - status: "0".to_owned(), - diagnostic: "helper reported a protocol error", - }); - } - let identity = - response - .identity - .ok_or_else(|| MycError::CustodyExternalCommandInvalidIdentity { - role: self.role.clone(), - path: command_path.to_path_buf(), - message: "missing `identity` in describe response".to_owned(), - })?; - validate_external_command_public_identity(&self.role, command_path, identity) - } - - fn status_with_public_identity( - &self, - identity: &RadrootsIdentityPublic, - ) -> MycIdentityStatusOutput { - MycIdentityStatusOutput { - backend: self.source.backend, - path: self.source.path.clone(), - keyring_account_id: self.source.keyring_account_id.clone(), - keyring_service_name: self.source.keyring_service_name.clone(), - profile_path: self.source.profile_path.clone(), - inherited_from: None, - resolved: true, - selected_account_id: None, - selected_account_label: None, - selected_account_state: None, - default_shared_secret_backend: MycConfig::default_shared_secret_backend(), - allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), - runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), - host_vault_policy: MycConfig::host_vault_policy(), - identity_id: Some(identity.id.to_string()), - public_key_hex: Some(identity.public_key_hex.clone()), - error: None, - } - } - - fn status_with_error(&self, error: &MycError) -> MycIdentityStatusOutput { - MycIdentityStatusOutput { - backend: self.source.backend, - path: self.source.path.clone(), - keyring_account_id: self.source.keyring_account_id.clone(), - keyring_service_name: self.source.keyring_service_name.clone(), - profile_path: self.source.profile_path.clone(), - inherited_from: None, - resolved: false, - selected_account_id: None, - selected_account_label: None, - selected_account_state: None, - default_shared_secret_backend: MycConfig::default_shared_secret_backend(), - allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), - runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), - host_vault_policy: MycConfig::host_vault_policy(), - identity_id: None, - public_key_hex: None, - error: Some(error.to_string()), - } - } - - fn selected_managed_account_record_result( - &self, - ) -> Result<Option<RadrootsNostrAccountRecord>, MycError> { - let manager = self.managed_accounts_manager()?; - manager - .default_account() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - }) - } - - fn managed_account_status( - &self, - identity_result: Result<(), &MycError>, - account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>, - ) -> MycIdentityStatusOutput { - let MycIdentityProviderBackend::ManagedAccount { - account_store_path, - service_name, - manager, - } = &self.backend - else { - return match self.load_identity_public() { - Ok(identity) => self.status_with_public_identity(&identity), - Err(error) => self.status_with_error(&error), - }; - }; - - let (selected_account_id, selected_account_label, identity_id, public_key_hex) = - match account_result { - Ok(Some(account)) => ( - Some(account.id().to_string()), - account.label().map(ToOwned::to_owned), - Some(account.id().to_string()), - Some(account.public_identity().public_key().to_hex()), - ), - Ok(None) => (None, None, None, None), - Err(error) => { - return MycIdentityStatusOutput { - backend: self.source.backend, - path: Some(account_store_path.clone()), - keyring_account_id: None, - keyring_service_name: Some(service_name.clone()), - profile_path: None, - inherited_from: None, - resolved: false, - selected_account_id: None, - selected_account_label: None, - selected_account_state: None, - default_shared_secret_backend: MycConfig::default_shared_secret_backend(), - allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), - runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), - host_vault_policy: MycConfig::host_vault_policy(), - identity_id: None, - public_key_hex: None, - error: Some(error.to_string()), - }; - } - }; - - let (resolved, selected_account_state, error) = match manager - .default_account_status() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - }) { - Ok(RadrootsNostrAccountStatus::NotConfigured) => ( - false, - Some(MycManagedAccountSelectionState::NotConfigured), - Some( - MycError::CustodyManagedAccountNotConfigured { - role: self.role.clone(), - path: account_store_path.clone(), - } - .to_string(), - ), - ), - Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => ( - false, - Some(MycManagedAccountSelectionState::PublicOnly), - Some( - MycError::CustodyManagedAccountPublicOnly { - role: self.role.clone(), - path: account_store_path.clone(), - service_name: service_name.clone(), - account_id: account.id().to_string(), - } - .to_string(), - ), - ), - Ok(RadrootsNostrAccountStatus::Ready { .. }) => match identity_result { - Ok(_) => (true, Some(MycManagedAccountSelectionState::Ready), None), - Err(error) => ( - false, - Some(MycManagedAccountSelectionState::Ready), - Some(error.to_string()), - ), - }, - Ok(_) => ( - false, - None, - Some("managed account backend returned an unsupported account status".to_owned()), - ), - Err(error) => (false, None, Some(error.to_string())), - }; - - MycIdentityStatusOutput { - backend: self.source.backend, - path: Some(account_store_path.clone()), - keyring_account_id: None, - keyring_service_name: Some(service_name.clone()), - profile_path: None, - inherited_from: None, - resolved, - selected_account_id, - selected_account_label, - selected_account_state, - default_shared_secret_backend: MycConfig::default_shared_secret_backend(), - allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), - runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), - host_vault_policy: MycConfig::host_vault_policy(), - identity_id, - public_key_hex, - error, - } - } - - fn managed_accounts_output(&self) -> Result<MycManagedAccountsOutput, MycError> { - let MycIdentityProviderBackend::ManagedAccount { - account_store_path, - service_name, - manager, - } = &self.backend - else { - return Err(MycError::InvalidOperation(format!( - "{} identity backend `{}` does not support managed account lifecycle commands", - self.role, - self.source.backend.as_str(), - ))); - }; - - let accounts = manager - .list_accounts() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })?; - let selected_account_id = manager - .default_account_id() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })? - .map(|value| value.to_string()); - let selected_account_state = - match manager - .default_account_status() - .map_err(|source| MycError::CustodyManager { - role: self.role.clone(), - source, - })? { - RadrootsNostrAccountStatus::NotConfigured => { - MycManagedAccountSelectionState::NotConfigured - } - RadrootsNostrAccountStatus::PublicOnly { .. } => { - MycManagedAccountSelectionState::PublicOnly - } - RadrootsNostrAccountStatus::Ready { .. } => MycManagedAccountSelectionState::Ready, - _ => MycManagedAccountSelectionState::PublicOnly, - }; - - Ok(MycManagedAccountsOutput { - role: self.role.clone(), - backend: self.source.backend, - account_store_path: account_store_path.clone(), - keyring_service_name: service_name.clone(), - selected_account_id, - selected_account_state, - accounts, - }) - } - - fn managed_accounts_manager(&self) -> Result<&RadrootsNostrAccountsManager, MycError> { - match &self.backend { - MycIdentityProviderBackend::ManagedAccount { manager, .. } => Ok(manager), - _ => Err(MycError::InvalidOperation(format!( - "{} identity backend `{}` does not support managed account lifecycle commands", - self.role, - self.source.backend.as_str(), - ))), - } - } - - fn vault_provider( - role: &str, - source: &MycIdentitySourceSpec, - account_id: RadrootsIdentityId, - service_name: String, - ) -> Result<MycIdentityProviderBackend, MycError> { - if service_name.trim().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{role} identity host_vault backend requires a non-empty keyring_service_name" - ))); - } - Ok(MycIdentityProviderBackend::HostVault { - account_id, - service_name: service_name.clone(), - profile_path: source.profile_path.clone(), - vault: Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)), - }) - } - - fn managed_account_provider( - role: &str, - account_store_path: PathBuf, - service_name: String, - ) -> Result<MycIdentityProviderBackend, MycError> { - if account_store_path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{role} identity managed_account backend requires a non-empty path" - ))); - } - if service_name.trim().is_empty() { - return Err(MycError::InvalidConfig(format!( - "{role} identity managed_account backend requires a non-empty keyring_service_name" - ))); - } - if let Some(parent) = account_store_path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { - path: parent.to_path_buf(), - source, - })?; - } - let manager = RadrootsNostrAccountsManager::new_file_backed_with_vault( - account_store_path.as_path(), - RadrootsSecretVaultOsKeyring::new(service_name.clone()), - ) - .map_err(|source| MycError::CustodyManager { - role: role.to_owned(), - source, - })?; - Ok(MycIdentityProviderBackend::ManagedAccount { - account_store_path, - service_name, - manager, - }) - } -} - -impl MycActiveIdentity { - pub fn new(identity: RadrootsIdentity) -> Self { - let public_identity = identity.to_public(); - let public_key = identity.public_key(); - Self::from_operations( - public_identity, - public_key, - Arc::new(MycLoadedIdentityOperations::new(identity)), - ) - } - - fn from_operations( - public_identity: RadrootsIdentityPublic, - public_key: RadrootsNostrPublicKey, - operations: Arc<dyn MycIdentityOperations>, - ) -> Self { - Self { - public_identity, - public_key, - operations, - } - } - - pub fn id(&self) -> RadrootsIdentityId { - self.public_identity.id.clone() - } - - pub fn public_key(&self) -> RadrootsNostrPublicKey { - self.public_key - } - - pub fn public_key_hex(&self) -> String { - self.public_identity.public_key_hex.clone() - } - - pub fn to_public(&self) -> RadrootsIdentityPublic { - self.public_identity.clone() - } - - pub fn public_identity(&self) -> &RadrootsIdentityPublic { - &self.public_identity - } - - pub fn nostr_client(&self) -> RadrootsNostrClient { - self.operations.nostr_client() - } - - pub fn nostr_client_owned(&self) -> RadrootsNostrClient { - self.operations.nostr_client_owned() - } - - pub fn sign_protocol_event_builder( - &self, - builder: RadrootsNostrGenericEventBuilder, - operation: &str, - ) -> Result<RadrootsNostrEvent, MycError> { - self.operations - .sign_protocol_event_builder(builder, operation) - } - - pub fn sign_unsigned_event( - &self, - unsigned_event: nostr::UnsignedEvent, - operation: &str, - ) -> Result<nostr::Event, MycError> { - self.operations - .sign_unsigned_event(unsigned_event, operation) - } - - pub fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: impl Into<String>, - ) -> Result<String, MycError> { - self.operations.nip04_encrypt(public_key, plaintext.into()) - } - - pub fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: impl AsRef<str>, - ) -> Result<String, MycError> { - self.operations - .nip04_decrypt(public_key, ciphertext.as_ref()) - } - - pub fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: impl Into<String>, - ) -> Result<String, MycError> { - self.operations.nip44_encrypt(public_key, plaintext.into()) - } - - pub fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: impl AsRef<str>, - ) -> Result<String, MycError> { - self.operations - .nip44_decrypt(public_key, ciphertext.as_ref()) - } -} - -fn load_identity_from_nip49_file( - path: &std::path::Path, - password: &str, -) -> Result<RadrootsIdentity, MycError> { - let encoded = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo { - path: path.to_path_buf(), - source, - })?; - let payload = encoded.trim(); - if payload.is_empty() { - return Err(MycError::InvalidOperation(format!( - "NIP-49 payload at {} was empty", - path.display() - ))); - } - RadrootsIdentity::from_encrypted_secret_key_str(payload, password).map_err(MycError::from) -} - -fn validate_external_command_public_identity( - role: &str, - command_path: &Path, - identity: RadrootsIdentityPublic, -) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> { - let public_key = - RadrootsNostrPublicKey::parse(identity.public_key_hex.as_str()).map_err(|error| { - MycError::CustodyExternalCommandInvalidIdentity { - role: role.to_owned(), - path: command_path.to_path_buf(), - message: format!( - "invalid public_key_hex `{}`: {error}", - identity.public_key_hex - ), - } - })?; - let expected_id = RadrootsIdentityId::from_public_key(public_key)?; - if identity.id != expected_id { - return Err(MycError::CustodyExternalCommandInvalidIdentity { - role: role.to_owned(), - path: command_path.to_path_buf(), - message: format!( - "identity id `{}` does not match public_key_hex `{}`", - identity.id, identity.public_key_hex - ), - }); - } - Ok((identity, public_key)) -} - -impl MycIdentityStatusOutput { - pub fn with_inherited_from(mut self, inherited_from: impl Into<String>) -> Self { - self.inherited_from = Some(inherited_from.into()); - self - } -} - -#[cfg(test)] -mod tests { - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::process::Command; - use std::sync::Mutex; - use std::time::Instant; - - use crate::accounts::RadrootsSecretVault; - use crate::accounts::{ - RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore, - RadrootsNostrSecretVaultMemory, - }; - use crate::host_identity::RadrootsIdentity; - - use super::*; - - fn write_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); - } - - fn fixture_source(path: &Path) -> MycIdentitySourceSpec { - MycIdentitySourceSpec { - backend: MycIdentityBackend::EncryptedFile, - path: Some(path.to_path_buf()), - keyring_account_id: None, - keyring_service_name: None, - profile_path: None, - } - } - - #[cfg(unix)] - fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) - } - - #[cfg(unix)] - fn write_timeout_helper(path: &Path, pid_path: &Path) { - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$$\" > {}\nwhile :; do\n :\ndone\n", - shell_single_quote(&pid_path.display().to_string()) - ); - fs::write(path, script).expect("write helper"); - let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("helper permissions"); - } - - #[cfg(unix)] - fn write_output_helper(path: &Path) { - let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do\n printf '0123456789abcdef\\n'\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n"; - fs::write(path, script).expect("write helper"); - let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("helper permissions"); - } - - #[cfg(unix)] - fn write_delayed_helper(path: &Path) { - let script = "#!/bin/sh\nsleep 1\nprintf '{}'\n"; - fs::write(path, script).expect("write helper"); - let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("helper permissions"); - } - - #[cfg(unix)] - fn write_excess_output_helper(path: &Path) { - let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 17000 ]; do\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n"; - fs::write(path, script).expect("write helper"); - let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("helper permissions"); - } - - #[cfg(unix)] - fn process_exists(pid: u32) -> bool { - Command::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .expect("kill probe") - .success() - } - - #[derive(Debug)] - struct FakeExternalCommandExecutor { - identity: RadrootsIdentity, - requests: Mutex<Vec<MycExternalCommandRequestWire>>, - } - - impl FakeExternalCommandExecutor { - fn new(secret_key: &str) -> Arc<Self> { - Arc::new(Self { - identity: RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"), - requests: Mutex::new(Vec::new()), - }) - } - } - - impl MycExternalCommandExecutor for FakeExternalCommandExecutor { - fn execute( - &self, - _command_path: &Path, - request_json: &[u8], - _timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - let request: MycExternalCommandRequestWire = - serde_json::from_slice(request_json).expect("request"); - assert_eq!(request.version, 1); - self.requests - .lock() - .expect("requests lock") - .push(request.clone()); - let response = match request.operation { - MycExternalCommandOperation::Describe => MycExternalCommandResponse { - identity: Some(self.identity.to_public()), - event: None, - content: None, - error: None, - }, - MycExternalCommandOperation::SignEvent => { - let unsigned_event = request.unsigned_event.expect("unsigned event"); - let event = unsigned_event - .sign_with_keys(self.identity.keys()) - .expect("sign event"); - MycExternalCommandResponse { - identity: None, - event: Some(event), - content: None, - error: None, - } - } - MycExternalCommandOperation::Nip04Encrypt => { - let public_key = RadrootsNostrPublicKey::parse( - request.public_key_hex.as_deref().expect("public key hex"), - ) - .expect("public key"); - let ciphertext = nip04::encrypt( - self.identity.keys().secret_key(), - &public_key, - request.content.expect("plaintext"), - ) - .expect("encrypt"); - MycExternalCommandResponse { - identity: None, - event: None, - content: Some(ciphertext), - error: None, - } - } - MycExternalCommandOperation::Nip04Decrypt => { - let public_key = RadrootsNostrPublicKey::parse( - request.public_key_hex.as_deref().expect("public key hex"), - ) - .expect("public key"); - let plaintext = nip04::decrypt( - self.identity.keys().secret_key(), - &public_key, - request.content.as_deref().expect("ciphertext"), - ) - .expect("decrypt"); - MycExternalCommandResponse { - identity: None, - event: None, - content: Some(plaintext), - error: None, - } - } - MycExternalCommandOperation::Nip44Encrypt => { - let public_key = RadrootsNostrPublicKey::parse( - request.public_key_hex.as_deref().expect("public key hex"), - ) - .expect("public key"); - let ciphertext = nip44::encrypt( - self.identity.keys().secret_key(), - &public_key, - request.content.expect("plaintext"), - Version::V2, - ) - .expect("encrypt"); - MycExternalCommandResponse { - identity: None, - event: None, - content: Some(ciphertext), - error: None, - } - } - MycExternalCommandOperation::Nip44Decrypt => { - let public_key = RadrootsNostrPublicKey::parse( - request.public_key_hex.as_deref().expect("public key hex"), - ) - .expect("public key"); - let plaintext = nip44::decrypt( - self.identity.keys().secret_key(), - &public_key, - request.content.as_deref().expect("ciphertext"), - ) - .expect("decrypt"); - MycExternalCommandResponse { - identity: None, - event: None, - content: Some(plaintext), - error: None, - } - } - }; - - Ok(MycExternalCommandOutput { - success: true, - status: Some(0), - stdout: serde_json::to_vec(&response).expect("response"), - stderr: Vec::new(), - }) - } - } - - fn managed_account_provider( - role: &str, - service_name: &str, - ) -> (MycIdentityProvider, Arc<RadrootsNostrSecretVaultMemory>) { - let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); - let manager = RadrootsNostrAccountsManager::new( - Arc::new(RadrootsNostrMemoryAccountStore::new()), - vault.clone() as Arc<dyn RadrootsSecretVault>, - ) - .expect("manager"); - ( - MycIdentityProvider { - role: role.to_owned(), - source: MycIdentitySourceSpec { - backend: MycIdentityBackend::ManagedAccount, - path: Some(PathBuf::from(format!("/tmp/{role}-accounts.json"))), - keyring_account_id: None, - keyring_service_name: Some(service_name.to_owned()), - profile_path: None, - }, - backend: MycIdentityProviderBackend::ManagedAccount { - account_store_path: PathBuf::from(format!("/tmp/{role}-accounts.json")), - service_name: service_name.to_owned(), - manager, - }, - }, - vault, - ) - } - - fn external_command_provider( - role: &str, - secret_key: &str, - ) -> (MycIdentityProvider, Arc<FakeExternalCommandExecutor>) { - let executor = FakeExternalCommandExecutor::new(secret_key); - let command_path = PathBuf::from(format!("/tmp/{role}-identity-helper")); - ( - MycIdentityProvider { - role: role.to_owned(), - source: MycIdentitySourceSpec { - backend: MycIdentityBackend::ExternalCommand, - path: Some(command_path.clone()), - keyring_account_id: None, - keyring_service_name: None, - profile_path: None, - }, - backend: MycIdentityProviderBackend::ExternalCommand { - command_path, - timeout: Duration::from_secs(10), - executor: executor.clone(), - }, - }, - executor, - ) - } - - #[test] - fn encrypted_file_provider_loads_identity() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("signer.json"); - write_identity( - &path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - - let provider = MycIdentityProvider::from_source( - "signer", - fixture_source(&path), - Duration::from_secs(10), - ) - .expect("provider"); - let identity = provider.load_identity().expect("identity"); - - assert_eq!( - identity.public_key_hex(), - "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" - ); - } - - #[test] - fn vault_provider_loads_identity_and_merges_profile() { - let temp = tempfile::tempdir().expect("tempdir"); - let profile_path = temp.path().join("profile.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - crate::identity_files::store_identity_profile(&profile_path, &identity) - .expect("save profile"); - - let account_id = identity.id(); - let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); - vault - .store_secret(account_id.as_str(), identity.secret_key_hex().as_str()) - .expect("store"); - - let provider = MycIdentityProvider { - role: "signer".to_owned(), - source: MycIdentitySourceSpec { - backend: MycIdentityBackend::HostVault, - path: None, - keyring_account_id: Some(account_id.to_string()), - keyring_service_name: Some("org.radroots.test".to_owned()), - profile_path: Some(profile_path.clone()), - }, - backend: MycIdentityProviderBackend::HostVault { - account_id: account_id.clone(), - service_name: "org.radroots.test".to_owned(), - profile_path: Some(profile_path), - vault, - }, - }; - - let loaded = provider.load_identity().expect("loaded"); - assert_eq!(loaded.id(), account_id); - assert!(provider.probe_status().resolved); - } - - #[test] - fn vault_provider_reports_missing_secret() { - let account_id = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - .expect("identity") - .id(); - let provider = MycIdentityProvider { - role: "user".to_owned(), - source: MycIdentitySourceSpec { - backend: MycIdentityBackend::HostVault, - path: None, - keyring_account_id: Some(account_id.to_string()), - keyring_service_name: Some("org.radroots.test".to_owned()), - profile_path: None, - }, - backend: MycIdentityProviderBackend::HostVault { - account_id: account_id.clone(), - service_name: "org.radroots.test".to_owned(), - profile_path: None, - vault: Arc::new(RadrootsNostrSecretVaultMemory::new()), - }, - }; - - let err = provider.load_identity().expect_err("missing secret"); - assert!(matches!(err, MycError::CustodySecretNotFound { .. })); - assert!(!provider.probe_status().resolved); - } - - #[test] - fn managed_account_provider_loads_selected_identity() { - let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer"); - let generated = provider - .generate_managed_account(Some("primary".to_owned()), true) - .expect("generate"); - - let identity = provider.load_identity().expect("identity"); - let identity_id = identity.id().to_string(); - assert_eq!( - generated.state.selected_account_id.as_deref(), - Some(identity_id.as_str()) - ); - let status = provider.probe_status(); - assert!(status.resolved); - assert_eq!( - status.selected_account_state, - Some(MycManagedAccountSelectionState::Ready) - ); - } - - #[test] - fn managed_account_provider_supports_nip49_export_and_import() { - let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer"); - let generated = provider - .generate_managed_account(Some("primary".to_owned()), true) - .expect("generate"); - let selected_account_id = generated - .state - .selected_account_id - .clone() - .expect("selected account id"); - let temp = tempfile::tempdir().expect("tempdir"); - let export_path = temp.path().join("managed-account.ncryptsec"); - - let export = provider - .export_nip49(&export_path, "test password") - .expect("export nip49"); - assert_eq!(export.format, "nip49"); - assert_eq!(export.identity_id, selected_account_id); - - provider - .remove_managed_account(selected_account_id.as_str()) - .expect("remove account"); - let removed_status = provider.probe_status(); - assert!(!removed_status.resolved); - - let imported = provider - .import_nip49(&export_path, "test password", Some("restored".to_owned())) - .expect("import nip49"); - assert_eq!(imported.account_id, export.identity_id); - assert!(imported.status.resolved); - assert_eq!( - imported.status.selected_account_label.as_deref(), - Some("restored") - ); - } - - #[test] - fn managed_account_provider_reports_not_configured() { - let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user"); - - let err = provider - .load_identity() - .expect_err("missing selected account"); - assert!(matches!( - err, - MycError::CustodyManagedAccountNotConfigured { .. } - )); - let status = provider.probe_status(); - assert!(!status.resolved); - assert_eq!( - status.selected_account_state, - Some(MycManagedAccountSelectionState::NotConfigured) - ); - } - - #[test] - fn managed_account_provider_reports_public_only_selected_account() { - let (provider, vault) = managed_account_provider("user", "org.radroots.test.user"); - let identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - .expect("identity"); - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("managed-account.json"); - identity.save_json(&path).expect("save"); - let record = provider - .import_managed_account_file(&path, Some("managed".to_owned()), true) - .expect("import"); - let selected_account_id = record - .state - .selected_account_id - .clone() - .expect("selected account"); - vault - .remove_secret( - RadrootsIdentityId::parse(selected_account_id.as_str()) - .expect("account id") - .as_str(), - ) - .expect("remove secret"); - - let err = provider.load_identity().expect_err("public only"); - assert!(matches!( - err, - MycError::CustodyManagedAccountPublicOnly { .. } - )); - let status = provider.probe_status(); - assert!(!status.resolved); - assert_eq!( - status.selected_account_state, - Some(MycManagedAccountSelectionState::PublicOnly) - ); - } - - #[test] - fn external_command_provider_loads_identity_and_executes_signing_operations() { - let (provider, executor) = external_command_provider( - "signer", - "1111111111111111111111111111111111111111111111111111111111111111", - ); - let active = provider.load_active_identity().expect("active identity"); - let expected_identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - assert_eq!(active.id(), expected_identity.id()); - assert_eq!(active.public_key_hex(), expected_identity.public_key_hex()); - - let peer_identity = RadrootsIdentity::from_secret_key_str( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("peer identity"); - let signed_event = active - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - nostr::Kind::Custom(24_133), - "hello from external command", - ), - "test event", - ) - .expect("signed event"); - assert_eq!(signed_event.pubkey, expected_identity.public_key()); - - let nip04_ciphertext = active - .nip04_encrypt(&peer_identity.public_key(), "hello nip04") - .expect("nip04 encrypt"); - assert_eq!( - nip04::decrypt( - peer_identity.keys().secret_key(), - &expected_identity.public_key(), - &nip04_ciphertext, - ) - .expect("decrypt with peer"), - "hello nip04" - ); - - let nip44_ciphertext = active - .nip44_encrypt(&peer_identity.public_key(), "hello nip44") - .expect("nip44 encrypt"); - assert_eq!( - nip44::decrypt( - peer_identity.keys().secret_key(), - &expected_identity.public_key(), - &nip44_ciphertext, - ) - .expect("decrypt with peer"), - "hello nip44" - ); - - let status = provider.probe_status(); - assert!(status.resolved); - assert_eq!( - status.path, - Some(PathBuf::from("/tmp/signer-identity-helper")) - ); - assert_eq!(status.identity_id, Some(expected_identity.id().to_string())); - - let operations = executor - .requests - .lock() - .expect("requests lock") - .iter() - .map(|request| request.operation) - .collect::<Vec<_>>(); - assert!(operations.contains(&MycExternalCommandOperation::Describe)); - assert!(operations.contains(&MycExternalCommandOperation::SignEvent)); - assert!(operations.contains(&MycExternalCommandOperation::Nip04Encrypt)); - assert!(operations.contains(&MycExternalCommandOperation::Nip44Encrypt)); - - let requests = executor.requests.lock().expect("requests lock"); - let signing_request = requests - .iter() - .find(|request| request.operation == MycExternalCommandOperation::SignEvent) - .and_then(|request| request.unsigned_event.as_ref()) - .expect("serialized unsigned event"); - assert!(signing_request.id.is_some()); - signing_request.verify_id().expect("canonical event id"); - assert_eq!(signing_request.kind, nostr::Kind::Custom(24_133)); - } - - #[derive(Debug)] - struct StaticSigningResponseExecutor { - event: RadrootsNostrEvent, - } - - impl MycExternalCommandExecutor for StaticSigningResponseExecutor { - fn execute( - &self, - _command_path: &Path, - request_json: &[u8], - _timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - let request: MycExternalCommandRequestWire = - serde_json::from_slice(request_json).expect("request"); - assert_eq!(request.version, 1); - assert_eq!(request.operation, MycExternalCommandOperation::SignEvent); - assert!(request.unsigned_event.is_some()); - Ok(MycExternalCommandOutput { - success: true, - status: Some(0), - stdout: serde_json::to_vec(&MycExternalCommandResponse { - identity: None, - event: Some(self.event.clone()), - content: None, - error: None, - }) - .expect("response"), - stderr: Vec::new(), - }) - } - } - - fn external_operations_with_event( - identity: &RadrootsIdentity, - event: RadrootsNostrEvent, - ) -> MycExternalCommandIdentityOperations { - MycExternalCommandIdentityOperations::new( - "user".to_owned(), - PathBuf::from("/tmp/user-helper"), - Duration::from_secs(10), - identity.public_key(), - Arc::new(StaticSigningResponseExecutor { event }), - ) - } - - fn caller_unsigned_event(identity: &RadrootsIdentity, content: &str) -> nostr::UnsignedEvent { - nostr::UnsignedEvent::new( - identity.public_key(), - nostr::Timestamp::from_secs(1_234), - nostr::Kind::Custom(30_001), - [], - content, - ) - } - - #[test] - fn external_command_rejects_invalid_caller_unsigned_events_before_execution() { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let valid_event = caller_unsigned_event(&identity, "valid") - .sign_with_keys(identity.keys()) - .expect("event"); - let operations = external_operations_with_event(&identity, valid_event); - - let other_identity = RadrootsIdentity::from_secret_key_str( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("other identity"); - let wrong_author = caller_unsigned_event(&other_identity, "wrong author"); - assert!(matches!( - operations.sign_unsigned_event(wrong_author, "caller event"), - Err(MycError::CustodyExternalCommandUnsignedEventInvalid { .. }) - )); - - let mut invalid_id = caller_unsigned_event(&identity, "invalid id"); - invalid_id.id = Some(nostr::EventId::all_zeros()); - assert!(matches!( - operations.sign_unsigned_event(invalid_id, "caller event"), - Err(MycError::CustodyExternalCommandUnsignedEventInvalid { .. }) - )); - } - - #[test] - fn external_command_accepts_only_the_exact_valid_caller_event() { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let unsigned_event = caller_unsigned_event(&identity, "expected"); - let valid_event = unsigned_event - .clone() - .sign_with_keys(identity.keys()) - .expect("event"); - let accepted = external_operations_with_event(&identity, valid_event.clone()) - .sign_unsigned_event(unsigned_event.clone(), "caller event") - .expect("accepted event"); - assert_eq!(accepted, valid_event); - - let wrong_event = caller_unsigned_event(&identity, "different") - .sign_with_keys(identity.keys()) - .expect("different event"); - assert!(matches!( - external_operations_with_event(&identity, wrong_event) - .sign_unsigned_event(unsigned_event.clone(), "caller event"), - Err(MycError::CustodyExternalCommandSignedEventMismatch { .. }) - )); - - let other_identity = RadrootsIdentity::from_secret_key_str( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - .expect("other identity"); - let wrong_author = caller_unsigned_event(&other_identity, "expected") - .sign_with_keys(other_identity.keys()) - .expect("wrong author event"); - assert!(matches!( - external_operations_with_event(&identity, wrong_author) - .sign_unsigned_event(unsigned_event.clone(), "caller event"), - Err(MycError::CustodyExternalCommandSignedEventMismatch { .. }) - )); - - let mut tampered_content = valid_event.clone(); - tampered_content.content.push_str(" tampered"); - assert!(matches!( - external_operations_with_event(&identity, tampered_content) - .sign_unsigned_event(unsigned_event.clone(), "caller event"), - Err(MycError::CustodyExternalCommandSignedEventInvalid { .. }) - )); - - let mut invalid_signature = valid_event; - invalid_signature.sig = caller_unsigned_event(&identity, "signature source") - .sign_with_keys(identity.keys()) - .expect("signature source") - .sig; - assert!(matches!( - external_operations_with_event(&identity, invalid_signature) - .sign_unsigned_event(unsigned_event, "caller event"), - Err(MycError::CustodyExternalCommandSignedEventInvalid { .. }) - )); - } - - #[test] - fn external_command_protocol_signing_uses_checked_library_completion() { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let wrong_event = RadrootsNostrGenericEventBuilder::new( - nostr::Kind::Custom(24_133), - "different response", - ) - .sign_with_keys(identity.keys()) - .expect("different event"); - let error = external_operations_with_event(&identity, wrong_event) - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new( - nostr::Kind::Custom(24_133), - "expected response", - ), - "protocol response", - ) - .expect_err("different event"); - - assert!(matches!( - error, - MycError::Nostr( - crate::nostr_contract::RadrootsNostrError::ExternalSigningEventIdMismatch { .. } - ) - )); - } - - #[tokio::test] - async fn external_command_provider_uses_signerless_relay_client() { - let (provider, _executor) = external_command_provider( - "signer", - "1111111111111111111111111111111111111111111111111111111111111111", - ); - let active = provider.load_active_identity().expect("active identity"); - - assert!(!active.nostr_client().has_signer().await); - assert!(!active.nostr_client_owned().has_signer().await); - } - - #[derive(Debug, Default)] - struct TimeoutExternalCommandExecutor; - - impl MycExternalCommandExecutor for TimeoutExternalCommandExecutor { - fn execute( - &self, - _command_path: &Path, - _request_json: &[u8], - _timeout: Duration, - ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - Err(MycExternalCommandExecuteError::TimedOut) - } - } - - #[test] - fn external_command_provider_maps_describe_timeout() { - let provider = MycIdentityProvider { - role: "signer".to_owned(), - source: MycIdentitySourceSpec { - backend: MycIdentityBackend::ExternalCommand, - path: Some(PathBuf::from("/tmp/signer-helper")), - keyring_account_id: None, - keyring_service_name: None, - profile_path: None, - }, - backend: MycIdentityProviderBackend::ExternalCommand { - command_path: PathBuf::from("/tmp/signer-helper"), - timeout: Duration::from_secs(7), - executor: Arc::new(TimeoutExternalCommandExecutor), - }, - }; - - let err = provider.load_active_identity().err().expect("timeout"); - assert!(matches!( - err, - MycError::CustodyExternalCommandTimedOut { - ref role, - ref path, - timeout_secs: 7, - } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper") - )); - } - - #[test] - fn external_command_provider_maps_operation_timeout() { - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let public_identity = identity.to_public(); - let public_key = identity.public_key(); - let active = MycActiveIdentity::from_operations( - public_identity.clone(), - public_key, - Arc::new(MycExternalCommandIdentityOperations::new( - "signer".to_owned(), - PathBuf::from("/tmp/signer-helper"), - Duration::from_secs(11), - public_key, - Arc::new(TimeoutExternalCommandExecutor), - )), - ); - - let err = active - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new(nostr::Kind::Custom(24_133), "timeout"), - "timeout event", - ) - .expect_err("timeout"); - assert!(matches!( - err, - MycError::CustodyExternalCommandTimedOut { - ref role, - ref path, - timeout_secs: 11, - } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper") - )); - } - - #[tokio::test] - async fn external_command_output_reader_enforces_its_byte_limit() { - let (mut writer, reader) = tokio::io::duplex(128); - let writer_task = tokio::spawn(async move { - let _ = writer.write_all(&[b'x'; 65]).await; - }); - - let error = read_limited_external_command_output(reader, "stdout", 64) - .await - .expect_err("output limit"); - writer_task.await.expect("writer task"); - - assert!(matches!( - error, - MycExternalCommandExecuteError::OutputLimitExceeded { - stream: "stdout", - limit_bytes: 64, - } - )); - } - - #[cfg(unix)] - #[test] - fn process_executor_drains_stdout_and_stderr_while_the_helper_runs() { - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("output-helper.sh"); - write_output_helper(&helper_path); - - let output = MycProcessCommandExecutor - .execute(&helper_path, b"{}", Duration::from_secs(5)) - .expect("helper output"); - - assert!(output.success); - assert_eq!(output.stdout.len(), 85_000); - assert_eq!(output.stderr.len(), 20_000); - } - - #[cfg(unix)] - #[test] - fn process_executor_reports_output_limits_without_pipe_deadlock() { - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("excess-output-helper.sh"); - write_excess_output_helper(&helper_path); - - let error = MycProcessCommandExecutor - .execute(&helper_path, b"{}", Duration::from_secs(5)) - .expect_err("stderr output limit"); - - assert!(matches!( - error, - MycExternalCommandExecuteError::OutputLimitExceeded { - stream: "stderr", - limit_bytes: MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES, - } - )); - } - - #[cfg(unix)] - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn process_executor_releases_the_tokio_worker_while_waiting() { - use std::sync::atomic::{AtomicBool, Ordering}; - - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("delayed-helper.sh"); - write_delayed_helper(&helper_path); - let made_progress = Arc::new(AtomicBool::new(false)); - let task_progress = made_progress.clone(); - let progress_task = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(50)).await; - task_progress.store(true, Ordering::SeqCst); - }); - - let output = MycProcessCommandExecutor - .execute(&helper_path, b"{}", Duration::from_secs(5)) - .expect("helper output"); - progress_task.await.expect("progress task"); - - assert!(output.success); - assert!( - made_progress.load(Ordering::SeqCst), - "the Tokio worker must continue scheduling while custody waits" - ); - } - - #[cfg(unix)] - #[test] - fn process_executor_times_out_and_kills_real_helper() { - let timeout = Duration::from_secs(2); - let temp = tempfile::tempdir().expect("tempdir"); - let helper_path = temp.path().join("timeout-helper.sh"); - let pid_path = temp.path().join("timeout-helper.pid"); - write_timeout_helper(&helper_path, &pid_path); - - let helper_path_for_thread = helper_path.clone(); - let handle = std::thread::spawn(move || { - let executor = MycProcessCommandExecutor; - let started_at = Instant::now(); - let err = executor - .execute( - &helper_path_for_thread, - b"{\"operation\":\"describe\"}", - timeout, - ) - .expect_err("timeout"); - (started_at.elapsed(), err) - }); - - // Give the real helper a little slack to create its pid file under a busy full-test run - // before we conclude the timeout path never launched it. - let pid_deadline = Instant::now() + timeout + Duration::from_secs(20); - let pid = loop { - match fs::read_to_string(&pid_path) { - Ok(value) => match value.trim().parse::<u32>() { - Ok(pid) => break pid, - Err(_) if Instant::now() < pid_deadline => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("helper pid: {error}"), - }, - Err(error) - if error.kind() == std::io::ErrorKind::NotFound - && Instant::now() < pid_deadline => - { - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("helper pid: {error}"), - } - }; - - let (elapsed, err) = handle.join().expect("executor thread"); - - assert!(matches!(err, MycExternalCommandExecuteError::TimedOut)); - assert!( - elapsed < timeout + Duration::from_secs(2), - "timeout path should stay bounded" - ); - assert!(!process_exists(pid), "helper process should be terminated"); - } -} diff --git a/src/error.rs b/src/error.rs @@ -1,438 +0,0 @@ -use std::net::SocketAddr; -use std::path::PathBuf; - -use crate::accounts::RadrootsNostrAccountsError; -use crate::host_identity::IdentityError; -use crate::nostr_contract::RadrootsNostrError; -use crate::signer::prelude::RadrootsNostrSignerError; -use crate::sql::error::SqlError; -use radroots_nostr_connect::Error as NostrConnectError; -use thiserror::Error; - -use crate::config::MycTransportDeliveryPolicy; - -#[derive(Debug, Error)] -pub enum MycError { - #[error("invalid config: {0}")] - InvalidConfig(String), - #[error("invalid operation: {0}")] - InvalidOperation(String), - #[error("invalid log filter `{filter}`: {source}")] - InvalidLogFilter { - filter: String, - #[source] - source: tracing_subscriber::filter::ParseError, - }, - #[error("logging already initialized")] - LoggingAlreadyInitialized, - #[error("failed to create directory {path}: {source}")] - CreateDir { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("persistence io error at {path}: {source}")] - PersistenceIo { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to serialize persistence data at {path}: {source}")] - PersistenceSerialize { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("failed to parse persistence backup manifest at {path}: {source}")] - PersistenceManifestParse { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("failed to bind observability server at {bind_addr}: {source}")] - ObservabilityBind { - bind_addr: SocketAddr, - #[source] - source: std::io::Error, - }, - #[error("observability server failed at {bind_addr}: {source}")] - ObservabilityServe { - bind_addr: SocketAddr, - #[source] - source: std::io::Error, - }, - #[error("audit io error at {path}: {source}")] - AuditIo { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("audit parse error at {path}:{line_number}: {source}")] - AuditParse { - path: PathBuf, - line_number: usize, - #[source] - source: serde_json::Error, - }, - #[error("failed to serialize audit record at {path}: {source}")] - AuditSerialize { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("audit sqlite error at {path}: {source}")] - AuditSql { - path: PathBuf, - #[source] - source: SqlError, - }, - #[error("audit sqlite decode error at {path}: {source}")] - AuditSqlDecode { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("delivery outbox sqlite error at {path}: {source}")] - DeliveryOutboxSql { - path: PathBuf, - #[source] - source: SqlError, - }, - #[error("delivery outbox sqlite decode error at {path}: {source}")] - DeliveryOutboxSqlDecode { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("failed to serialize delivery outbox record at {path}: {source}")] - DeliveryOutboxSerialize { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("invalid delivery outbox job id `{0}`")] - InvalidDeliveryOutboxJobId(String), - #[error("delivery outbox job not found: {0}")] - DeliveryOutboxJobNotFound(String), - #[error("discovery io error at {path}: {source}")] - DiscoveryIo { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("discovery parse error at {path}: {source}")] - DiscoveryParse { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("invalid discovery bundle: {0}")] - InvalidDiscoveryBundle(String), - #[error("invalid discovery event: {0}")] - InvalidDiscoveryEvent(String), - #[error( - "failed to fetch discovery state from all configured relays ({relay_count}): {details}" - )] - DiscoveryFetchUnavailable { relay_count: usize, details: String }, - #[error("discovery refresh attempt {attempt_id} failed: {source}")] - DiscoveryRefreshFailed { - attempt_id: String, - #[source] - source: Box<MycError>, - }, - #[error("custody manager error for {role} identity: {source}")] - CustodyManager { - role: String, - #[source] - source: RadrootsNostrAccountsError, - }, - #[error("custody vault error for {role} identity: {source}")] - CustodyVault { - role: String, - #[source] - source: RadrootsNostrAccountsError, - }, - #[error( - "no secret found in custody vault service `{service_name}` for {role} identity `{account_id}`" - )] - CustodySecretNotFound { - role: String, - service_name: String, - account_id: String, - }, - #[error( - "custody vault service `{service_name}` resolved {role} identity `{resolved_identity_id}` but expected `{account_id}`" - )] - CustodySecretIdentityMismatch { - role: String, - service_name: String, - account_id: String, - resolved_identity_id: String, - }, - #[error( - "public identity file {path} resolved {role} identity `{profile_identity_id}` but expected `{account_id}`" - )] - CustodyProfileIdentityMismatch { - role: String, - path: PathBuf, - account_id: String, - profile_identity_id: String, - }, - #[error("no selected managed account configured for {role} identity store {path}")] - CustodyManagedAccountNotConfigured { role: String, path: PathBuf }, - #[error( - "selected managed account `{account_id}` in {path} for {role} identity has no secret in keyring service `{service_name}`" - )] - CustodyManagedAccountPublicOnly { - role: String, - path: PathBuf, - service_name: String, - account_id: String, - }, - #[error("external custody command io error for {role} identity at {path}: {source}")] - CustodyExternalCommandIo { - role: String, - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error( - "external custody command for {role} identity at {path} timed out after {timeout_secs}s" - )] - CustodyExternalCommandTimedOut { - role: String, - path: PathBuf, - timeout_secs: u64, - }, - #[error( - "external custody command for {role} identity at {path} exceeded the {stream} output limit of {limit_bytes} bytes" - )] - CustodyExternalCommandOutputLimit { - role: String, - path: PathBuf, - stream: &'static str, - limit_bytes: usize, - }, - #[error( - "external custody command for {role} identity at {path} failed with status {status}: {diagnostic}" - )] - CustodyExternalCommandFailed { - role: String, - path: PathBuf, - status: String, - diagnostic: &'static str, - }, - #[error( - "external custody command response parse error for {role} identity at {path}: {source}" - )] - CustodyExternalCommandParse { - role: String, - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error( - "external custody command returned invalid public identity for {role} at {path}: {message}" - )] - CustodyExternalCommandInvalidIdentity { - role: String, - path: PathBuf, - message: String, - }, - #[error( - "external custody command rejected invalid unsigned event for {role} identity at {path} while signing {operation}" - )] - CustodyExternalCommandUnsignedEventInvalid { - role: String, - path: PathBuf, - operation: String, - }, - #[error( - "external custody command returned a different signed event for {role} identity at {path} while signing {operation}" - )] - CustodyExternalCommandSignedEventMismatch { - role: String, - path: PathBuf, - operation: String, - }, - #[error( - "external custody command returned an invalid signed event for {role} identity at {path} while signing {operation}" - )] - CustodyExternalCommandSignedEventInvalid { - role: String, - path: PathBuf, - operation: String, - }, - #[error(transparent)] - Identity(#[from] IdentityError), - #[error(transparent)] - Nostr(#[from] RadrootsNostrError), - #[error(transparent)] - NostrConnect(#[from] NostrConnectError), - #[error(transparent)] - SignerState(#[from] RadrootsNostrSignerError), - #[error(transparent)] - Json(#[from] serde_json::Error), - #[error("NIP-46 decrypt failed: {0}")] - Nip46Decrypt(String), - #[error("NIP-46 encrypt failed: {0}")] - Nip46Encrypt(String), - #[error("NIP-46 listener notifications closed")] - Nip46ListenerClosed, - #[error( - "Nostr publish failed for {operation} after {attempt_count} attempt(s) with delivery policy {} requiring {required_acknowledged_relay_count} acknowledgements: {details}", - delivery_policy.as_str() - )] - PublishRejected { - operation: String, - relay_count: usize, - acknowledged_relay_count: usize, - required_acknowledged_relay_count: usize, - delivery_policy: MycTransportDeliveryPolicy, - attempt_count: usize, - details: String, - rejected_relays: Vec<String>, - }, - #[error( - "configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}" - )] - SignerIdentityMismatch { - identity_path: PathBuf, - state_path: PathBuf, - configured_identity_id: String, - persisted_identity_id: String, - }, - #[error( - "configured signer identity `{configured_identity_id}` does not match imported signer identity `{imported_identity_id}` from {state_path}" - )] - SignerIdentityImportMismatch { - state_path: PathBuf, - configured_identity_id: String, - imported_identity_id: String, - }, -} - -impl From<nostr_sdk::client::Error> for MycError { - fn from(_: nostr_sdk::client::Error) -> Self { - Self::InvalidOperation("Nostr client operation failed".to_owned()) - } -} - -impl MycError { - pub fn with_discovery_refresh_attempt_id(self, attempt_id: impl Into<String>) -> Self { - match self { - Self::DiscoveryRefreshFailed { .. } => self, - source => Self::DiscoveryRefreshFailed { - attempt_id: attempt_id.into(), - source: Box::new(source), - }, - } - } - - pub fn discovery_refresh_attempt_id(&self) -> Option<&str> { - match self { - Self::DiscoveryRefreshFailed { attempt_id, .. } => Some(attempt_id.as_str()), - _ => None, - } - } - - pub fn publish_rejection_details(&self) -> Option<&str> { - match self { - Self::PublishRejected { details, .. } => Some(details.as_str()), - Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_details(), - _ => None, - } - } - - pub fn publish_rejection_counts(&self) -> Option<(usize, usize)> { - match self { - Self::PublishRejected { - relay_count, - acknowledged_relay_count, - .. - } => Some((*relay_count, *acknowledged_relay_count)), - Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_counts(), - _ => None, - } - } - - pub fn publish_rejected_relays(&self) -> Option<&[String]> { - match self { - Self::PublishRejected { - rejected_relays, .. - } => Some(rejected_relays.as_slice()), - Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejected_relays(), - _ => None, - } - } - - pub fn publish_delivery_policy(&self) -> Option<MycTransportDeliveryPolicy> { - match self { - Self::PublishRejected { - delivery_policy, .. - } => Some(*delivery_policy), - Self::DiscoveryRefreshFailed { source, .. } => source.publish_delivery_policy(), - _ => None, - } - } - - pub fn publish_attempt_count(&self) -> Option<usize> { - match self { - Self::PublishRejected { attempt_count, .. } => Some(*attempt_count), - Self::DiscoveryRefreshFailed { source, .. } => source.publish_attempt_count(), - _ => None, - } - } - - pub fn publish_required_acknowledged_relay_count(&self) -> Option<usize> { - match self { - Self::PublishRejected { - required_acknowledged_relay_count, - .. - } => Some(*required_acknowledged_relay_count), - Self::DiscoveryRefreshFailed { source, .. } => { - source.publish_required_acknowledged_relay_count() - } - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use crate::config::MycTransportDeliveryPolicy; - - use super::MycError; - - #[test] - fn discovery_refresh_wrapper_preserves_attempt_id_and_publish_details() { - let wrapped = MycError::PublishRejected { - operation: "discovery refresh".to_owned(), - relay_count: 2, - acknowledged_relay_count: 0, - required_acknowledged_relay_count: 1, - delivery_policy: MycTransportDeliveryPolicy::Any, - attempt_count: 2, - details: "relay-a: blocked".to_owned(), - rejected_relays: vec!["wss://relay-a.example.com".to_owned()], - } - .with_discovery_refresh_attempt_id("attempt-1"); - - assert_eq!(wrapped.discovery_refresh_attempt_id(), Some("attempt-1")); - assert_eq!( - wrapped.publish_rejection_details(), - Some("relay-a: blocked") - ); - assert_eq!(wrapped.publish_rejection_counts(), Some((2, 0))); - assert_eq!( - wrapped.publish_rejected_relays(), - Some(["wss://relay-a.example.com".to_owned()].as_slice()) - ); - assert_eq!( - wrapped.publish_delivery_policy(), - Some(MycTransportDeliveryPolicy::Any) - ); - assert_eq!(wrapped.publish_required_acknowledged_relay_count(), Some(1)); - assert_eq!(wrapped.publish_attempt_count(), Some(2)); - } -} diff --git a/src/host_identity.rs b/src/host_identity.rs @@ -1,327 +0,0 @@ -//! Myc-owned secret identity container. -//! -//! `radroots_identity` deliberately exposes only public, transport-neutral -//! values. This host-private type keeps service key custody and the legacy -//! Nostr-facing profile payload inside Myc. - -use std::fs; -use std::path::{Path, PathBuf}; - -use nostr::nips::nip19::ToBech32; -use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; -use nostr::{Keys, SecretKey}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum IdentityError { - #[error("identity file missing at {0}")] - NotFound(PathBuf), - #[error("identity generation is not permitted for {0}")] - GenerationNotAllowed(PathBuf), - #[error("failed to read identity file at {0}")] - Read(PathBuf, #[source] std::io::Error), - #[error("failed to create identity directory {0}")] - CreateDir(PathBuf, #[source] std::io::Error), - #[error("failed to write identity file at {0}")] - Write(PathBuf, #[source] std::io::Error), - #[error("invalid identity JSON")] - InvalidJson(#[from] serde_json::Error), - #[error("invalid secret key")] - InvalidSecretKey(#[from] nostr::key::Error), - #[error("invalid public key")] - InvalidPublicKey, - #[error("public key does not match secret key")] - PublicKeyMismatch, - #[error("invalid encrypted secret key")] - InvalidEncryptedSecretKey, - #[error("failed to encrypt secret key")] - EncryptSecretKey, - #[error("failed to decrypt encrypted secret key")] - DecryptEncryptedSecretKey, - #[error("unsupported identity file format")] - InvalidIdentityFormat, - #[error("protected identity storage error at {path}: {message}")] - ProtectedStorage { path: PathBuf, message: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct RadrootsIdentityId(String); - -impl RadrootsIdentityId { - pub fn from_public_key(public_key: nostr::PublicKey) -> Result<Self, IdentityError> { - let key = radroots_nostr::key::public_key_from_nostr(public_key) - .map_err(|_| IdentityError::InvalidPublicKey)?; - Ok(Self( - radroots_identity::IdentityId::from_public_key(key).to_hex(), - )) - } - - pub fn parse(value: &str) -> Result<Self, IdentityError> { - radroots_identity::IdentityId::from_hex(value) - .map(|identity_id| Self(identity_id.to_hex())) - .map_err(|_| IdentityError::InvalidPublicKey) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } - - pub fn to_final(&self) -> radroots_identity::IdentityId { - radroots_identity::IdentityId::from_hex(self.0.as_str()) - .expect("host identity ids are constructed from validated keys") - } -} - -impl std::fmt::Display for RadrootsIdentityId { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(formatter) - } -} - -impl From<radroots_identity::PublicKey> for RadrootsIdentityId { - fn from(public_key: radroots_identity::PublicKey) -> Self { - Self(radroots_identity::IdentityId::from_public_key(public_key).to_hex()) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityProfile { - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -impl RadrootsIdentityProfile { - pub fn is_empty(&self) -> bool { - self.identifier.is_none() && self.metadata.is_none() && self.application_handler.is_none() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityPublic { - pub id: RadrootsIdentityId, - pub public_key_hex: String, - pub public_key_npub: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option<RadrootsIdentityProfile>, -} - -impl PartialEq for RadrootsIdentityPublic { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - && self.public_key_hex == other.public_key_hex - && self.profile == other.profile - } -} - -impl Eq for RadrootsIdentityPublic {} - -impl RadrootsIdentityPublic { - pub fn new(public_key: nostr::PublicKey) -> Result<Self, IdentityError> { - Ok(Self { - id: RadrootsIdentityId::from_public_key(public_key)?, - public_key_hex: public_key.to_hex(), - public_key_npub: public_key - .to_bech32() - .expect("validated Nostr public keys encode as npub"), - profile: None, - }) - } - - pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self { - self.profile = (!profile.is_empty()).then_some(profile); - self - } - - pub fn from_final_public_key( - public_key: radroots_identity::PublicKey, - ) -> Result<Self, IdentityError> { - let public_key = radroots_nostr::key::public_key_to_nostr(public_key) - .map_err(|_| IdentityError::InvalidPublicKey)?; - Self::new(public_key) - } - - pub fn id(&self) -> &RadrootsIdentityId { - &self.id - } - - pub fn public_key(&self) -> radroots_identity::PublicKey { - radroots_identity::PublicKey::from_hex(self.public_key_hex.as_str()) - .expect("host public identities are constructed from validated keys") - } - - pub fn to_final(&self) -> radroots_identity::PublicIdentity { - radroots_identity::PublicIdentity::new(self.public_key()) - } - - pub fn account_id(&self) -> radroots_identity::AccountId { - self.id.to_final().into() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityFile { - pub secret_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -#[derive(Debug, Clone)] -pub struct RadrootsIdentity { - keys: Keys, - profile: Option<RadrootsIdentityProfile>, -} - -impl RadrootsIdentity { - pub fn new(keys: Keys) -> Self { - Self { - keys, - profile: None, - } - } - - pub fn generate() -> Self { - Self::new(Keys::generate()) - } - - pub fn from_secret_key_str(value: &str) -> Result<Self, IdentityError> { - let secret = SecretKey::parse(value)?; - Ok(Self::new(Keys::new(secret))) - } - - pub fn from_encrypted_secret_key_str( - payload: &str, - password: &str, - ) -> Result<Self, IdentityError> { - use nostr::nips::nip19::FromBech32; - let encrypted = EncryptedSecretKey::from_bech32(payload) - .map_err(|_| IdentityError::InvalidEncryptedSecretKey)?; - let secret = encrypted - .decrypt(password) - .map_err(|_| IdentityError::DecryptEncryptedSecretKey)?; - Ok(Self::new(Keys::new(secret))) - } - - pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> { - let encrypted = - EncryptedSecretKey::new(self.keys.secret_key(), password, 16, KeySecurity::Unknown) - .map_err(|_| IdentityError::EncryptSecretKey)?; - encrypted - .to_bech32() - .map_err(|_| IdentityError::EncryptSecretKey) - } - - pub fn keys(&self) -> &Keys { - &self.keys - } - - pub fn public_key(&self) -> nostr::PublicKey { - self.keys.public_key() - } - - pub fn final_public_key(&self) -> radroots_identity::PublicKey { - radroots_nostr::key::public_key_from_nostr(self.public_key()) - .expect("identity keys always contain a valid public key") - } - - pub fn id(&self) -> RadrootsIdentityId { - RadrootsIdentityId::from_public_key(self.public_key()) - .expect("identity keys always contain a valid public key") - } - - pub fn public_key_hex(&self) -> String { - self.public_key().to_hex() - } - - pub fn secret_key_hex(&self) -> String { - self.keys.secret_key().to_secret_hex() - } - - pub fn profile(&self) -> Option<&RadrootsIdentityProfile> { - self.profile.as_ref() - } - - pub fn set_profile(&mut self, profile: RadrootsIdentityProfile) { - self.profile = (!profile.is_empty()).then_some(profile); - } - - pub fn to_public(&self) -> RadrootsIdentityPublic { - let mut public = RadrootsIdentityPublic::new(self.public_key()) - .expect("identity keys always contain a valid public key"); - public.profile = self.profile.clone(); - public - } - - pub fn to_file(&self) -> RadrootsIdentityFile { - let profile = self.profile.clone().unwrap_or_default(); - RadrootsIdentityFile { - secret_key: self.secret_key_hex(), - public_key: Some(self.public_key_hex()), - identifier: profile.identifier, - metadata: profile.metadata, - application_handler: profile.application_handler, - } - } - - pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> { - let path = path.as_ref(); - if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) { - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - } - fs::write(path, serde_json::to_vec_pretty(&self.to_file())?) - .map_err(|source| IdentityError::Write(path.to_path_buf(), source)) - } - - pub fn load_from_path_auto(path: impl AsRef<Path>) -> Result<Self, IdentityError> { - let path = path.as_ref(); - let encoded = fs::read(path).map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - IdentityError::NotFound(path.to_path_buf()) - } else { - IdentityError::Read(path.to_path_buf(), source) - } - })?; - let file: RadrootsIdentityFile = serde_json::from_slice(encoded.as_slice())?; - Self::try_from(file) - } -} - -impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity { - type Error = IdentityError; - - fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> { - let mut identity = Self::from_secret_key_str(file.secret_key.as_str())?; - if file - .public_key - .as_deref() - .is_some_and(|public| public != identity.public_key_hex()) - { - return Err(IdentityError::PublicKeyMismatch); - } - identity.set_profile(RadrootsIdentityProfile { - identifier: file.identifier, - metadata: file.metadata, - application_handler: file.application_handler, - }); - Ok(identity) - } -} diff --git a/src/identity_files.rs b/src/identity_files.rs @@ -1,563 +0,0 @@ -use std::ffi::OsString; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use chacha20poly1305::aead::{Aead, KeyInit, Payload}; -use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; -use radroots_secrets::context::{ - EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, -}; -use radroots_secrets::envelope::{ - ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION, LegacyV1ResealAuthority, Nonce, SealMaterial, - SealRequest, -}; -use radroots_secrets::error::Operation; -use radroots_secrets::id::{BackendKind, KeyVersion}; -use radroots_secrets::wrapping::{ - BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, -}; -use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef}; -use zeroize::Zeroize; - -use crate::host_identity::{ - IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic, -}; - -const MYC_IDENTITY_KEY_SLOT: &str = "myc_identity"; -const WRAPPING_KEY_BYTES: usize = 32; -const WRAPPING_NONCE_BYTES: usize = 24; -const LEGACY_WRAPPED_KEY_VERSION: u8 = 1; -const WRAPPED_KEY_VERSION: u8 = 2; -const WRAPPING_AAD_DOMAIN: &[u8] = b"myc.wrapped_data_key.v2"; - -struct MycFileKeyWrapping { - key_path: PathBuf, -} - -impl MycFileKeyWrapping { - fn new(identity_path: &Path) -> Self { - Self { - key_path: encrypted_identity_wrapping_key_path(identity_path), - } - } - - fn load_or_create_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { - if let Ok(raw) = fs::read(&self.key_path) { - return key_from_bytes(raw.as_slice()); - } - if let Some(parent) = self - .key_path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|_| secret_backend_failure(Operation::Provision))?; - } - let key: [u8; WRAPPING_KEY_BYTES] = rand::random(); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&self.key_path) - { - Ok(mut file) => { - file.write_all(&key) - .map_err(|_| secret_backend_failure(Operation::Write))?; - file.sync_all() - .map_err(|_| secret_backend_failure(Operation::Write))?; - set_secret_permissions(&self.key_path) - .map_err(|_| secret_backend_failure(Operation::Write))?; - Ok(key) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - let raw = fs::read(&self.key_path) - .map_err(|_| secret_backend_failure(Operation::Read))?; - key_from_bytes(raw.as_slice()) - } - Err(_) => Err(secret_backend_failure(Operation::Provision)), - } - } - - fn load_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { - let raw = fs::read(&self.key_path).map_err(|_| secret_backend_failure(Operation::Read))?; - key_from_bytes(raw.as_slice()) - } -} - -impl KeyWrapping for MycFileKeyWrapping { - fn wrap<'a>( - &'a self, - request: WrapRequest<'a>, - ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { - Box::pin(async move { - validate_identity_reference(request.reference(), Operation::Wrap)?; - let mut key = self.load_or_create_key()?; - let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random(); - let aad = wrapping_aad(request.reference(), request.context()); - let ciphertext = request.plaintext().expose_secret(|plaintext| { - XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt( - XNonce::from_slice(&nonce), - Payload { - msg: plaintext, - aad: aad.as_slice(), - }, - ) - }); - key.zeroize(); - let ciphertext = ciphertext.map_err(|_| secret_backend_failure(Operation::Wrap))?; - let mut wrapped = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); - wrapped.push(WRAPPED_KEY_VERSION); - wrapped.extend_from_slice(&nonce); - wrapped.extend_from_slice(ciphertext.as_slice()); - WrappedSecret::from_bytes(wrapped) - }) - } - - fn unwrap<'a>( - &'a self, - request: UnwrapRequest<'a>, - ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { - Box::pin(async move { - validate_identity_reference(request.reference(), Operation::Unwrap)?; - let aad = wrapping_aad(request.reference(), request.context()); - self.unwrap_with_aad(request.wrapped(), WRAPPED_KEY_VERSION, aad.as_slice()) - }) - } - - fn unwrap_legacy_v1<'a>( - &'a self, - request: LegacyV1UnwrapRequest<'a>, - ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { - Box::pin(async move { - validate_identity_reference(request.reference(), Operation::Unwrap)?; - self.unwrap_with_aad( - request.wrapped(), - LEGACY_WRAPPED_KEY_VERSION, - request.reference().id().as_str().as_bytes(), - ) - }) - } -} - -impl MycFileKeyWrapping { - fn unwrap_with_aad( - &self, - wrapped: &WrappedSecret, - expected_version: u8, - aad: &[u8], - ) -> Result<SecretMaterial, radroots_secrets::Error> { - let wrapped = wrapped.as_bytes(); - if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != expected_version { - return Err(secret_backend_failure(Operation::Unwrap)); - } - let mut key = self.load_key()?; - let plaintext = XChaCha20Poly1305::new(Key::from_slice(&key)).decrypt( - XNonce::from_slice(&wrapped[1..1 + WRAPPING_NONCE_BYTES]), - Payload { - msg: &wrapped[1 + WRAPPING_NONCE_BYTES..], - aad, - }, - ); - key.zeroize(); - SecretMaterial::from_slice( - &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?, - ) - } -} - -fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> { - let id = reference.id().as_str().as_bytes(); - let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32); - aad.extend_from_slice(WRAPPING_AAD_DOMAIN); - aad.extend_from_slice( - &u16::try_from(id.len()) - .expect("validated secret identifier length fits u16") - .to_be_bytes(), - ); - aad.extend_from_slice(id); - aad.extend_from_slice(&reference.key_version().get().to_be_bytes()); - aad.extend_from_slice(&context.authentication_digest()); - aad -} - -fn validate_identity_reference( - reference: &SecretRef, - operation: Operation, -) -> Result<(), radroots_secrets::Error> { - if reference.backend() != BackendKind::External - || reference.key_version().get() != 1 - || reference.id().as_str() != MYC_IDENTITY_KEY_SLOT - { - return Err(secret_backend_failure(operation)); - } - Ok(()) -} - -fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> { - Ok(SecretRef::new( - SecretId::parse(MYC_IDENTITY_KEY_SLOT)?, - BackendKind::External, - KeyVersion::new(1)?, - )) -} - -fn identity_envelope_context() -> Result<EnvelopeContext, radroots_secrets::Error> { - Ok(EnvelopeContext::new( - EnvelopePurpose::parse("radroots.service_identity")?, - EnvelopeSubject::parse("service", "myc")?, - PayloadSchemaId::parse("radroots.myc_identity.v1")?, - )) -} - -fn secret_backend_failure(operation: Operation) -> radroots_secrets::Error { - radroots_secrets::Error::BackendFailure { - backend: BackendKind::External, - operation, - } -} - -fn key_from_bytes(raw: &[u8]) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { - raw.try_into() - .map_err(|_| secret_backend_failure(Operation::Read)) -} - -fn storage_error(path: &Path, operation: &str) -> IdentityError { - IdentityError::ProtectedStorage { - path: path.to_path_buf(), - message: operation.to_owned(), - } -} - -pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf { - let mut value = OsString::from(path.as_ref().as_os_str()); - value.push(".key"); - PathBuf::from(value) -} - -pub fn store_encrypted_identity( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - let path = path.as_ref(); - if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) { - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - } - let payload = serde_json::to_vec(&identity.to_file())?; - let plaintext = SecretMaterial::from_slice(payload.as_slice()) - .map_err(|_| storage_error(path, "validate identity secret material"))?; - let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>()) - .map_err(|_| storage_error(path, "validate identity data key"))?; - let wrapping = MycFileKeyWrapping::new(path); - let context = - identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?; - let envelope = futures_executor::block_on(EncryptedEnvelope::seal( - &wrapping, - SealRequest::new( - identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?, - context, - &plaintext, - SealMaterial::new(data_key, Nonce::new(rand::random())), - ), - )) - .map_err(|_| storage_error(path, "seal encrypted identity"))?; - let encoded = envelope - .encode() - .map_err(|_| storage_error(path, "encode encrypted identity"))?; - atomic_write(path, encoded.as_slice()) -} - -pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> { - let path = path.as_ref(); - let encoded = fs::read(path).map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - IdentityError::NotFound(path.to_path_buf()) - } else { - IdentityError::Read(path.to_path_buf(), source) - } - })?; - let envelope = EncryptedEnvelope::decode(encoded.as_slice()) - .map_err(|_| storage_error(path, "decode encrypted identity"))?; - let wrapping = MycFileKeyWrapping::new(path); - let context = - identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?; - if envelope.version() == LEGACY_ENVELOPE_VERSION { - return migrate_legacy_identity(path, envelope, &wrapping, context); - } - if envelope.version() != ENVELOPE_VERSION { - return Err(storage_error( - path, - "unsupported encrypted identity version", - )); - } - open_identity(path, &envelope, &wrapping, &context) -} - -fn open_identity( - path: &Path, - envelope: &EncryptedEnvelope, - wrapping: &MycFileKeyWrapping, - context: &EnvelopeContext, -) -> Result<RadrootsIdentity, IdentityError> { - let payload = futures_executor::block_on(envelope.open(wrapping, context)) - .map_err(|_| storage_error(path, "open encrypted identity"))?; - let file: RadrootsIdentityFile = payload - .expose_secret(|bytes| serde_json::from_slice(bytes)) - .map_err(IdentityError::from)?; - RadrootsIdentity::try_from(file) -} - -fn migrate_legacy_identity( - path: &Path, - envelope: EncryptedEnvelope, - wrapping: &MycFileKeyWrapping, - context: EnvelopeContext, -) -> Result<RadrootsIdentity, IdentityError> { - let expected_reference = - identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?; - let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>()) - .map_err(|_| storage_error(path, "validate identity data key"))?; - let resealed = futures_executor::block_on(envelope.reseal_legacy_v1( - wrapping, - &LegacyV1ResealAuthority::new(), - &expected_reference, - identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?, - context.clone(), - &valid_identity_payload, - SealMaterial::new(data_key, Nonce::new(rand::random())), - )) - .map_err(|_| storage_error(path, "migrate legacy encrypted identity"))?; - let envelope = resealed.into_envelope(); - let identity = open_identity(path, &envelope, wrapping, &context)?; - let encoded = envelope - .encode() - .map_err(|_| storage_error(path, "encode migrated identity"))?; - atomic_write(path, encoded.as_slice())?; - Ok(identity) -} - -fn valid_identity_payload(bytes: &[u8]) -> bool { - serde_json::from_slice::<RadrootsIdentityFile>(bytes) - .ok() - .and_then(|file| RadrootsIdentity::try_from(file).ok()) - .is_some() -} - -pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> { - let path = path.as_ref(); - let identity = load_encrypted_identity(path)?; - let key_path = encrypted_identity_wrapping_key_path(path); - let old_key = - fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?; - fs::remove_file(&key_path).map_err(|source| IdentityError::Write(key_path.clone(), source))?; - if let Err(error) = store_encrypted_identity(path, &identity) { - fs::write(&key_path, old_key) - .map_err(|source| IdentityError::Write(key_path.clone(), source))?; - set_secret_permissions(&key_path) - .map_err(|source| IdentityError::Write(key_path, source))?; - return Err(error); - } - Ok(()) -} - -pub fn load_identity_profile( - path: impl AsRef<Path>, -) -> Result<RadrootsIdentityPublic, IdentityError> { - let path = path.as_ref(); - let encoded = fs::read(path).map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - IdentityError::NotFound(path.to_path_buf()) - } else { - IdentityError::Read(path.to_path_buf(), source) - } - })?; - serde_json::from_slice(encoded.as_slice()).map_err(IdentityError::from) -} - -pub fn store_identity_profile( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - let encoded = serde_json::to_vec_pretty(&identity.to_public())?; - atomic_write(path.as_ref(), encoded.as_slice()) -} - -fn atomic_write(path: &Path, encoded: &[u8]) -> Result<(), IdentityError> { - let parent = path - .parent() - .filter(|value| !value.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - let mut temporary = tempfile::NamedTempFile::new_in(parent) - .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?; - temporary - .write_all(encoded) - .and_then(|()| temporary.as_file().sync_all()) - .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?; - set_file_permissions(temporary.as_file()) - .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?; - temporary - .persist(path) - .map_err(|error| IdentityError::Write(path.to_path_buf(), error.error))?; - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|source| IdentityError::Write(path.to_path_buf(), source)) -} - -#[cfg(unix)] -fn set_secret_permissions(path: &Path) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} - -#[cfg(not(unix))] -fn set_secret_permissions(_path: &Path) -> std::io::Result<()> { - Ok(()) -} - -fn set_file_permissions(file: &fs::File) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.set_permissions(fs::Permissions::from_mode(0o600)) - } - #[cfg(not(unix))] - { - let _ = file; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn identity() -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity") - } - - #[test] - fn encrypted_identity_round_trips_and_rotates_wrapping_key() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc"); - let identity = identity(); - store_encrypted_identity(&path, &identity).expect("store"); - let key_path = encrypted_identity_wrapping_key_path(&path); - let before = fs::read(&key_path).expect("key before"); - assert_eq!( - load_encrypted_identity(&path).expect("load").id(), - identity.id() - ); - rotate_encrypted_identity(&path).expect("rotate"); - assert_ne!(before, fs::read(key_path).expect("key after")); - assert_eq!( - load_encrypted_identity(&path).expect("load").id(), - identity.id() - ); - let envelope = - EncryptedEnvelope::decode(&fs::read(&path).expect("read encrypted identity")) - .expect("decode encrypted identity"); - assert_eq!(envelope.version(), ENVELOPE_VERSION); - assert_eq!( - envelope.context(), - Some(&identity_envelope_context().expect("identity context")) - ); - } - - #[test] - fn encrypted_identity_migrates_legacy_v1() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc"); - let identity = identity(); - store_legacy_identity(&path, &identity); - - assert_eq!( - load_encrypted_identity(&path) - .expect("migrate legacy identity") - .id(), - identity.id() - ); - let envelope = EncryptedEnvelope::decode(&fs::read(&path).expect("read migrated identity")) - .expect("decode migrated identity"); - assert_eq!(envelope.version(), ENVELOPE_VERSION); - assert_eq!( - envelope.context(), - Some(&identity_envelope_context().expect("identity context")) - ); - } - - #[test] - fn public_profile_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.json"); - let identity = identity(); - store_identity_profile(&path, &identity).expect("store profile"); - assert_eq!( - load_identity_profile(path).expect("load profile").id, - identity.id() - ); - } - - fn store_legacy_identity(path: &Path, identity: &RadrootsIdentity) { - const NONCE_BYTES: usize = 24; - const TAG_BYTES: usize = 16; - - let wrapping_key = [0x11; 32]; - let data_key = [0x22; 32]; - let wrapping_nonce = [0x33; NONCE_BYTES]; - let envelope_nonce = [0x44; NONCE_BYTES]; - let payload = serde_json::to_vec(&identity.to_file()).expect("encode identity payload"); - let wrapped_ciphertext = XChaCha20Poly1305::new(Key::from_slice(&wrapping_key)) - .encrypt( - XNonce::from_slice(&wrapping_nonce), - Payload { - msg: &data_key, - aad: MYC_IDENTITY_KEY_SLOT.as_bytes(), - }, - ) - .expect("wrap legacy data key"); - let mut wrapped = Vec::with_capacity(1 + NONCE_BYTES + wrapped_ciphertext.len()); - wrapped.push(LEGACY_WRAPPED_KEY_VERSION); - wrapped.extend_from_slice(&wrapping_nonce); - wrapped.extend_from_slice(&wrapped_ciphertext); - - let id = MYC_IDENTITY_KEY_SLOT.as_bytes(); - let mut encoded = Vec::new(); - encoded.extend_from_slice(b"RRS1"); - encoded.extend_from_slice(&LEGACY_ENVELOPE_VERSION.to_be_bytes()); - encoded.extend_from_slice(&[1, 1, 4]); - encoded.extend_from_slice(&1_u32.to_be_bytes()); - encoded.extend_from_slice(&u16::try_from(id.len()).expect("id length").to_be_bytes()); - encoded.extend_from_slice(id); - encoded.extend_from_slice(&envelope_nonce); - encoded.extend_from_slice( - &u32::try_from(wrapped.len()) - .expect("wrapped length") - .to_be_bytes(), - ); - encoded.extend_from_slice(&wrapped); - encoded.extend_from_slice( - &u32::try_from(payload.len() + TAG_BYTES) - .expect("ciphertext length") - .to_be_bytes(), - ); - let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&data_key)) - .encrypt( - XNonce::from_slice(&envelope_nonce), - Payload { - msg: &payload, - aad: &encoded, - }, - ) - .expect("encrypt legacy payload"); - encoded.extend_from_slice(&ciphertext); - - fs::write(path, encoded).expect("write legacy envelope"); - let key_path = encrypted_identity_wrapping_key_path(path); - fs::write(&key_path, wrapping_key).expect("write wrapping key"); - set_secret_permissions(&key_path).expect("secure wrapping key"); - } -} diff --git a/src/logging.rs b/src/logging.rs @@ -1,86 +0,0 @@ -use crate::config::MycLoggingConfig; -use crate::error::MycError; -use tracing_subscriber::fmt::writer::MakeWriterExt; -use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; - -static LOG_GUARD: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> = - std::sync::OnceLock::new(); - -pub fn init_logging(config: &MycLoggingConfig) -> Result<(), MycError> { - let filter = - EnvFilter::try_new(config.filter.clone()).map_err(|source| MycError::InvalidLogFilter { - filter: config.filter.clone(), - source, - })?; - let registry = tracing_subscriber::registry().with(filter); - - match config.output_dir.as_deref() { - Some(directory) => { - std::fs::create_dir_all(directory).map_err(|source| MycError::CreateDir { - path: directory.to_path_buf(), - source, - })?; - let appender = tracing_appender::rolling::never(directory, "myc.log"); - let (file_writer, guard) = tracing_appender::non_blocking(appender); - if config.stdout { - registry - .with( - tracing_subscriber::fmt::layer() - .with_writer(std::io::stdout.and(file_writer)), - ) - .try_init() - .map_err(|_| MycError::LoggingAlreadyInitialized)?; - } else { - registry - .with(tracing_subscriber::fmt::layer().with_writer(file_writer)) - .try_init() - .map_err(|_| MycError::LoggingAlreadyInitialized)?; - } - LOG_GUARD - .set(guard) - .map_err(|_| MycError::LoggingAlreadyInitialized)?; - } - None if config.stdout => { - registry - .with(tracing_subscriber::fmt::layer().with_writer(std::io::stdout)) - .try_init() - .map_err(|_| MycError::LoggingAlreadyInitialized)?; - } - None => { - return Err(MycError::InvalidOperation( - "logging requires stdout or an output directory".to_owned(), - )); - } - } - - tracing::info!("logging initialized"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::config::MycLoggingConfig; - - #[test] - fn explicit_logging_configuration_preserves_values() { - let config = MycLoggingConfig { - filter: "info,myc=debug".to_owned(), - output_dir: Some(PathBuf::from("/tmp/myc-logs")), - stdout: false, - }; - - assert_eq!(config.output_dir, Some(PathBuf::from("/tmp/myc-logs"))); - assert!(!config.stdout); - } - - #[test] - fn stable_log_path_is_host_owned() { - let directory = PathBuf::from("/tmp/myc-logs"); - assert_eq!( - directory.join("myc.log"), - PathBuf::from("/tmp/myc-logs/myc.log") - ); - } -} diff --git a/src/nostr_contract.rs b/src/nostr_contract.rs @@ -1,114 +0,0 @@ -//! Myc-owned relay client and explicit aliases at the final Nostr boundary. - -use std::time::Duration; - -pub use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl}; -pub use nostr_sdk::prelude::Output as RadrootsNostrOutput; -pub use nostr_sdk::{ - RelayPoolNotification as RadrootsNostrRelayPoolNotification, - RelayStatus as RadrootsNostrRelayStatus, -}; -pub use radroots_nostr::Error as RadrootsNostrError; -pub use radroots_nostr::event::{ - ApplicationHandlerSpec as RadrootsNostrApplicationHandlerSpec, Event as RadrootsNostrEvent, - EventId as RadrootsNostrEventId, ExternalSigningRequest as RadrootsNostrExternalSigningRequest, - GenericBuilder as RadrootsNostrGenericEventBuilder, Kind as RadrootsNostrKind, - Metadata as RadrootsNostrMetadata, Timestamp as RadrootsNostrTimestamp, - build_application_handler as radroots_nostr_build_application_handler_event, - metadata_has_fields as radroots_nostr_metadata_has_fields, -}; -pub use radroots_nostr::filter::Filter as RadrootsNostrFilter; -pub use radroots_nostr::tag::{Tag as RadrootsNostrTag, TagKind as RadrootsNostrTagKind}; - -pub fn radroots_nostr_filter_tag( - filter: RadrootsNostrFilter, - tag: &str, - values: Vec<String>, -) -> Result<RadrootsNostrFilter, RadrootsNostrError> { - radroots_nostr::filter::with_tag(filter, tag, values) -} - -pub fn radroots_nostr_tag_first_value(tag: &RadrootsNostrTag, key: &str) -> Option<String> { - radroots_nostr::tag::first_value(tag, key) -} - -pub fn radroots_nostr_kind(kind: u16) -> RadrootsNostrKind { - radroots_nostr::filter::kind(kind) -} - -#[derive(Clone)] -pub struct RadrootsNostrClient { - inner: nostr_sdk::Client, -} - -impl RadrootsNostrClient { - pub fn with_keys(keys: nostr::Keys) -> Self { - let inner = nostr_sdk::Client::new(keys); - inner.automatic_authentication(false); - Self { inner } - } - - pub fn from_identity(identity: &crate::host_identity::RadrootsIdentity) -> Self { - Self::with_keys(identity.keys().clone()) - } - - pub fn from_identity_owned(identity: crate::host_identity::RadrootsIdentity) -> Self { - Self::with_keys(identity.keys().clone()) - } - - pub fn new_signerless() -> Self { - let inner = nostr_sdk::Client::default(); - inner.automatic_authentication(false); - Self { inner } - } - - pub fn into_inner(self) -> nostr_sdk::Client { - self.inner - } - - pub async fn connect(&self) { - self.inner.connect().await; - } - - pub async fn wait_for_connection(&self, timeout: Duration) { - self.inner.wait_for_connection(timeout).await; - } - - pub async fn add_relay(&self, url: &str) -> Result<bool, nostr_sdk::client::Error> { - self.inner.add_relay(url).await - } - - pub async fn relays(&self) -> std::collections::HashMap<nostr::RelayUrl, nostr_sdk::Relay> { - self.inner.relays().await - } - - pub async fn has_signer(&self) -> bool { - self.inner.has_signer().await - } - - pub async fn fetch_events( - &self, - filter: RadrootsNostrFilter, - timeout: Duration, - ) -> Result<Vec<RadrootsNostrEvent>, nostr_sdk::client::Error> { - self.inner - .fetch_events(filter, timeout) - .await - .map(|events| events.to_vec()) - } - - pub async fn subscribe( - &self, - filter: RadrootsNostrFilter, - options: Option<nostr_sdk::SubscribeAutoCloseOptions>, - ) -> Result<RadrootsNostrOutput<nostr::SubscriptionId>, nostr_sdk::client::Error> { - self.inner.subscribe(filter, options).await - } - - pub async fn send_event( - &self, - event: &RadrootsNostrEvent, - ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, nostr_sdk::client::Error> { - self.inner.send_event(event).await - } -} diff --git a/src/policy.rs b/src/policy.rs @@ -1,924 +0,0 @@ -use std::collections::{BTreeSet, HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager, - RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Policy, -}; -use nostr::PublicKey; -use radroots_nostr_connect::{ - Method, Permission, Request, message::RequestMessage, permission::Permissions, -}; - -use crate::config::{MycConnectionApproval, MycPolicyConfig}; -use crate::error::MycError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MycConnectDecision { - Allow, - RequireApproval, - Deny, -} - -#[derive(Debug, Clone)] -pub struct MycPolicyContext { - default_connect_decision: MycConnectDecision, - trusted_client_pubkeys: BTreeSet<String>, - denied_client_pubkeys: BTreeSet<String>, - permission_ceiling: Permissions, - allowed_sign_event_kinds: BTreeSet<u16>, - auth_url: Option<String>, - auth_pending_ttl_secs: u64, - auth_authorized_ttl_secs: Option<u64>, - reauth_after_inactivity_secs: Option<u64>, - connect_rate_limiter: Option<MycPolicyRateLimiter>, - auth_challenge_rate_limiter: Option<MycPolicyRateLimiter>, -} - -#[derive(Debug, Clone)] -struct MycPolicyRateLimiter { - window_secs: u64, - max_attempts: usize, - entries: Arc<Mutex<HashMap<String, VecDeque<u64>>>>, -} - -impl MycPolicyContext { - pub fn from_config(config: &MycPolicyConfig) -> Result<Self, MycError> { - Ok(Self { - default_connect_decision: match config.connection_approval { - MycConnectionApproval::NotRequired => MycConnectDecision::Allow, - MycConnectionApproval::ExplicitUser => MycConnectDecision::RequireApproval, - MycConnectionApproval::Deny => MycConnectDecision::Deny, - }, - trusted_client_pubkeys: normalize_public_key_set(&config.trusted_client_pubkeys)?, - denied_client_pubkeys: normalize_public_key_set(&config.denied_client_pubkeys)?, - permission_ceiling: normalize_permissions(config.permission_ceiling.clone()), - allowed_sign_event_kinds: config.allowed_sign_event_kinds.iter().copied().collect(), - auth_url: config.auth_url.clone(), - auth_pending_ttl_secs: config.auth_pending_ttl_secs, - auth_authorized_ttl_secs: config.auth_authorized_ttl_secs, - reauth_after_inactivity_secs: config.reauth_after_inactivity_secs, - connect_rate_limiter: build_rate_limiter( - config.connect_rate_limit_window_secs, - config.connect_rate_limit_max_attempts, - ), - auth_challenge_rate_limiter: build_rate_limiter( - config.auth_challenge_rate_limit_window_secs, - config.auth_challenge_rate_limit_max_attempts, - ), - }) - } - - pub fn default_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { - match self.default_connect_decision { - MycConnectDecision::Allow => RadrootsNostrSignerApprovalRequirement::NotRequired, - MycConnectDecision::RequireApproval | MycConnectDecision::Deny => { - RadrootsNostrSignerApprovalRequirement::ExplicitUser - } - } - } - - pub fn connect_decision(&self, client_public_key: &PublicKey) -> MycConnectDecision { - let client_public_key_hex = client_public_key.to_hex(); - if self.denied_client_pubkeys.contains(&client_public_key_hex) { - return MycConnectDecision::Deny; - } - if self.trusted_client_pubkeys.contains(&client_public_key_hex) { - return MycConnectDecision::Allow; - } - self.default_connect_decision - } - - pub fn approval_requirement_for_client( - &self, - client_public_key: &PublicKey, - ) -> Option<RadrootsNostrSignerApprovalRequirement> { - match self.connect_decision(client_public_key) { - MycConnectDecision::Allow => Some(RadrootsNostrSignerApprovalRequirement::NotRequired), - MycConnectDecision::RequireApproval => { - Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser) - } - MycConnectDecision::Deny => None, - } - } - - pub fn connect_rate_limit_denied_reason( - &self, - client_public_key: &PublicKey, - ) -> Option<String> { - self.connect_rate_limiter.as_ref().and_then(|limiter| { - limiter - .check_and_record(&client_public_key.to_hex()) - .map(|retry_after_secs| throttled_reason("connect attempts", retry_after_secs)) - }) - } - - pub fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions { - self.filtered_requested_permissions(requested_permissions) - } - - pub fn filtered_requested_permissions( - &self, - requested_permissions: &Permissions, - ) -> Permissions { - let mut filtered = Vec::new(); - - for permission in requested_permissions.as_slice() { - if permission.method == Method::SignEvent - && permission.parameter.is_none() - && !self.allowed_sign_event_kinds.is_empty() - { - for kind in &self.allowed_sign_event_kinds { - let candidate = - Permission::with_parameter(Method::SignEvent, format!("kind:{kind}")); - if self.permission_within_policy(&candidate) { - filtered.push(candidate); - } - } - continue; - } - - if self.permission_within_policy(permission) { - filtered.push(permission.clone()); - } - } - - normalize_permissions(filtered.into()) - } - - pub fn validate_operator_grants( - &self, - granted_permissions: Permissions, - ) -> Result<Permissions, MycError> { - let granted_permissions = normalize_permissions(granted_permissions); - let invalid_permissions = granted_permissions - .as_slice() - .iter() - .filter(|permission| !self.permission_within_policy(permission)) - .map(ToString::to_string) - .collect::<Vec<_>>(); - - if invalid_permissions.is_empty() { - Ok(granted_permissions) - } else { - Err(MycError::InvalidOperation(format!( - "granted permissions exceed the configured policy ceiling: {}", - invalid_permissions.join(", ") - ))) - } - } - - pub fn prepare_request<B: RadrootsNostrSignerBackend>( - &self, - backend: &B, - connection: &RadrootsNostrSignerConnectionRecord, - request_message: &RequestMessage, - ) -> Result<Option<String>, MycError> { - if self.client_is_denied(&connection.client_public_key) { - return Ok(Some("client public key denied by policy".to_owned())); - } - - if let Some(reason) = self.request_denied_reason(&request_message.request) { - return Ok(Some(reason)); - } - - if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending - && self.auth_challenge_is_expired(connection) - { - if self.request_uses_automatic_auth(connection, &request_message.request) { - if let Some(reason) = - self.require_auth_challenge_with_guardrails(backend, connection)? - { - return Ok(Some(reason)); - } - } else { - return Ok(Some( - "auth challenge expired; require a new auth challenge".to_owned(), - )); - } - } else if self.should_require_fresh_auth(connection, &request_message.request) - && let Some(reason) = - self.require_auth_challenge_with_guardrails(backend, connection)? - { - return Ok(Some(reason)); - } - - Ok(None) - } - - pub fn ensure_authorize_auth_challenge_allowed( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> Result<(), MycError> { - if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending - && self.auth_challenge_is_expired(connection) - { - return Err(MycError::InvalidOperation( - "auth challenge expired; require a new auth challenge".to_owned(), - )); - } - Ok(()) - } - - pub fn cleanup_stale_sessions( - &self, - manager: &RadrootsNostrSignerManager, - ) -> Result<usize, MycError> { - let mut cleaned = 0usize; - for connection in manager.list_connections()? { - if !self.stale_session_requires_cleanup(&connection) { - continue; - } - self.require_auth_challenge_with_manager(manager, &connection)?; - cleaned += 1; - } - Ok(cleaned) - } - - fn client_is_denied(&self, client_public_key: &PublicKey) -> bool { - self.denied_client_pubkeys - .contains(&client_public_key.to_hex()) - } - - fn client_is_trusted(&self, client_public_key: &PublicKey) -> bool { - self.trusted_client_pubkeys - .contains(&client_public_key.to_hex()) - } - - fn permission_within_policy(&self, permission: &Permission) -> bool { - if permission.method == Method::SignEvent && !self.allowed_sign_event_kinds.is_empty() { - let Some(kind) = permission - .parameter - .as_deref() - .and_then(parse_sign_event_kind_parameter) - else { - return false; - }; - if !self.allowed_sign_event_kinds.contains(&kind) { - return false; - } - } - - if self.permission_ceiling.is_empty() { - return true; - } - - self.permission_ceiling - .as_slice() - .iter() - .any(|ceiling| permission_within_ceiling(permission, ceiling)) - } - - fn request_denied_reason(&self, request: &Request) -> Option<String> { - if self.permission_ceiling.is_empty() - && (self.allowed_sign_event_kinds.is_empty() - || !matches!(request, Request::SignEvent(_))) - { - return None; - } - - let required_permission = required_permission_for_request(request)?; - if self.permission_within_policy(&required_permission) { - None - } else { - Some(format!( - "request {} is outside the configured policy ceiling", - request.method() - )) - } - } - - fn request_uses_automatic_auth( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - request: &Request, - ) -> bool { - self.automatic_auth_enabled_for_connection(connection) && request_requires_auth(request) - } - - fn should_require_fresh_auth( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - request: &Request, - ) -> bool { - if !self.request_uses_automatic_auth(connection, request) { - return false; - } - - if connection.auth_state == crate::signer::prelude::RadrootsNostrSignerAuthState::Pending { - return false; - } - - let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { - return true; - }; - let now_unix = now_unix_secs(); - - if self - .auth_authorized_ttl_secs - .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) - { - return true; - } - - self.reauth_after_inactivity_secs.is_some_and(|ttl| { - let Some(last_request_at_unix) = connection.last_request_at_unix else { - return false; - }; - now_unix > last_request_at_unix.saturating_add(ttl) - }) - } - - fn auth_challenge_is_expired(&self, connection: &RadrootsNostrSignerConnectionRecord) -> bool { - let Some(auth_challenge) = connection.auth_challenge.as_ref() else { - return false; - }; - now_unix_secs() - > auth_challenge - .required_at_unix - .saturating_add(self.auth_pending_ttl_secs) - } - - fn auth_url(&self) -> Result<&str, MycError> { - self.auth_url.as_deref().ok_or_else(|| { - MycError::InvalidOperation( - "automatic auth policy requires policy.auth_url to be configured".to_owned(), - ) - }) - } - - fn automatic_auth_enabled_for_connection( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> bool { - self.auth_url.is_some() && self.client_is_trusted(&connection.client_public_key) - } - - fn require_auth_challenge_with_guardrails<B: RadrootsNostrSignerBackend>( - &self, - backend: &B, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> Result<Option<String>, MycError> { - if let Some(retry_after_secs) = self - .auth_challenge_rate_limiter - .as_ref() - .and_then(|limiter| limiter.check_and_record(&connection.client_public_key.to_hex())) - { - return Ok(Some(throttled_reason( - "auth challenge issuance", - retry_after_secs, - ))); - } - self.require_auth_challenge_with_backend(backend, connection)?; - Ok(None) - } - - fn require_auth_challenge_with_backend<B: RadrootsNostrSignerBackend>( - &self, - backend: &B, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> Result<(), MycError> { - backend.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; - Ok(()) - } - - fn require_auth_challenge_with_manager( - &self, - manager: &RadrootsNostrSignerManager, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> Result<(), MycError> { - manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; - Ok(()) - } - - fn stale_session_requires_cleanup( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - ) -> bool { - if connection.is_terminal() - || connection.auth_state - != crate::signer::prelude::RadrootsNostrSignerAuthState::Authorized - || !self.automatic_auth_enabled_for_connection(connection) - { - return false; - } - - let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { - return true; - }; - let now_unix = now_unix_secs(); - - if self - .auth_authorized_ttl_secs - .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) - { - return true; - } - - self.reauth_after_inactivity_secs.is_some_and(|ttl| { - connection - .last_request_at_unix - .is_some_and(|last_request_at_unix| { - now_unix > last_request_at_unix.saturating_add(ttl) - }) - }) - } -} - -impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for MycPolicyContext { - fn connect_decision( - &self, - client_public_key: &PublicKey, - ) -> RadrootsNostrSignerNip46ConnectDecision { - match self.connect_decision(client_public_key) { - MycConnectDecision::Allow => RadrootsNostrSignerNip46ConnectDecision::Allow, - MycConnectDecision::RequireApproval => { - RadrootsNostrSignerNip46ConnectDecision::RequireApproval - } - MycConnectDecision::Deny => RadrootsNostrSignerNip46ConnectDecision::Deny, - } - } - - fn connect_rate_limit_denied_reason(&self, client_public_key: &PublicKey) -> Option<String> { - self.connect_rate_limit_denied_reason(client_public_key) - } - - fn approval_requirement_for_client( - &self, - client_public_key: &PublicKey, - ) -> Option<RadrootsNostrSignerApprovalRequirement> { - self.approval_requirement_for_client(client_public_key) - } - - fn filtered_requested_permissions(&self, requested_permissions: &Permissions) -> Permissions { - self.filtered_requested_permissions(requested_permissions) - } - - fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions { - self.auto_granted_permissions(requested_permissions) - } - - fn prepare_request( - &self, - backend: &B, - connection: &RadrootsNostrSignerConnectionRecord, - request_message: &RequestMessage, - ) -> Result<Option<String>, crate::signer::prelude::RadrootsNostrSignerError> { - self.prepare_request(backend, connection, request_message) - .map_err(myc_policy_signer_error) - } -} - -impl MycPolicyRateLimiter { - fn check_and_record(&self, key: &str) -> Option<u64> { - let now_unix = now_unix_secs(); - let mut guard = self - .entries - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let attempts = guard.entry(key.to_owned()).or_default(); - prune_attempts(attempts, now_unix, self.window_secs); - if attempts.len() >= self.max_attempts { - return Some( - attempts - .front() - .copied() - .map(|oldest_attempt_unix| { - oldest_attempt_unix - .saturating_add(self.window_secs) - .saturating_sub(now_unix) - .max(1) - }) - .unwrap_or(1), - ); - } - attempts.push_back(now_unix); - None - } -} - -fn normalize_permissions(permissions: Permissions) -> Permissions { - let mut permissions = permissions.into_vec(); - permissions.sort(); - permissions.dedup(); - permissions.into() -} - -fn normalize_public_key_set(values: &[String]) -> Result<BTreeSet<String>, MycError> { - values - .iter() - .map(|value| normalize_public_key_hex(value)) - .collect() -} - -fn normalize_public_key_hex(value: &str) -> Result<String, MycError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(MycError::InvalidConfig( - "policy client pubkeys must not contain empty values".to_owned(), - )); - } - let public_key = PublicKey::parse(trimmed) - .or_else(|_| PublicKey::from_hex(trimmed)) - .map_err(|_| { - MycError::InvalidConfig(format!( - "policy client pubkey `{trimmed}` is not a valid nostr public key" - )) - })?; - Ok(public_key.to_hex()) -} - -fn required_permission_for_request(request: &Request) -> Option<Permission> { - match request { - Request::Connect { .. } - | Request::GetPublicKey - | Request::GetSessionCapability - | Request::Ping - | Request::Logout => None, - Request::SignEvent(unsigned_event) => Some(Permission::with_parameter( - Method::SignEvent, - format!("kind:{}", unsigned_event.kind()), - )), - Request::Nip04Encrypt { .. } => Some(Permission::new(Method::Nip04Encrypt)), - Request::Nip04Decrypt { .. } => Some(Permission::new(Method::Nip04Decrypt)), - Request::Nip44Encrypt { .. } => Some(Permission::new(Method::Nip44Encrypt)), - Request::Nip44Decrypt { .. } => Some(Permission::new(Method::Nip44Decrypt)), - Request::SwitchRelays => Some(Permission::new(Method::SwitchRelays)), - Request::Custom { method, .. } => Some(Permission::new(method.clone())), - } -} - -fn permission_within_ceiling(permission: &Permission, ceiling: &Permission) -> bool { - if permission.method != ceiling.method { - return false; - } - - match ( - &permission.method, - permission.parameter.as_deref(), - ceiling.parameter.as_deref(), - ) { - (Method::SignEvent, _, None) => true, - (Method::SignEvent, Some(parameter), Some(ceiling_parameter)) => { - sign_event_parameter_eq(parameter, ceiling_parameter) - } - (Method::SignEvent, None, Some(_)) => false, - (_, _, None) => true, - (_, Some(parameter), Some(ceiling_parameter)) => parameter == ceiling_parameter, - (_, None, Some(_)) => false, - } -} - -fn sign_event_parameter_eq(left: &str, right: &str) -> bool { - parse_sign_event_kind_parameter(left) == parse_sign_event_kind_parameter(right) -} - -fn parse_sign_event_kind_parameter(value: &str) -> Option<u16> { - value - .strip_prefix("kind:") - .unwrap_or(value) - .parse::<u16>() - .ok() -} - -fn request_requires_auth(request: &Request) -> bool { - !matches!( - request, - Request::Connect { .. } - | Request::GetPublicKey - | Request::GetSessionCapability - | Request::Ping - | Request::Logout - ) -} - -fn build_rate_limiter( - window_secs: Option<u64>, - max_attempts: Option<usize>, -) -> Option<MycPolicyRateLimiter> { - match (window_secs, max_attempts) { - (Some(window_secs), Some(max_attempts)) => Some(MycPolicyRateLimiter { - window_secs, - max_attempts, - entries: Arc::new(Mutex::new(HashMap::new())), - }), - _ => None, - } -} - -fn prune_attempts(attempts: &mut VecDeque<u64>, now_unix: u64, window_secs: u64) { - while attempts - .front() - .copied() - .is_some_and(|attempt_unix| now_unix > attempt_unix.saturating_add(window_secs)) - { - let _ = attempts.pop_front(); - } -} - -fn throttled_reason(label: &str, retry_after_secs: u64) -> String { - format!("{label} throttled by policy; retry after {retry_after_secs}s") -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -fn myc_policy_signer_error(error: MycError) -> crate::signer::prelude::RadrootsNostrSignerError { - crate::signer::prelude::RadrootsNostrSignerError::InvalidState(error.to_string()) -} - -#[cfg(test)] -mod tests { - use super::{MycConnectDecision, MycPolicyContext}; - use crate::config::{MycConnectionApproval, MycPolicyConfig}; - use crate::host_identity::RadrootsIdentity; - use crate::signer::prelude::{ - RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerManager, - }; - use nostr::PublicKey; - use radroots_nostr_connect::{ - Method, Permission, Request, message::RequestMessage, permission::Permissions, - }; - use serde_json::json; - use std::thread; - use std::time::Duration; - - fn public_key(hex: &str) -> PublicKey { - PublicKey::parse(hex).expect("public key") - } - - fn identity(secret_key: &str) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") - } - - fn in_memory_manager() -> RadrootsNostrSignerManager { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity( - identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .to_public(), - ) - .expect("set signer identity"); - manager - } - - fn backend_for(manager: &RadrootsNostrSignerManager) -> RadrootsNostrEmbeddedSignerBackend { - RadrootsNostrEmbeddedSignerBackend::new( - manager.clone(), - identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .keys() - .clone(), - ) - .expect("backend") - } - - fn register_connection( - manager: &RadrootsNostrSignerManager, - client_public_key: PublicKey, - ) -> crate::signer::prelude::RadrootsNostrSignerConnectionRecord { - manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - client_public_key, - identity("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - .to_public(), - ) - .with_requested_permissions( - vec![Permission::with_parameter(Method::SignEvent, "kind:1")].into(), - ) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), - ) - .expect("register connection") - } - - fn unsigned_event(kind: u16) -> radroots_nostr_connect::message::UnsignedEvent { - radroots_nostr_connect::message::UnsignedEvent::from_json(&json!({ - "pubkey": public_key("1111111111111111111111111111111111111111111111111111111111111111").to_hex(), - "created_at": 1, - "kind": kind, - "tags": [], - "content": "hello" - }).to_string()) - .expect("unsigned event") - } - - #[test] - fn connect_decision_prefers_deny_then_trust_then_default() { - let config = MycPolicyConfig { - connection_approval: MycConnectionApproval::ExplicitUser, - trusted_client_pubkeys: vec![ - "2222222222222222222222222222222222222222222222222222222222222222".to_owned(), - ], - denied_client_pubkeys: vec![ - "3333333333333333333333333333333333333333333333333333333333333333".to_owned(), - ], - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - - assert_eq!( - policy.connect_decision(&public_key( - "2222222222222222222222222222222222222222222222222222222222222222" - )), - MycConnectDecision::Allow - ); - assert_eq!( - policy.connect_decision(&public_key( - "3333333333333333333333333333333333333333333333333333333333333333" - )), - MycConnectDecision::Deny - ); - assert_eq!( - policy.connect_decision(&public_key( - "4444444444444444444444444444444444444444444444444444444444444444" - )), - MycConnectDecision::RequireApproval - ); - } - - #[test] - fn auto_granted_permissions_apply_policy_ceiling_and_kind_limits() { - let config = MycPolicyConfig { - permission_ceiling: vec![ - Permission::new(Method::Nip04Encrypt), - Permission::with_parameter(Method::SignEvent, "kind:1"), - ] - .into(), - allowed_sign_event_kinds: vec![1], - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - - let requested_permissions: Permissions = vec![ - Permission::new(Method::Nip04Encrypt), - Permission::new(Method::SignEvent), - Permission::with_parameter(Method::SignEvent, "kind:2"), - ] - .into(); - let filtered = policy.auto_granted_permissions(&requested_permissions); - - assert_eq!(filtered.to_string(), "nip04_encrypt,sign_event:kind:1"); - } - - #[test] - fn request_denied_reason_applies_sign_event_kind_limits() { - let config = MycPolicyConfig { - allowed_sign_event_kinds: vec![1], - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - let manager = in_memory_manager(); - let backend = backend_for(&manager); - let connection = register_connection( - &manager, - public_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - ); - - let denied = policy - .prepare_request( - &backend, - &connection, - &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(2))), - ) - .expect("prepare request"); - - assert_eq!( - denied, - Some("request sign_event is outside the configured policy ceiling".to_owned()) - ); - } - - #[test] - fn validate_operator_grants_rejects_out_of_policy_permissions() { - let config = MycPolicyConfig { - permission_ceiling: Permissions::from(vec![Permission::new(Method::Nip04Encrypt)]), - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - - let error = policy - .validate_operator_grants(vec![Permission::new(Method::Nip44Encrypt)].into()) - .expect_err("grant outside ceiling"); - assert!( - error - .to_string() - .contains("granted permissions exceed the configured policy ceiling") - ); - } - - #[test] - fn prepare_request_requires_fresh_auth_after_authorized_ttl() { - let client_public_key = - public_key("2222222222222222222222222222222222222222222222222222222222222222"); - let config = MycPolicyConfig { - trusted_client_pubkeys: vec![client_public_key.to_hex()], - auth_url: Some("https://auth.example".to_owned()), - auth_authorized_ttl_secs: Some(1), - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - let manager = in_memory_manager(); - let backend = backend_for(&manager); - let connection = register_connection(&manager, client_public_key); - - manager - .require_auth_challenge(&connection.connection_id, "https://auth.example") - .expect("require auth challenge"); - manager - .authorize_auth_challenge(&connection.connection_id) - .expect("authorize auth challenge"); - thread::sleep(Duration::from_secs(2)); - - let connection = manager - .get_connection(&connection.connection_id) - .expect("connection lookup") - .expect("connection"); - let denied = policy - .prepare_request( - &backend, - &connection, - &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(1))), - ) - .expect("prepare request"); - - assert_eq!(denied, None); - let updated_connection = manager - .get_connection(&connection.connection_id) - .expect("connection lookup") - .expect("connection"); - assert_eq!( - updated_connection.auth_state, - RadrootsNostrSignerAuthState::Pending - ); - assert_eq!( - updated_connection - .auth_challenge - .expect("auth challenge") - .auth_url, - "https://auth.example/" - ); - } - - #[test] - fn prepare_request_requires_fresh_auth_after_inactivity() { - let client_public_key = - public_key("2323232323232323232323232323232323232323232323232323232323232323"); - let config = MycPolicyConfig { - trusted_client_pubkeys: vec![client_public_key.to_hex()], - auth_url: Some("https://auth.example".to_owned()), - reauth_after_inactivity_secs: Some(1), - ..MycPolicyConfig::default() - }; - let policy = MycPolicyContext::from_config(&config).expect("policy"); - let manager = in_memory_manager(); - let backend = backend_for(&manager); - let connection = register_connection(&manager, client_public_key); - - manager - .require_auth_challenge(&connection.connection_id, "https://auth.example") - .expect("require auth challenge"); - manager - .authorize_auth_challenge(&connection.connection_id) - .expect("authorize auth challenge"); - manager - .record_request( - &connection.connection_id, - "request-0", - Method::SignEvent, - crate::signer::prelude::RadrootsNostrSignerRequestDecision::Allowed, - None, - ) - .expect("record request"); - thread::sleep(Duration::from_secs(2)); - - let connection = manager - .get_connection(&connection.connection_id) - .expect("connection lookup") - .expect("connection"); - let denied = policy - .prepare_request( - &backend, - &connection, - &RequestMessage::new("request-1", Request::SignEvent(unsigned_event(1))), - ) - .expect("prepare request"); - - assert_eq!(denied, None); - let updated_connection = manager - .get_connection(&connection.connection_id) - .expect("connection lookup") - .expect("connection"); - assert_eq!( - updated_connection.auth_state, - RadrootsNostrSignerAuthState::Pending - ); - } -} diff --git a/src/signer/backend.rs b/src/signer/backend.rs @@ -1,1753 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::capability::{ - RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, - RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerCapability, -}; -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerSessionLookup, -}; -use crate::signer::manager::RadrootsNostrSignerManager; -use crate::signer::model::{ - RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestAuditRecord, - RadrootsNostrSignerRequestDecision, RadrootsNostrSignerWorkflowId, -}; -use nostr::{Event, Keys, PublicKey, RelayUrl, UnsignedEvent}; -use radroots_identity::PublicKey as IdentityPublicKey; -use radroots_nostr_connect::{Method, Request, message::RequestMessage, permission::Permissions}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrSignerBackendCapabilities { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_signer: Option<RadrootsNostrLocalSignerCapability>, - #[serde(default)] - pub remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, -} - -/// Result of signing an externally supplied unsigned Nostr event. -/// -/// This low-level protocol result does not establish Radroots typed-authoring -/// validity for the event. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrSignerSignOutput { - pub signer: RadrootsNostrSignerCapability, - pub event: Event, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "state", content = "value")] -pub enum RadrootsNostrSignerPublishTransition { - Begun(RadrootsNostrSignerPublishWorkflowRecord), - MarkedPublished(RadrootsNostrSignerPublishWorkflowRecord), - Finalized { - workflow_id: RadrootsNostrSignerWorkflowId, - connection: Box<RadrootsNostrSignerConnectionRecord>, - }, - Cancelled(RadrootsNostrSignerPublishWorkflowRecord), -} - -pub trait RadrootsNostrSignerBackend: Send + Sync { - fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError>; - - fn set_signer_identity( - &self, - signer_identity: PublicIdentity, - ) -> Result<(), RadrootsNostrSignerError>; - - fn capabilities( - &self, - ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError>; - - fn list_connections( - &self, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>; - - fn get_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>; - - fn list_publish_workflows( - &self, - ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>; - - fn get_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>; - - fn find_connections_by_client_public_key( - &self, - client_public_key: &PublicKey, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>; - - fn find_connection_by_connect_secret( - &self, - connect_secret: &str, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError>; - - fn lookup_session( - &self, - client_public_key: &PublicKey, - connect_secret: Option<&str>, - ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError>; - - fn evaluate_connect_request( - &self, - client_public_key: PublicKey, - request: Request, - ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError>; - - fn register_connection( - &self, - draft: RadrootsNostrSignerConnectionDraft, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn set_granted_permissions( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn approve_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn reject_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn revoke_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn update_relays( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - relays: Vec<RelayUrl>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn require_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - auth_url: &str, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn set_pending_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn authorize_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError>; - - fn restore_pending_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - pending_request: RadrootsNostrSignerPendingRequest, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn begin_connect_secret_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>; - - fn begin_auth_replay_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>; - - fn mark_publish_workflow_published( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>; - - fn finalize_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>; - - fn cancel_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError>; - - fn mark_authenticated( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn mark_connect_secret_consumed( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError>; - - fn evaluate_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>; - - fn evaluate_auth_replay_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>; - - fn record_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_id: &str, - method: Method, - decision: RadrootsNostrSignerRequestDecision, - message: Option<String>, - ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError>; - - /// Signs an externally supplied unsigned Nostr event. - /// - /// This is a low-level interoperability boundary used by generic signer - /// protocols. It does not validate or confer a Radroots product-authoring - /// contract; product events must use their typed authoring boundary. - fn sign_unsigned_event( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError>; -} - -#[derive(Clone)] -pub struct RadrootsNostrEmbeddedSignerBackend { - manager: RadrootsNostrSignerManager, - signer_keys: Keys, - signer_identity: PublicIdentity, -} - -impl RadrootsNostrSignerBackendCapabilities { - pub fn new( - local_signer: Option<RadrootsNostrLocalSignerCapability>, - remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, - ) -> Self { - Self { - local_signer, - remote_sessions, - } - } - - pub fn all_signers(&self) -> Vec<RadrootsNostrSignerCapability> { - let mut signers = Vec::new(); - if let Some(local_signer) = self.local_signer.clone() { - signers.push(RadrootsNostrSignerCapability::LocalAccount(Box::new( - local_signer, - ))); - } - signers.extend( - self.remote_sessions - .iter() - .cloned() - .map(Box::new) - .map(RadrootsNostrSignerCapability::RemoteSession), - ); - signers - } -} - -impl RadrootsNostrSignerSignOutput { - pub fn new(signer: RadrootsNostrSignerCapability, event: Event) -> Self { - Self { signer, event } - } -} - -impl RadrootsNostrSignerPublishTransition { - pub fn begun(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self { - Self::Begun(workflow) - } - - pub fn marked_published(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self { - Self::MarkedPublished(workflow) - } - - pub fn finalized( - workflow_id: RadrootsNostrSignerWorkflowId, - connection: RadrootsNostrSignerConnectionRecord, - ) -> Self { - Self::Finalized { - workflow_id, - connection: Box::new(connection), - } - } - - pub fn cancelled(workflow: RadrootsNostrSignerPublishWorkflowRecord) -> Self { - Self::Cancelled(workflow) - } - - pub fn workflow(&self) -> Option<&RadrootsNostrSignerPublishWorkflowRecord> { - match self { - Self::Begun(workflow) | Self::MarkedPublished(workflow) | Self::Cancelled(workflow) => { - Some(workflow) - } - Self::Finalized { .. } => None, - } - } - - pub fn finalized_connection(&self) -> Option<&RadrootsNostrSignerConnectionRecord> { - match self { - Self::Finalized { connection, .. } => Some(connection.as_ref()), - _ => None, - } - } -} - -impl RadrootsNostrEmbeddedSignerBackend { - pub fn new( - manager: RadrootsNostrSignerManager, - signer_keys: Keys, - ) -> Result<Self, RadrootsNostrSignerError> { - let signer_identity = public_identity_from_keys(&signer_keys)?; - let existing_identity = manager.signer_identity()?; - if let Some(existing_identity) = existing_identity { - if !same_public_identity_key(&existing_identity, &signer_identity) { - return Err(RadrootsNostrSignerError::InvalidState( - "embedded signer identity does not match signer manager identity".into(), - )); - } - } else { - manager.set_signer_identity(signer_identity.clone())?; - } - - Ok(Self { - manager, - signer_keys, - signer_identity, - }) - } - - pub fn new_in_memory(signer_keys: Keys) -> Result<Self, RadrootsNostrSignerError> { - Self::new(RadrootsNostrSignerManager::new_in_memory(), signer_keys) - } - - pub fn manager(&self) -> &RadrootsNostrSignerManager { - &self.manager - } - - pub fn local_keys(&self) -> &Keys { - &self.signer_keys - } - - fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability { - let public_identity = self.signer_identity.clone(); - RadrootsNostrLocalSignerCapability::new( - public_identity.id.to_final().into(), - public_identity, - RadrootsNostrLocalSignerAvailability::SecretBacked, - ) - } -} - -impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend { - fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { - self.manager.signer_identity() - } - - fn set_signer_identity( - &self, - signer_identity: PublicIdentity, - ) -> Result<(), RadrootsNostrSignerError> { - self.manager.set_signer_identity(signer_identity) - } - - fn capabilities( - &self, - ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> { - let mut remote_sessions = Vec::new(); - for record in self.manager.list_connections()? { - if record.status == RadrootsNostrSignerConnectionStatus::Active { - remote_sessions.push(RadrootsNostrRemoteSessionSignerCapability::from(&record)); - } - } - Ok(RadrootsNostrSignerBackendCapabilities::new( - Some(self.local_signer_capability()), - remote_sessions, - )) - } - - fn list_connections( - &self, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager.list_connections() - } - - fn get_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager.get_connection(connection_id) - } - - fn list_publish_workflows( - &self, - ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - self.manager.list_publish_workflows() - } - - fn get_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - self.manager.get_publish_workflow(workflow_id) - } - - fn find_connections_by_client_public_key( - &self, - client_public_key: &PublicKey, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager - .find_connections_by_client_public_key(client_public_key) - } - - fn find_connection_by_connect_secret( - &self, - connect_secret: &str, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - self.manager - .find_connection_by_connect_secret(connect_secret) - } - - fn lookup_session( - &self, - client_public_key: &PublicKey, - connect_secret: Option<&str>, - ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { - self.manager - .lookup_session(client_public_key, connect_secret) - } - - fn evaluate_connect_request( - &self, - client_public_key: PublicKey, - request: Request, - ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { - self.manager - .evaluate_connect_request(client_public_key, request) - } - - fn register_connection( - &self, - draft: RadrootsNostrSignerConnectionDraft, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.register_connection(draft) - } - - fn set_granted_permissions( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager - .set_granted_permissions(connection_id, granted_permissions) - } - - fn approve_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager - .approve_connection(connection_id, granted_permissions) - } - - fn reject_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.reject_connection(connection_id, reason) - } - - fn revoke_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.revoke_connection(connection_id, reason) - } - - fn update_relays( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - relays: Vec<RelayUrl>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.update_relays(connection_id, relays) - } - - fn require_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - auth_url: &str, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.require_auth_challenge(connection_id, auth_url) - } - - fn set_pending_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager - .set_pending_request(connection_id, request_message) - } - - fn authorize_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> { - self.manager.authorize_auth_challenge(connection_id) - } - - fn restore_pending_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - pending_request: RadrootsNostrSignerPendingRequest, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager - .restore_pending_auth_challenge(connection_id, pending_request) - } - - fn begin_connect_secret_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let workflow = self - .manager - .begin_connect_secret_publish_finalization(connection_id)?; - Ok(RadrootsNostrSignerPublishTransition::begun(workflow)) - } - - fn begin_auth_replay_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let workflow = self - .manager - .begin_auth_replay_publish_finalization(connection_id)?; - Ok(RadrootsNostrSignerPublishTransition::begun(workflow)) - } - - fn mark_publish_workflow_published( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let workflow = self.manager.mark_publish_workflow_published(workflow_id)?; - Ok(RadrootsNostrSignerPublishTransition::marked_published( - workflow, - )) - } - - fn finalize_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let connection = self.manager.finalize_publish_workflow(workflow_id)?; - Ok(RadrootsNostrSignerPublishTransition::finalized( - workflow_id.clone(), - connection, - )) - } - - fn cancel_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - let workflow = self.manager.cancel_publish_workflow(workflow_id)?; - Ok(RadrootsNostrSignerPublishTransition::cancelled(workflow)) - } - - fn mark_authenticated( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.mark_authenticated(connection_id) - } - - fn mark_connect_secret_consumed( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.manager.mark_connect_secret_consumed(connection_id) - } - - fn evaluate_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - self.manager - .evaluate_request(connection_id, request_message) - } - - fn evaluate_auth_replay_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - self.manager - .evaluate_auth_replay_publish_workflow(workflow_id) - } - - fn record_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_id: &str, - method: Method, - decision: RadrootsNostrSignerRequestDecision, - message: Option<String>, - ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { - self.manager - .record_request(connection_id, request_id, method, decision, message) - } - - fn sign_unsigned_event( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { - let event = unsigned_event.sign_with_keys(&self.signer_keys)?; - Ok(RadrootsNostrSignerSignOutput::new( - RadrootsNostrSignerCapability::LocalAccount(Box::new(self.local_signer_capability())), - event, - )) - } -} - -fn same_public_identity_key(left: &PublicIdentity, right: &PublicIdentity) -> bool { - left.id() == right.id() && left.public_key() == right.public_key() -} - -fn public_identity_from_keys(keys: &Keys) -> Result<PublicIdentity, RadrootsNostrSignerError> { - let public_key = IdentityPublicKey::from_hex(&keys.public_key().to_hex()).map_err(|error| { - RadrootsNostrSignerError::InvalidState(format!( - "embedded signer public key is invalid: {error}" - )) - })?; - PublicIdentity::from_final_public_key(public_key).map_err(|error| { - RadrootsNostrSignerError::InvalidState(format!( - "embedded signer public key is invalid: {error}" - )) - }) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend, - RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition, - same_public_identity_key, - }; - use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; - use crate::signer::error::RadrootsNostrSignerError; - use crate::signer::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, - RadrootsNostrSignerRequestAction, RadrootsNostrSignerSessionLookup, - }; - use crate::signer::manager::RadrootsNostrSignerManager; - use crate::signer::model::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId, - }; - use crate::signer::store::RadrootsNostrSignerStore; - use crate::signer::test_support::{ - fixture_bob_identity, primary_relay, secondary_relay, synthetic_keys, - synthetic_public_identity, synthetic_public_key, - }; - use nostr::{EventBuilder, EventId, Keys, Kind}; - use radroots_nostr_connect::{Method, Permission, Request, message::RequestMessage}; - use std::panic::{AssertUnwindSafe, catch_unwind}; - use std::sync::Arc; - use std::sync::RwLock; - use std::sync::atomic::{AtomicU8, Ordering}; - - fn embedded_identity(index: u32) -> Keys { - synthetic_keys(index) - } - - fn embedded_public_identity(keys: &Keys) -> PublicIdentity { - PublicIdentity::new(keys.public_key()).expect("identity public key") - } - - fn expect_registration_required( - evaluation: RadrootsNostrSignerConnectEvaluation, - ) -> RadrootsNostrSignerConnectProposal { - match evaluation { - RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal, - other => panic!("unexpected connect evaluation: {other:?}"), - } - } - - fn expect_lookup_connection( - lookup: RadrootsNostrSignerSessionLookup, - ) -> RadrootsNostrSignerConnectionRecord { - match lookup { - RadrootsNostrSignerSessionLookup::Connection(found) => *found, - other => panic!("unexpected session lookup: {other:?}"), - } - } - - fn expect_begun_workflow_id( - transition: RadrootsNostrSignerPublishTransition, - ) -> RadrootsNostrSignerWorkflowId { - match transition { - RadrootsNostrSignerPublishTransition::Begun(workflow) => workflow.workflow_id, - other => panic!("unexpected begin transition: {other:?}"), - } - } - - fn expect_finalized_transition( - transition: RadrootsNostrSignerPublishTransition, - ) -> ( - RadrootsNostrSignerWorkflowId, - RadrootsNostrSignerConnectionRecord, - ) { - match transition { - RadrootsNostrSignerPublishTransition::Finalized { - workflow_id, - connection, - } => (workflow_id, *connection), - other => panic!("unexpected finalize transition: {other:?}"), - } - } - - struct StubBackend { - signer_identity: Option<PublicIdentity>, - signer_identity_error: Option<&'static str>, - sign_error_message: Option<&'static str>, - } - - #[derive(Default)] - struct ToggleSaveStore { - state: RwLock<RadrootsNostrSignerStoreState>, - mode: AtomicU8, - } - - impl ToggleSaveStore { - fn set_mode(&self, mode: u8) { - self.mode.store(mode, Ordering::SeqCst); - } - } - - impl RadrootsNostrSignerStore for ToggleSaveStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - let guard = self.state.read().map_err(|_| { - RadrootsNostrSignerError::Store("toggle store lock poisoned".into()) - })?; - Ok(guard.clone()) - } - - fn save( - &self, - state: &RadrootsNostrSignerStoreState, - ) -> Result<(), RadrootsNostrSignerError> { - match self.mode.load(Ordering::SeqCst) { - 1 => Err(RadrootsNostrSignerError::Store("save failed".into())), - 2 => panic!("toggle save panic"), - _ => { - let mut guard = self.state.write().map_err(|_| { - RadrootsNostrSignerError::Store("toggle store lock poisoned".into()) - })?; - *guard = state.clone(); - Ok(()) - } - } - } - } - - impl RadrootsNostrSignerBackend for StubBackend { - fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { - if let Some(message) = self.signer_identity_error { - return Err(RadrootsNostrSignerError::InvalidState(message.into())); - } - Ok(self.signer_identity.clone()) - } - - fn set_signer_identity( - &self, - _signer_identity: PublicIdentity, - ) -> Result<(), RadrootsNostrSignerError> { - unreachable!("set_signer_identity not used in tests") - } - - fn capabilities( - &self, - ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> { - unreachable!("capabilities not used in tests") - } - - fn list_connections( - &self, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - unreachable!("list_connections not used in tests") - } - - fn get_connection( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - unreachable!("get_connection not used in tests") - } - - fn list_publish_workflows( - &self, - ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> - { - unreachable!("list_publish_workflows not used in tests") - } - - fn get_publish_workflow( - &self, - _workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> - { - unreachable!("get_publish_workflow not used in tests") - } - - fn find_connections_by_client_public_key( - &self, - _client_public_key: &nostr::PublicKey, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - unreachable!("find_connections_by_client_public_key not used in tests") - } - - fn find_connection_by_connect_secret( - &self, - _connect_secret: &str, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - unreachable!("find_connection_by_connect_secret not used in tests") - } - - fn lookup_session( - &self, - _client_public_key: &nostr::PublicKey, - _connect_secret: Option<&str>, - ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { - unreachable!("lookup_session not used in tests") - } - - fn evaluate_connect_request( - &self, - _client_public_key: nostr::PublicKey, - _request: Request, - ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { - unreachable!("evaluate_connect_request not used in tests") - } - - fn register_connection( - &self, - _draft: RadrootsNostrSignerConnectionDraft, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("register_connection not used in tests") - } - - fn set_granted_permissions( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _granted_permissions: radroots_nostr_connect::permission::Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("set_granted_permissions not used in tests") - } - - fn approve_connection( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _granted_permissions: radroots_nostr_connect::permission::Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("approve_connection not used in tests") - } - - fn reject_connection( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("reject_connection not used in tests") - } - - fn revoke_connection( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("revoke_connection not used in tests") - } - - fn update_relays( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _relays: Vec<nostr::RelayUrl>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("update_relays not used in tests") - } - - fn require_auth_challenge( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _auth_url: &str, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("require_auth_challenge not used in tests") - } - - fn set_pending_request( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("set_pending_request not used in tests") - } - - fn authorize_auth_challenge( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result< - crate::signer::model::RadrootsNostrSignerAuthorizationOutcome, - RadrootsNostrSignerError, - > { - unreachable!("authorize_auth_challenge not used in tests") - } - - fn restore_pending_auth_challenge( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _pending_request: crate::signer::model::RadrootsNostrSignerPendingRequest, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("restore_pending_auth_challenge not used in tests") - } - - fn begin_connect_secret_publish_finalization( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - unreachable!("begin_connect_secret_publish_finalization not used in tests") - } - - fn begin_auth_replay_publish_finalization( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - unreachable!("begin_auth_replay_publish_finalization not used in tests") - } - - fn mark_publish_workflow_published( - &self, - _workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - unreachable!("mark_publish_workflow_published not used in tests") - } - - fn finalize_publish_workflow( - &self, - _workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - unreachable!("finalize_publish_workflow not used in tests") - } - - fn cancel_publish_workflow( - &self, - _workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { - unreachable!("cancel_publish_workflow not used in tests") - } - - fn mark_authenticated( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("mark_authenticated not used in tests") - } - - fn mark_connect_secret_consumed( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - unreachable!("mark_connect_secret_consumed not used in tests") - } - - fn evaluate_request( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _request_message: RequestMessage, - ) -> Result< - crate::signer::evaluation::RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerError, - > { - unreachable!("evaluate_request not used in tests") - } - - fn evaluate_auth_replay_publish_workflow( - &self, - _workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result< - crate::signer::evaluation::RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerError, - > { - unreachable!("evaluate_auth_replay_publish_workflow not used in tests") - } - - fn record_request( - &self, - _connection_id: &crate::signer::model::RadrootsNostrSignerConnectionId, - _request_id: &str, - _method: Method, - _decision: RadrootsNostrSignerRequestDecision, - _message: Option<String>, - ) -> Result< - crate::signer::model::RadrootsNostrSignerRequestAuditRecord, - RadrootsNostrSignerError, - > { - unreachable!("record_request not used in tests") - } - - fn sign_unsigned_event( - &self, - _unsigned_event: nostr::UnsignedEvent, - ) -> Result<super::RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { - match self.sign_error_message { - Some(message) => Err(RadrootsNostrSignerError::InvalidState(message.into())), - None => unreachable!("sign_unsigned_event success path not used in tests"), - } - } - } - - #[test] - fn embedded_backend_bootstraps_signer_identity_and_capabilities() { - let identity = embedded_identity(0x90); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - - let signer_identity = backend - .signer_identity() - .expect("signer identity") - .expect("present"); - assert_eq!(signer_identity, embedded_public_identity(&identity)); - - let capabilities = backend.capabilities().expect("capabilities"); - let local = capabilities.local_signer.clone().expect("local signer"); - assert_eq!(local.public_identity, embedded_public_identity(&identity)); - assert!(local.is_secret_backed()); - assert!(capabilities.remote_sessions.is_empty()); - assert_eq!(capabilities.all_signers().len(), 1); - let manager_identity = backend - .manager() - .signer_identity() - .expect("manager signer identity") - .expect("stored signer identity"); - assert!(same_public_identity_key( - &manager_identity, - &embedded_public_identity(&identity) - )); - assert_eq!(backend.local_keys().public_key(), identity.public_key()); - } - - #[test] - fn embedded_backend_rejects_mismatched_manager_identity() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(fixture_bob_identity()) - .expect("set signer identity"); - - let error = match RadrootsNostrEmbeddedSignerBackend::new(manager, embedded_identity(0x91)) - { - Ok(_) => panic!("mismatched identity"), - Err(error) => error, - }; - assert!( - error - .to_string() - .contains("embedded signer identity does not match") - ); - } - - #[test] - fn embedded_backend_accepts_matching_manager_identity_and_setter_delegate() { - let identity = embedded_identity(0x97); - let manager = RadrootsNostrSignerManager::new_in_memory(); - let public_identity = embedded_public_identity(&identity); - manager - .set_signer_identity(public_identity.clone()) - .expect("prime manager identity"); - - let backend = RadrootsNostrEmbeddedSignerBackend::new(manager, identity.clone()) - .expect("matching embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - assert_eq!(backend.local_keys().public_key(), identity.public_key()); - assert!(same_public_identity_key( - backend_trait - .signer_identity() - .expect("signer identity") - .as_ref() - .expect("present"), - &public_identity - )); - - backend_trait - .set_signer_identity(public_identity.clone()) - .expect("delegate set signer identity"); - let manager_identity = backend - .manager() - .signer_identity() - .expect("manager signer identity") - .expect("stored signer identity"); - assert!(same_public_identity_key( - &manager_identity, - &public_identity - )); - } - - #[test] - fn backend_source_does_not_accept_raw_event_builders() { - let production_source = include_str!("backend.rs") - .split("\n#[cfg(test)]") - .next() - .expect("production backend source"); - - assert!(!production_source.contains(concat!("fn sign_event_", "builder"))); - } - - #[test] - fn external_unsigned_signing_propagates_backend_errors() { - let backend = StubBackend { - signer_identity: None, - signer_identity_error: None, - sign_error_message: Some("stub interop signing failure"), - }; - let unsigned_event = - EventBuilder::new(Kind::TextNote, "external interop").build(synthetic_public_key(0xaa)); - - let error = backend - .sign_unsigned_event(unsigned_event) - .expect_err("external unsigned signing failure"); - - assert!(error.to_string().contains("stub interop signing failure")); - } - - #[test] - fn capabilities_only_include_active_remote_sessions() { - let identity = embedded_identity(0xac); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - let active = backend_trait - .register_connection(RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xad), - synthetic_public_identity(0xae), - )) - .expect("register active"); - - let pending = backend_trait - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xaf), - synthetic_public_identity(0xb0), - ) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - ) - .expect("register pending"); - let rejected = backend_trait - .register_connection(RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xb1), - synthetic_public_identity(0xb2), - )) - .expect("register rejected"); - backend_trait - .reject_connection(&rejected.connection_id, Some("rejected".into())) - .expect("reject connection"); - - let capabilities = backend_trait.capabilities().expect("capabilities"); - assert_eq!(capabilities.remote_sessions.len(), 1); - assert_eq!( - capabilities.remote_sessions[0].connection_id, - active.connection_id - ); - assert_ne!( - capabilities.remote_sessions[0].connection_id, - pending.connection_id - ); - } - - #[test] - fn embedded_backend_propagates_missing_publish_targets() { - let identity = embedded_identity(0xb3); - let backend = - RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity).expect("embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - let missing_connection_id = - crate::signer::model::RadrootsNostrSignerConnectionId::parse("conn-backend-missing") - .expect("connection id"); - let missing_workflow_id = - RadrootsNostrSignerWorkflowId::parse("wf-backend-missing").expect("workflow id"); - - assert!( - backend_trait - .begin_connect_secret_publish_finalization(&missing_connection_id) - .expect_err("missing connect workflow") - .to_string() - .contains("connection not found") - ); - assert!( - backend_trait - .begin_auth_replay_publish_finalization(&missing_connection_id) - .expect_err("missing auth workflow") - .to_string() - .contains("connection not found") - ); - assert!( - backend_trait - .mark_publish_workflow_published(&missing_workflow_id) - .expect_err("missing published workflow") - .to_string() - .contains("publish workflow not found") - ); - assert!( - backend_trait - .finalize_publish_workflow(&missing_workflow_id) - .expect_err("missing finalized workflow") - .to_string() - .contains("publish workflow not found") - ); - assert!( - backend_trait - .cancel_publish_workflow(&missing_workflow_id) - .expect_err("missing cancelled workflow") - .to_string() - .contains("publish workflow not found") - ); - } - - #[test] - fn embedded_backend_reports_manager_read_and_save_failures() { - let save_fail_store = Arc::new(ToggleSaveStore::default()); - save_fail_store.set_mode(1); - let save_fail_manager = - RadrootsNostrSignerManager::new(save_fail_store).expect("save-fail manager"); - let err = match RadrootsNostrEmbeddedSignerBackend::new( - save_fail_manager, - embedded_identity(0xb4), - ) { - Ok(_) => panic!("expected save failure"), - Err(err) => err, - }; - assert!(err.to_string().contains("save failed")); - - let poisoned_store = Arc::new(ToggleSaveStore::default()); - let poisoned_manager = - RadrootsNostrSignerManager::new(poisoned_store.clone()).expect("poison manager"); - let backend = RadrootsNostrEmbeddedSignerBackend::new( - poisoned_manager.clone(), - embedded_identity(0xb5), - ) - .expect("embedded backend"); - poisoned_store.set_mode(2); - assert!( - catch_unwind(AssertUnwindSafe(|| { - let _ = backend - .manager() - .set_signer_identity(fixture_bob_identity()); - })) - .is_err() - ); - - let err = backend.capabilities().expect_err("poisoned capabilities"); - assert!(err.to_string().contains("signer state lock poisoned")); - - let err = match RadrootsNostrEmbeddedSignerBackend::new( - poisoned_manager, - embedded_identity(0xb5), - ) { - Ok(_) => panic!("expected poisoned new failure"), - Err(err) => err, - }; - assert!(err.to_string().contains("signer state lock poisoned")); - } - - #[test] - fn embedded_backend_sign_unsigned_event_rejects_invalid_precomputed_id() { - let identity = embedded_identity(0xb6); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - let mut unsigned_event = - EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key()); - unsigned_event.id = Some(EventId::all_zeros()); - let err = backend_trait - .sign_unsigned_event(unsigned_event) - .expect_err("invalid precomputed id"); - assert!(err.to_string().starts_with("sign error:")); - } - - #[test] - fn embedded_backend_trait_delegates_connect_and_publish_workflow_methods() { - let identity = embedded_identity(0x92); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let backend: &dyn RadrootsNostrSignerBackend = &backend; - - let evaluation = backend - .evaluate_connect_request( - synthetic_public_key(0x93), - Request::Connect { - remote_signer_public_key: embedded_public_identity(&identity).public_key(), - secret: Some("connect-secret".into()), - requested_permissions: vec![Permission::new(Method::Ping)].into(), - client_metadata: None, - }, - ) - .expect("connect evaluation"); - let proposal = expect_registration_required(evaluation); - let connection = backend - .register_connection( - proposal - .into_connection_draft(synthetic_public_identity(0x94)) - .with_relays(vec![primary_relay()]), - ) - .expect("register connection"); - - let capabilities = backend.capabilities().expect("capabilities"); - assert_eq!(capabilities.remote_sessions.len(), 1); - - let begun = backend - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin workflow"); - let workflow_id = expect_begun_workflow_id(begun.clone()); - assert_eq!( - begun.workflow().expect("begun workflow").connection_id, - connection.connection_id - ); - - let published = backend - .mark_publish_workflow_published(&workflow_id) - .expect("mark published"); - assert!(matches!( - published, - RadrootsNostrSignerPublishTransition::MarkedPublished(_) - )); - - let finalized = backend - .finalize_publish_workflow(&workflow_id) - .expect("finalize workflow"); - let (finalized_workflow_id, finalized_connection) = expect_finalized_transition(finalized); - assert_eq!(finalized_workflow_id, workflow_id); - assert!(finalized_connection.connect_secret_is_consumed()); - - let audit = backend - .record_request( - &connection.connection_id, - "req-1", - Method::Ping, - RadrootsNostrSignerRequestDecision::Allowed, - None, - ) - .expect("record request"); - assert_eq!(audit.method, Method::Ping); - } - - #[test] - fn embedded_backend_delegates_lookup_state_and_auth_workflow_methods() { - let identity = embedded_identity(0xa0); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - let connect_evaluation = backend_trait - .evaluate_connect_request( - synthetic_public_key(0xa1), - Request::Connect { - remote_signer_public_key: embedded_public_identity(&identity).public_key(), - secret: Some("connect-secret-2".into()), - requested_permissions: vec![Permission::new(Method::Ping)].into(), - client_metadata: None, - }, - ) - .expect("connect evaluation"); - let connect_proposal = expect_registration_required(connect_evaluation); - let connection = backend_trait - .register_connection( - connect_proposal - .into_connection_draft(synthetic_public_identity(0xa2)) - .with_relays(vec![primary_relay()]), - ) - .expect("register connect-secret connection"); - - assert_eq!(backend_trait.list_connections().expect("list").len(), 1); - assert_eq!( - backend_trait - .get_connection(&connection.connection_id) - .expect("get connection") - .expect("stored connection") - .connection_id, - connection.connection_id - ); - assert_eq!( - backend_trait - .find_connections_by_client_public_key(&connection.client_public_key) - .expect("find by client key") - .len(), - 1 - ); - assert_eq!( - backend_trait - .find_connection_by_connect_secret("connect-secret-2") - .expect("find by secret") - .expect("stored by secret") - .connection_id, - connection.connection_id - ); - let looked_up = expect_lookup_connection( - backend_trait - .lookup_session(&connection.client_public_key, Some("connect-secret-2")) - .expect("lookup session"), - ); - assert_eq!(looked_up.connection_id, connection.connection_id); - - let with_relays = backend_trait - .update_relays( - &connection.connection_id, - vec![primary_relay(), secondary_relay()], - ) - .expect("update relays"); - assert_eq!(with_relays.relays.len(), 2); - - let evaluation = backend_trait - .evaluate_request( - &connection.connection_id, - RequestMessage::new("req-ping", Request::Ping), - ) - .expect("evaluate request"); - assert!(matches!( - evaluation.action, - RadrootsNostrSignerRequestAction::Allowed { .. } - )); - - let authenticated = backend_trait - .mark_authenticated(&connection.connection_id) - .expect("mark authenticated"); - assert!(authenticated.last_authenticated_at_unix.is_some()); - - let pending_connection = backend_trait - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xab), - synthetic_public_identity(0xac), - ) - .with_requested_permissions(vec![Permission::new(Method::Ping)].into()) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - ) - .expect("register pending connection"); - let granted_permissions: radroots_nostr_connect::permission::Permissions = - vec![Permission::new(Method::Ping)].into(); - let granted = backend_trait - .set_granted_permissions( - &pending_connection.connection_id, - granted_permissions.clone(), - ) - .expect("set granted permissions"); - assert_eq!(granted.connection_id, pending_connection.connection_id); - - let approved = backend_trait - .approve_connection(&pending_connection.connection_id, granted_permissions) - .expect("approve connection"); - assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active); - - let begun = backend_trait - .begin_connect_secret_publish_finalization(&connection.connection_id) - .expect("begin connect workflow"); - let workflow = begun.workflow().expect("begun workflow").clone(); - assert!(begun.finalized_connection().is_none()); - assert_eq!( - backend_trait - .list_publish_workflows() - .expect("list publish workflows") - .len(), - 1 - ); - assert_eq!( - backend_trait - .get_publish_workflow(&workflow.workflow_id) - .expect("get publish workflow") - .expect("stored workflow") - .workflow_id, - workflow.workflow_id - ); - - let published = backend_trait - .mark_publish_workflow_published(&workflow.workflow_id) - .expect("mark publish workflow"); - assert_eq!( - published - .workflow() - .expect("published workflow") - .workflow_id, - workflow.workflow_id - ); - - let finalized = backend_trait - .finalize_publish_workflow(&workflow.workflow_id) - .expect("finalize workflow"); - assert!(finalized.workflow().is_none()); - assert_eq!( - finalized - .finalized_connection() - .expect("finalized connection") - .connection_id, - connection.connection_id - ); - - let audit = backend_trait - .record_request( - &connection.connection_id, - "req-audit", - Method::Ping, - RadrootsNostrSignerRequestDecision::Allowed, - None, - ) - .expect("record request"); - assert_eq!(audit.connection_id, connection.connection_id); - - let consumed_connection = backend_trait - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xa3), - synthetic_public_identity(0xa4), - ) - .with_connect_secret("manual-secret"), - ) - .expect("register consumed connection"); - let consumed = backend_trait - .mark_connect_secret_consumed(&consumed_connection.connection_id) - .expect("mark connect secret consumed"); - assert!(consumed.connect_secret_is_consumed()); - - let rejected = backend_trait - .register_connection(RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xa5), - synthetic_public_identity(0xa6), - )) - .expect("register rejected connection"); - let rejected = backend_trait - .reject_connection(&rejected.connection_id, Some("rejected".into())) - .expect("reject connection"); - assert_eq!( - rejected.status, - RadrootsNostrSignerConnectionStatus::Rejected - ); - - let auth_connection = backend_trait - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xa7), - synthetic_public_identity(0xa8), - ) - .with_requested_permissions(vec![Permission::new(Method::Ping)].into()), - ) - .expect("register auth connection"); - backend_trait - .require_auth_challenge( - &auth_connection.connection_id, - "https://api.example.com/auth", - ) - .expect("require auth challenge"); - let pending = backend_trait - .set_pending_request( - &auth_connection.connection_id, - RequestMessage::new("req-auth-replay", Request::Ping), - ) - .expect("set pending request"); - assert!(pending.pending_request.is_some()); - let authorized = backend_trait - .authorize_auth_challenge(&auth_connection.connection_id) - .expect("authorize auth challenge"); - let pending_request = authorized.pending_request.expect("pending request"); - let restored = backend_trait - .restore_pending_auth_challenge(&auth_connection.connection_id, pending_request.clone()) - .expect("restore pending auth challenge"); - assert_eq!(restored.pending_request.as_ref(), Some(&pending_request)); - - let auth_workflow = backend_trait - .begin_auth_replay_publish_finalization(&auth_connection.connection_id) - .expect("begin auth replay") - .workflow() - .expect("auth replay workflow") - .clone(); - let replay_evaluation = backend_trait - .evaluate_auth_replay_publish_workflow(&auth_workflow.workflow_id) - .expect("evaluate auth replay workflow"); - assert_eq!( - replay_evaluation.connection.connection_id, - auth_connection.connection_id - ); - let cancelled = backend_trait - .cancel_publish_workflow(&auth_workflow.workflow_id) - .expect("cancel auth workflow"); - assert_eq!( - cancelled - .workflow() - .expect("cancelled workflow") - .workflow_id, - auth_workflow.workflow_id - ); - - let revoked = backend_trait - .revoke_connection(&auth_connection.connection_id, Some("revoked".into())) - .expect("revoke connection"); - assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked); - } - - #[test] - fn embedded_backend_signs_external_unsigned_event_with_local_capability() { - let identity = embedded_identity(0x95); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let output = - <RadrootsNostrEmbeddedSignerBackend as RadrootsNostrSignerBackend>::sign_unsigned_event( - &backend, - EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key()), - ) - .expect("sign external unsigned event"); - - assert_eq!(output.event.pubkey, identity.public_key()); - let local = output.signer.local_account().expect("local signer"); - assert_eq!(local.public_identity, embedded_public_identity(&identity)); - assert!(local.is_secret_backed()); - } - - #[test] - fn embedded_backend_can_prepare_and_cancel_auth_replay_workflow() { - let identity = embedded_identity(0x96); - let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) - .expect("embedded backend"); - let backend: &dyn RadrootsNostrSignerBackend = &backend; - - let connection = backend - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0x97), - synthetic_public_identity(0x98), - ) - .with_requested_permissions(vec![Permission::new(Method::Ping)].into()), - ) - .expect("register connection"); - backend - .require_auth_challenge(&connection.connection_id, "https://api.example.com/auth") - .expect("require auth"); - backend - .set_pending_request( - &connection.connection_id, - RequestMessage::new("req-auth", Request::Ping), - ) - .expect("set pending request"); - - let begun = backend - .begin_auth_replay_publish_finalization(&connection.connection_id) - .expect("begin auth replay"); - let workflow_id = begun - .workflow() - .expect("begun auth replay workflow") - .workflow_id - .clone(); - - let cancelled = backend - .cancel_publish_workflow(&workflow_id) - .expect("cancel workflow"); - assert!(matches!( - cancelled, - RadrootsNostrSignerPublishTransition::Cancelled(_) - )); - } - - #[test] - fn backend_capabilities_all_signers_supports_remote_only_and_identity_comparison() { - let remote = crate::signer::capability::RadrootsNostrRemoteSessionSignerCapability::new( - crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(), - synthetic_public_identity(0xb0), - synthetic_public_identity(0xb1), - ); - let capabilities = RadrootsNostrSignerBackendCapabilities::new(None, vec![remote.clone()]); - - assert_eq!( - capabilities.all_signers(), - vec![ - crate::signer::capability::RadrootsNostrSignerCapability::RemoteSession(Box::new( - remote, - )) - ] - ); - - let valid_identity = synthetic_public_identity(0xb2); - assert!(same_public_identity_key(&valid_identity, &valid_identity)); - let valid_identity_with_different_hex = synthetic_public_identity(0xb3); - assert!(!same_public_identity_key( - &valid_identity, - &valid_identity_with_different_hex - )); - } - - #[test] - fn backend_test_helpers_reject_unexpected_variants() { - let connection = RadrootsNostrSignerConnectionRecord::new( - crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(), - synthetic_public_identity(0xb4), - RadrootsNostrSignerConnectionDraft::new( - synthetic_public_key(0xb5), - synthetic_public_identity(0xb6), - ), - 1, - ); - let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( - connection.connection_id.clone(), - 1, - ); - - assert!( - std::panic::catch_unwind(|| { - expect_registration_required( - RadrootsNostrSignerConnectEvaluation::ExistingConnection(Box::new( - connection.clone(), - )), - ) - }) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| { - expect_lookup_connection(RadrootsNostrSignerSessionLookup::None) - }) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| { - expect_begun_workflow_id(RadrootsNostrSignerPublishTransition::cancelled( - workflow.clone(), - )) - }) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| { - expect_finalized_transition(RadrootsNostrSignerPublishTransition::begun(workflow)) - }) - .is_err() - ); - } -} diff --git a/src/signer/capability.rs b/src/signer/capability.rs @@ -1,330 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::model::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord}; -use nostr::RelayUrl; -use radroots_identity::AccountId; -use radroots_nostr_connect::permission::Permissions; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrLocalSignerAvailability { - PublicOnly, - SecretBacked, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrLocalSignerCapability { - pub account_id: AccountId, - pub public_identity: PublicIdentity, - pub availability: RadrootsNostrLocalSignerAvailability, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrRemoteSessionSignerCapability { - pub connection_id: RadrootsNostrSignerConnectionId, - pub signer_identity: PublicIdentity, - pub user_identity: PublicIdentity, - pub relays: Vec<RelayUrl>, - pub permissions: Permissions, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RadrootsNostrSignerCapability { - LocalAccount(Box<RadrootsNostrLocalSignerCapability>), - RemoteSession(Box<RadrootsNostrRemoteSessionSignerCapability>), -} - -fn public_identity_eq(left: &PublicIdentity, right: &PublicIdentity) -> bool { - left == right -} - -impl RadrootsNostrLocalSignerCapability { - pub fn new( - account_id: AccountId, - public_identity: PublicIdentity, - availability: RadrootsNostrLocalSignerAvailability, - ) -> Self { - Self { - account_id, - public_identity, - availability, - } - } - - pub fn is_secret_backed(&self) -> bool { - self.availability == RadrootsNostrLocalSignerAvailability::SecretBacked - } -} - -impl RadrootsNostrRemoteSessionSignerCapability { - pub fn new( - connection_id: RadrootsNostrSignerConnectionId, - signer_identity: PublicIdentity, - user_identity: PublicIdentity, - ) -> Self { - Self { - connection_id, - signer_identity, - user_identity, - relays: Vec::new(), - permissions: Permissions::default(), - } - } - - pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self { - self.relays = relays; - self - } - - pub fn with_permissions(mut self, permissions: Permissions) -> Self { - self.permissions = permissions; - self - } -} - -impl RadrootsNostrSignerCapability { - pub fn public_identity(&self) -> &PublicIdentity { - match self { - Self::LocalAccount(capability) => &capability.public_identity, - Self::RemoteSession(capability) => &capability.user_identity, - } - } - - pub fn local_account(&self) -> Option<&RadrootsNostrLocalSignerCapability> { - match self { - Self::LocalAccount(capability) => Some(capability.as_ref()), - Self::RemoteSession(_) => None, - } - } - - pub fn remote_session(&self) -> Option<&RadrootsNostrRemoteSessionSignerCapability> { - match self { - Self::RemoteSession(capability) => Some(capability.as_ref()), - Self::LocalAccount(_) => None, - } - } -} - -impl PartialEq for RadrootsNostrLocalSignerCapability { - fn eq(&self, other: &Self) -> bool { - self.account_id == other.account_id - && self.availability == other.availability - && public_identity_eq(&self.public_identity, &other.public_identity) - } -} - -impl Eq for RadrootsNostrLocalSignerCapability {} - -impl PartialEq for RadrootsNostrRemoteSessionSignerCapability { - fn eq(&self, other: &Self) -> bool { - self.connection_id == other.connection_id - && self.relays == other.relays - && self.permissions == other.permissions - && public_identity_eq(&self.signer_identity, &other.signer_identity) - && public_identity_eq(&self.user_identity, &other.user_identity) - } -} - -impl Eq for RadrootsNostrRemoteSessionSignerCapability {} - -impl PartialEq for RadrootsNostrSignerCapability { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::LocalAccount(left), Self::LocalAccount(right)) => { - left.as_ref() == right.as_ref() - } - (Self::RemoteSession(left), Self::RemoteSession(right)) => { - left.as_ref() == right.as_ref() - } - _ => false, - } - } -} - -impl Eq for RadrootsNostrSignerCapability {} - -impl From<&RadrootsNostrSignerConnectionRecord> for RadrootsNostrRemoteSessionSignerCapability { - fn from(value: &RadrootsNostrSignerConnectionRecord) -> Self { - Self { - connection_id: value.connection_id.clone(), - signer_identity: value.signer_identity.clone(), - user_identity: value.user_identity.clone(), - relays: value.relays.clone(), - permissions: value.effective_permissions(), - } - } -} - -impl RadrootsNostrSignerConnectionRecord { - pub fn remote_session_capability(&self) -> RadrootsNostrSignerCapability { - RadrootsNostrSignerCapability::RemoteSession(Box::new( - RadrootsNostrRemoteSessionSignerCapability::from(self), - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; - use crate::signer::model::{ - RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionRecord, - }; - use crate::signer::test_support::{ - fixture_alice_identity, fixture_bob_identity, fixture_carol_identity, - fixture_diego_public_key, primary_relay, secondary_relay, - }; - use radroots_nostr_connect::{Method, Permission}; - - fn assert_public_identity_matches(actual: &PublicIdentity, expected: &PublicIdentity) { - assert_eq!(actual, expected); - } - - #[test] - fn local_capability_reports_secret_backing_and_public_identity() { - let public_identity = fixture_alice_identity(); - let capability = RadrootsNostrSignerCapability::LocalAccount(Box::new( - RadrootsNostrLocalSignerCapability::new( - public_identity.account_id(), - public_identity.clone(), - RadrootsNostrLocalSignerAvailability::SecretBacked, - ), - )); - - assert_public_identity_matches(capability.public_identity(), &public_identity); - assert!( - capability - .local_account() - .expect("local capability") - .is_secret_backed() - ); - assert!(capability.remote_session().is_none()); - } - - #[test] - fn remote_session_capability_reflects_connection_effective_permissions() { - let signer_identity = fixture_bob_identity(); - let user_identity = fixture_carol_identity(); - let record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::new_v7(), - signer_identity.clone(), - RadrootsNostrSignerConnectionDraft::new( - fixture_diego_public_key(), - user_identity.clone(), - ) - .with_requested_permissions(vec![Permission::new(Method::Ping)].into()) - .with_relays(vec![primary_relay()]), - 1, - ); - - let capability = record.remote_session_capability(); - assert_public_identity_matches(capability.public_identity(), &user_identity); - assert!(capability.local_account().is_none()); - let remote = capability.remote_session().expect("remote capability"); - assert_eq!(remote.connection_id, record.connection_id); - assert_public_identity_matches(&remote.signer_identity, &signer_identity); - assert_public_identity_matches(&remote.user_identity, &user_identity); - assert_eq!(remote.permissions, record.effective_permissions()); - assert_eq!(remote.relays, record.relays); - } - - #[test] - fn remote_session_builder_helpers_replace_default_fields() { - let capability = RadrootsNostrRemoteSessionSignerCapability::new( - RadrootsNostrSignerConnectionId::new_v7(), - fixture_alice_identity(), - fixture_bob_identity(), - ) - .with_permissions(vec![Permission::new(Method::SwitchRelays)].into()) - .with_relays(vec![primary_relay()]); - - assert_eq!(capability.permissions.as_slice().len(), 1); - assert_eq!(capability.relays.len(), 1); - } - - #[test] - fn capability_equality_accounts_for_identity_fields_and_variant_kind() { - let alice = fixture_alice_identity(); - let bob = fixture_bob_identity(); - - let local = RadrootsNostrLocalSignerCapability::new( - alice.account_id(), - alice.clone(), - RadrootsNostrLocalSignerAvailability::SecretBacked, - ); - let local_same = RadrootsNostrLocalSignerCapability::new( - alice.account_id(), - alice.clone(), - RadrootsNostrLocalSignerAvailability::SecretBacked, - ); - let local_changed_account = RadrootsNostrLocalSignerCapability::new( - bob.account_id(), - alice.clone(), - RadrootsNostrLocalSignerAvailability::SecretBacked, - ); - let local_changed_availability = RadrootsNostrLocalSignerCapability::new( - alice.account_id(), - alice.clone(), - RadrootsNostrLocalSignerAvailability::PublicOnly, - ); - let local_changed_identity = RadrootsNostrLocalSignerCapability::new( - alice.account_id(), - bob, - RadrootsNostrLocalSignerAvailability::SecretBacked, - ); - assert_eq!(local, local_same); - assert_ne!(local, local_changed_account); - assert_ne!(local, local_changed_availability); - assert_ne!(local, local_changed_identity); - - let remote = RadrootsNostrRemoteSessionSignerCapability::new( - RadrootsNostrSignerConnectionId::new_v7(), - fixture_bob_identity(), - fixture_carol_identity(), - ) - .with_relays(vec![primary_relay()]); - let remote_same = remote.clone(); - let remote_changed_connection = RadrootsNostrRemoteSessionSignerCapability::new( - RadrootsNostrSignerConnectionId::new_v7(), - remote.signer_identity.clone(), - remote.user_identity.clone(), - ) - .with_relays(remote.relays.clone()) - .with_permissions(remote.permissions.clone()); - let remote_changed_relays = remote.clone().with_relays(vec![secondary_relay()]); - let remote_changed_permissions = remote - .clone() - .with_permissions(vec![Permission::new(Method::Ping)].into()); - let mut remote_changed_signer = remote.clone(); - remote_changed_signer.signer_identity = fixture_alice_identity(); - let mut remote_changed_user = remote.clone(); - remote_changed_user.user_identity = fixture_alice_identity(); - assert_eq!(remote, remote_same); - assert_ne!(remote, remote_changed_connection); - assert_ne!(remote, remote_changed_relays); - assert_ne!(remote, remote_changed_permissions); - assert_ne!(remote, remote_changed_signer); - assert_ne!(remote, remote_changed_user); - - assert_eq!( - RadrootsNostrSignerCapability::LocalAccount(Box::new(local.clone())), - RadrootsNostrSignerCapability::LocalAccount(Box::new(local_same)) - ); - assert_eq!( - RadrootsNostrSignerCapability::RemoteSession(Box::new(remote.clone())), - RadrootsNostrSignerCapability::RemoteSession(Box::new(remote)) - ); - assert_ne!( - RadrootsNostrSignerCapability::LocalAccount(Box::new(local)), - RadrootsNostrSignerCapability::RemoteSession(Box::new(remote_changed_user)) - ); - } - - #[test] - fn public_identity_eq_compares_invariant_checked_values() { - let alice = fixture_alice_identity(); - let bob = fixture_bob_identity(); - - assert!(!public_identity_eq(&alice, &bob)); - assert!(public_identity_eq(&alice, &alice)); - } -} diff --git a/src/signer/error.rs b/src/signer/error.rs @@ -1,127 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum RadrootsNostrSignerError { - #[error("store error: {0}")] - Store(String), - - #[error("sign error: {0}")] - Sign(String), - - #[error("missing signer identity")] - MissingSignerIdentity, - - #[error("connection not found: {0}")] - ConnectionNotFound(String), - - #[error( - "connection already exists for client `{client_public_key}` and user `{user_identity_id}`" - )] - ConnectionAlreadyExists { - client_public_key: String, - user_identity_id: String, - }, - - #[error("connect secret already in use")] - ConnectSecretAlreadyInUse, - - #[error("invalid auth url `{0}`")] - InvalidAuthUrl(String), - - #[error("invalid signer state: {0}")] - InvalidState(String), - - #[error("invalid granted permission `{0}`")] - InvalidGrantedPermission(String), - - #[error("invalid connection id `{0}`")] - InvalidConnectionId(String), - - #[error("invalid request id `{0}`")] - InvalidRequestId(String), - - #[error("invalid workflow id `{0}`")] - InvalidWorkflowId(String), - - #[error("publish workflow not found: {0}")] - PublishWorkflowNotFound(String), - - #[error("SQLite signer journal-mode query returned {actual_rows} rows; expected exactly one")] - SqliteJournalModeResultCardinality { actual_rows: usize }, - - #[error( - "SQLite signer connection did not enter `{expected}` journal mode; reported `{actual}`" - )] - SqliteJournalModeMismatch { - expected: &'static str, - actual: String, - }, -} - -impl From<serde_json::Error> for RadrootsNostrSignerError { - fn from(value: serde_json::Error) -> Self { - Self::Store(value.to_string()) - } -} - -impl From<nostr::event::Error> for RadrootsNostrSignerError { - fn from(value: nostr::event::Error) -> Self { - Self::Sign(value.to_string()) - } -} - -impl From<radroots_nostr::Error> for RadrootsNostrSignerError { - fn from(value: radroots_nostr::Error) -> Self { - Self::InvalidState(value.to_string()) - } -} - -impl From<radroots_nostr_connect::Error> for RadrootsNostrSignerError { - fn from(value: radroots_nostr_connect::Error) -> Self { - Self::InvalidState(value.to_string()) - } -} - -impl From<crate::sql::SqlError> for RadrootsNostrSignerError { - fn from(value: crate::sql::SqlError) -> Self { - Self::Store(value.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn converts_serde_json_error() { - let source = - serde_json::from_str::<serde_json::Value>("{not-json").expect_err("serde error"); - let converted: RadrootsNostrSignerError = source.into(); - assert!(converted.to_string().starts_with("store error:")); - } - - #[test] - fn converts_nostr_event_error() { - let converted: RadrootsNostrSignerError = nostr::event::Error::InvalidId.into(); - assert!(converted.to_string().starts_with("sign error:")); - } - - #[test] - fn converts_nostr_filter_error() { - let converted: RadrootsNostrSignerError = - radroots_nostr::Error::FilterTagError("bad tag".to_string()).into(); - assert!(converted.to_string().starts_with("invalid signer state:")); - } - - #[test] - fn converts_nostr_connect_error() { - let converted: RadrootsNostrSignerError = - radroots_nostr_connect::Error::InvalidMethod("bad".to_string()).into(); - assert!(converted.to_string().starts_with("invalid signer state:")); - } - - #[test] - fn converts_sql_error() { - let converted: RadrootsNostrSignerError = crate::sql::SqlError::Internal.into(); - assert!(converted.to_string().starts_with("store error:")); - } -} diff --git a/src/signer/evaluation.rs b/src/signer/evaluation.rs @@ -1,519 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::model::{ - RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId, -}; -use nostr::PublicKey; -use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl; -use radroots_nostr_connect::{ - Method, Permission, Request, message::RemoteSessionCapability, permission::Permissions, - uri::ClientMetadata, -}; - -#[derive(Debug, Clone)] -pub enum RadrootsNostrSignerSessionLookup { - None, - Connection(Box<RadrootsNostrSignerConnectionRecord>), - Ambiguous(Vec<RadrootsNostrSignerConnectionRecord>), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsNostrSignerConnectProposal { - pub client_public_key: PublicKey, - pub connect_secret: Option<String>, - pub client_metadata: Option<ClientMetadata>, - pub requested_permissions: Permissions, -} - -#[derive(Debug, Clone)] -pub enum RadrootsNostrSignerConnectEvaluation { - ExistingConnection(Box<RadrootsNostrSignerConnectionRecord>), - RegistrationRequired(RadrootsNostrSignerConnectProposal), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrSignerRequestResponseHint { - None, - Pong, - UserPublicKey(radroots_identity::PublicKey), - RemoteSessionCapability(RemoteSessionCapability), - RelayList(Vec<ConnectRelayUrl>), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrSignerRequestAction { - Allowed { - required_permission: Option<Permission>, - response_hint: RadrootsNostrSignerRequestResponseHint, - }, - Denied { - reason: String, - }, - Challenged { - auth_challenge: RadrootsNostrSignerAuthChallenge, - pending_request: RadrootsNostrSignerPendingRequest, - }, -} - -#[derive(Debug, Clone)] -pub struct RadrootsNostrSignerRequestEvaluation { - pub request_id: RadrootsNostrSignerRequestId, - pub method: Method, - pub connection: RadrootsNostrSignerConnectionRecord, - pub audit: RadrootsNostrSignerRequestAuditRecord, - pub action: RadrootsNostrSignerRequestAction, -} - -impl RadrootsNostrSignerConnectProposal { - pub fn into_connection_draft( - self, - user_identity: PublicIdentity, - ) -> RadrootsNostrSignerConnectionDraft { - let mut draft = - RadrootsNostrSignerConnectionDraft::new(self.client_public_key, user_identity) - .with_requested_permissions(self.requested_permissions); - if let Some(connect_secret) = self.connect_secret { - draft = draft.with_connect_secret(connect_secret); - } - if let Some(client_metadata) = self.client_metadata { - draft = draft.with_client_metadata(client_metadata); - } - draft - } -} - -impl RadrootsNostrSignerRequestEvaluation { - pub fn denied_reason(&self) -> Option<&str> { - match &self.action { - RadrootsNostrSignerRequestAction::Denied { reason } => Some(reason.as_str()), - _ => None, - } - } -} - -impl RadrootsNostrSignerRequestAction { - pub fn audit_message(&self) -> Option<String> { - match self { - Self::Allowed { .. } => None, - Self::Denied { reason } => Some(reason.clone()), - Self::Challenged { .. } => Some("auth challenge required".into()), - } - } -} - -pub(crate) fn required_permission_for_request(request: &Request) -> Option<Permission> { - radroots_nostr_connect::server::required_permission(request) -} - -pub(crate) fn request_allowed_by_permissions( - granted_permissions: &Permissions, - request: &Request, -) -> bool { - let Some(required_permission) = required_permission_for_request(request) else { - return true; - }; - - granted_permissions - .as_slice() - .iter() - .any(|permission| permission_matches(permission, &required_permission)) -} - -pub(crate) fn response_hint_for_request( - connection: &RadrootsNostrSignerConnectionRecord, - request: &Request, -) -> Result<RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerError> { - match request { - Request::GetPublicKey => Ok(RadrootsNostrSignerRequestResponseHint::UserPublicKey( - identity_public_key(&connection.user_identity)?, - )), - Request::GetSessionCapability => Ok( - RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability( - RemoteSessionCapability { - user_public_key: identity_public_key(&connection.user_identity)?, - relays: connection - .relays - .iter() - .map(|relay| ConnectRelayUrl::parse(&relay.to_string())) - .collect::<Result<Vec<_>, _>>()?, - permissions: connection.effective_permissions(), - }, - ), - ), - Request::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong), - Request::SwitchRelays => Ok(RadrootsNostrSignerRequestResponseHint::RelayList( - connection - .relays - .iter() - .map(|relay| ConnectRelayUrl::parse(&relay.to_string())) - .collect::<Result<Vec<_>, _>>()?, - )), - _ => Ok(RadrootsNostrSignerRequestResponseHint::None), - } -} - -fn permission_matches(granted_permission: &Permission, required_permission: &Permission) -> bool { - if granted_permission.method != required_permission.method { - return false; - } - - match ( - &granted_permission.method, - granted_permission.parameter.as_deref(), - required_permission.parameter.as_deref(), - ) { - (Method::SignEvent, None, _) => true, - (Method::SignEvent, Some(parameter), Some(required)) => { - parameter == required || parameter == sign_event_kind_suffix(required) - } - (_, None, _) => true, - (_, Some(parameter), Some(required)) => parameter == required, - (_, Some(_), None) => false, - } -} - -fn sign_event_kind_suffix(value: &str) -> &str { - value.strip_prefix("kind:").unwrap_or(value) -} - -fn identity_public_key( - identity: &PublicIdentity, -) -> Result<radroots_identity::PublicKey, RadrootsNostrSignerError> { - Ok(identity.public_key()) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::*; - use crate::signer::test_support::{ - api_primary_https, fixture_alice_identity, fixture_alice_public_key, fixture_bob_identity, - fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_key, - }; - use nostr::{PublicKey, Timestamp}; - use radroots_nostr_connect::message::UnsignedEvent as ConnectUnsignedEvent; - use serde_json::json; - - fn public_key(index: u32) -> PublicKey { - synthetic_public_key(index) - } - - fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { - radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") - } - - fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl { - ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay") - } - - fn unsigned_event(kind: u16) -> ConnectUnsignedEvent { - ConnectUnsignedEvent::from_json( - &json!({ - "pubkey": fixture_alice_public_key().to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": "hello" - }) - .to_string(), - ) - .expect("unsigned event") - } - - fn connection() -> RadrootsNostrSignerConnectionRecord { - RadrootsNostrSignerConnectionRecord::new( - crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(), - fixture_bob_identity(), - RadrootsNostrSignerConnectionDraft::new( - fixture_carol_public_key(), - fixture_diego_identity(), - ) - .with_relays(vec![primary_relay()]), - 1, - ) - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn assert_action_audit_message_none(action: &RadrootsNostrSignerRequestAction) { - assert_eq!(action.audit_message(), None); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn assert_response_hint_none(hint: RadrootsNostrSignerRequestResponseHint) { - match hint { - RadrootsNostrSignerRequestResponseHint::None => {} - other => panic!("unexpected response hint: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn assert_response_hint_pong(hint: RadrootsNostrSignerRequestResponseHint) { - match hint { - RadrootsNostrSignerRequestResponseHint::Pong => {} - other => panic!("unexpected response hint: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn assert_response_hint_user_public_key(hint: RadrootsNostrSignerRequestResponseHint) { - match hint { - RadrootsNostrSignerRequestResponseHint::UserPublicKey(_) => {} - other => panic!("unexpected response hint: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn assert_response_hint_remote_session_capability( - hint: RadrootsNostrSignerRequestResponseHint, - expected_permissions: Permissions, - ) { - match hint { - RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => { - let expected_public_key = fixture_diego_identity().public_key(); - assert_eq!(capability.user_public_key, expected_public_key); - assert_eq!(capability.relays, vec![connect_relay(primary_relay())]); - assert_eq!(capability.permissions, expected_permissions); - } - other => panic!("unexpected response hint: {other:?}"), - } - } - - #[test] - fn connect_proposal_builds_connection_draft() { - let requested_permissions: Permissions = vec![Permission::new(Method::Nip04Encrypt)].into(); - let proposal = RadrootsNostrSignerConnectProposal { - client_public_key: public_key(5), - connect_secret: Some("secret".into()), - client_metadata: Some(ClientMetadata { - requested_permissions: Permissions::default(), - name: Some("Example Client".into()), - url: Some("https://client.example.com/".into()), - image: None, - }), - requested_permissions: requested_permissions.clone(), - }; - - let draft = proposal.into_connection_draft(fixture_alice_identity()); - - assert_eq!(draft.connect_secret.as_deref(), Some("secret")); - assert_eq!(draft.requested_permissions, requested_permissions); - assert_eq!( - draft - .client_metadata - .as_ref() - .and_then(|metadata| metadata.name.as_deref()), - Some("Example Client") - ); - - let no_secret = RadrootsNostrSignerConnectProposal { - client_public_key: public_key(7), - connect_secret: None, - client_metadata: None, - requested_permissions: Permissions::default(), - } - .into_connection_draft(fixture_bob_identity()); - assert!(no_secret.connect_secret.is_none()); - } - - #[test] - fn request_action_audit_message_and_denied_reason_cover_variants() { - let denied = RadrootsNostrSignerRequestAction::Denied { - reason: "unauthorized".into(), - }; - let challenged = RadrootsNostrSignerRequestAction::Challenged { - auth_challenge: crate::signer::model::RadrootsNostrSignerAuthChallenge::new( - api_primary_https(), - 1, - ) - .expect("challenge"), - pending_request: crate::signer::model::RadrootsNostrSignerPendingRequest::new( - radroots_nostr_connect::message::RequestMessage::new("req-1", Request::Ping), - 1, - ) - .expect("pending"), - }; - let evaluation = RadrootsNostrSignerRequestEvaluation { - request_id: RadrootsNostrSignerRequestId::new_v7(), - method: Method::Ping, - connection: connection(), - audit: crate::signer::model::RadrootsNostrSignerRequestAuditRecord::new( - RadrootsNostrSignerRequestId::new_v7(), - crate::signer::model::RadrootsNostrSignerConnectionId::new_v7(), - Method::Ping, - crate::signer::model::RadrootsNostrSignerRequestDecision::Denied, - Some("unauthorized".into()), - 1, - ), - action: denied.clone(), - }; - - assert_eq!(denied.audit_message().as_deref(), Some("unauthorized")); - assert_eq!( - challenged.audit_message().as_deref(), - Some("auth challenge required") - ); - assert_eq!(evaluation.denied_reason(), Some("unauthorized")); - assert_action_audit_message_none(&RadrootsNostrSignerRequestAction::Allowed { - required_permission: None, - response_hint: RadrootsNostrSignerRequestResponseHint::None, - }); - } - - #[test] - fn request_permission_matching_covers_generic_and_sign_event_forms() { - let kind_one = unsigned_event(1); - let kind_two = unsigned_event(2); - let sign_kind = Permission::with_parameter(Method::SignEvent, "kind:1"); - let sign_numeric = Permission::with_parameter(Method::SignEvent, "1"); - let sign_all = Permission::new(Method::SignEvent); - let nip44 = Permission::new(Method::Nip44Encrypt); - - assert!(request_allowed_by_permissions( - &vec![sign_kind.clone()].into(), - &Request::SignEvent(kind_one.clone()), - )); - assert!(request_allowed_by_permissions( - &vec![sign_numeric].into(), - &Request::SignEvent(kind_one), - )); - assert!(request_allowed_by_permissions( - &vec![sign_all].into(), - &Request::SignEvent(kind_two), - )); - assert!(!request_allowed_by_permissions( - &vec![sign_kind, nip44].into(), - &Request::Nip04Encrypt { - public_key: connect_public_key(public_key(7)), - plaintext: "hello".into(), - }, - )); - assert!(request_allowed_by_permissions( - &Permissions::default(), - &Request::Ping, - )); - assert!(!request_allowed_by_permissions( - &vec![Permission::with_parameter( - Method::custom("do_thing").expect("valid custom NIP-46 method"), - "scoped", - )] - .into(), - &Request::Custom { - method: Method::custom("do_thing").expect("valid custom NIP-46 method"), - params: vec!["value".into()], - }, - )); - assert!(permission_matches( - &Permission::new(Method::Nip04Encrypt), - &Permission::new(Method::Nip04Encrypt), - )); - assert!(permission_matches( - &Permission::with_parameter( - Method::custom("scoped").expect("valid custom NIP-46 method"), - "alpha", - ), - &Permission::with_parameter( - Method::custom("scoped").expect("valid custom NIP-46 method"), - "alpha", - ), - )); - } - - #[test] - fn required_permission_and_response_hint_cover_request_variants() { - let connection = connection(); - let public_key = public_key(8); - let connect = Request::Connect { - remote_signer_public_key: connect_public_key(public_key), - secret: Some("secret".into()), - requested_permissions: Permissions::default(), - client_metadata: None, - }; - let ping = Request::Ping; - let get_public_key = Request::GetPublicKey; - let get_session_capability = Request::GetSessionCapability; - let switch_relays = Request::SwitchRelays; - let sign_event = Request::SignEvent(unsigned_event(7)); - let custom = Request::Custom { - method: Method::custom("do_thing").expect("valid custom NIP-46 method"), - params: vec!["a".into()], - }; - - assert!(required_permission_for_request(&connect).is_none()); - assert!(required_permission_for_request(&ping).is_none()); - assert!(required_permission_for_request(&get_public_key).is_none()); - assert!(required_permission_for_request(&get_session_capability).is_none()); - assert_eq!( - required_permission_for_request(&Request::Nip04Decrypt { - public_key: connect_public_key(public_key), - ciphertext: "cipher".into(), - }) - .expect("nip04 decrypt permission") - .to_string(), - "nip04_decrypt" - ); - assert_eq!( - required_permission_for_request(&Request::Nip44Encrypt { - public_key: connect_public_key(public_key), - plaintext: "hello".into(), - }) - .expect("nip44 encrypt permission") - .to_string(), - "nip44_encrypt" - ); - assert_eq!( - required_permission_for_request(&Request::Nip44Decrypt { - public_key: connect_public_key(public_key), - ciphertext: "cipher".into(), - }) - .expect("nip44 decrypt permission") - .to_string(), - "nip44_decrypt" - ); - assert_eq!( - required_permission_for_request(&switch_relays) - .expect("switch relays permission") - .to_string(), - "switch_relays" - ); - assert_eq!( - required_permission_for_request(&sign_event) - .expect("sign_event permission") - .to_string(), - "sign_event:kind:7" - ); - assert_eq!( - required_permission_for_request(&custom) - .expect("custom permission") - .to_string(), - "do_thing" - ); - - assert_response_hint_none( - response_hint_for_request( - &connection, - &Request::Nip04Decrypt { - public_key: connect_public_key(public_key), - ciphertext: "cipher".into(), - }, - ) - .expect("nip04 response hint"), - ); - assert_response_hint_pong( - response_hint_for_request(&connection, &ping).expect("ping hint"), - ); - assert_response_hint_user_public_key( - response_hint_for_request(&connection, &get_public_key).expect("pubkey hint"), - ); - assert_response_hint_remote_session_capability( - response_hint_for_request(&connection, &get_session_capability) - .expect("capability hint"), - connection.effective_permissions(), - ); - assert_eq!( - response_hint_for_request(&connection, &switch_relays).expect("relay hint"), - RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay(primary_relay())]) - ); - } -} diff --git a/src/signer/manager.rs b/src/signer/manager.rs @@ -1,4004 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, - RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerSessionLookup, request_allowed_by_permissions, - required_permission_for_request, response_hint_for_request, -}; -use crate::signer::model::{ - RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge, - RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome, - RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId, -}; -use crate::signer::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore}; -use nostr::{PublicKey, RelayUrl}; -use radroots_nostr_connect::{ - Method, Request, message::RequestMessage, permission::Permissions, uri::ClientMetadata, -}; -use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Clone)] -pub struct RadrootsNostrSignerManager { - store: Arc<dyn RadrootsNostrSignerStore>, - state: Arc<RwLock<RadrootsNostrSignerStoreState>>, -} - -impl RadrootsNostrSignerManager { - pub fn new_in_memory() -> Self { - Self { - store: Arc::new(RadrootsNostrMemorySignerStore::new()), - state: Arc::new(RwLock::new(RadrootsNostrSignerStoreState::default())), - } - } - - pub fn new(store: Arc<dyn RadrootsNostrSignerStore>) -> Result<Self, RadrootsNostrSignerError> { - let state = store.load()?; - if state.version != RADROOTS_NOSTR_SIGNER_STORE_VERSION { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "unsupported signer schema version {}", - state.version - ))); - } - - Ok(Self { - store, - state: Arc::new(RwLock::new(state)), - }) - } - - pub fn signer_identity(&self) -> Result<Option<PublicIdentity>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard.signer_identity.clone()) - } - - pub fn set_signer_identity( - &self, - signer_identity: PublicIdentity, - ) -> Result<(), RadrootsNostrSignerError> { - validate_public_identity(&signer_identity)?; - self.update_state(|state| { - state.signer_identity = Some(signer_identity); - Ok(()) - }) - } - - pub fn list_connections( - &self, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard.connections.clone()) - } - - pub fn get_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard - .connections - .iter() - .find(|record| &record.connection_id == connection_id) - .cloned()) - } - - pub fn list_publish_workflows( - &self, - ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard.publish_workflows.clone()) - } - - pub fn get_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard - .publish_workflows - .iter() - .find(|record| &record.workflow_id == workflow_id) - .cloned()) - } - - pub fn find_connections_by_client_public_key( - &self, - client_public_key: &PublicKey, - ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard - .connections - .iter() - .filter(|record| &record.client_public_key == client_public_key) - .cloned() - .collect()) - } - - pub fn find_connection_by_connect_secret( - &self, - connect_secret: &str, - ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - let Some(connect_secret_hash) = - RadrootsNostrSignerConnectSecretHash::from_secret(connect_secret) - else { - return Ok(None); - }; - - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard - .connections - .iter() - .find(|record| { - record.connect_secret_hash.as_ref() == Some(&connect_secret_hash) - && (!record.is_terminal() || record.connect_secret_is_consumed()) - }) - .cloned()) - } - - pub fn lookup_session( - &self, - client_public_key: &PublicKey, - connect_secret: Option<&str>, - ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { - if let Some(connect_secret) = connect_secret - && let Some(connection) = self.find_connection_by_connect_secret(connect_secret)? - { - if &connection.client_public_key != client_public_key { - return Err(RadrootsNostrSignerError::InvalidState( - "connect secret is bound to a different client public key".into(), - )); - } - return Ok(RadrootsNostrSignerSessionLookup::Connection(Box::new( - connection, - ))); - } - - let mut matches = self.find_connections_by_client_public_key(client_public_key)?; - matches.retain(|record| !record.is_terminal()); - Ok(match matches.len() { - 0 => RadrootsNostrSignerSessionLookup::None, - 1 => RadrootsNostrSignerSessionLookup::Connection(Box::new(matches.remove(0))), - _ => RadrootsNostrSignerSessionLookup::Ambiguous(matches), - }) - } - - pub fn evaluate_connect_request( - &self, - client_public_key: PublicKey, - request: Request, - ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { - let Request::Connect { - remote_signer_public_key, - secret, - requested_permissions, - client_metadata, - } = request - else { - return Err(RadrootsNostrSignerError::InvalidState( - "connect evaluation requires a connect request".into(), - )); - }; - - let remote_signer_public_key = - radroots_nostr::key::public_key_to_nostr(remote_signer_public_key)?; - let (connect_secret, existing_connection) = - self.resolve_connect_request_context(remote_signer_public_key, secret)?; - if let Some(connection) = existing_connection { - if connection.client_public_key != client_public_key { - return Err(RadrootsNostrSignerError::InvalidState( - "connect secret is bound to a different client public key".into(), - )); - } - return Ok(RadrootsNostrSignerConnectEvaluation::ExistingConnection( - Box::new(connection), - )); - } - - Ok(RadrootsNostrSignerConnectEvaluation::RegistrationRequired( - RadrootsNostrSignerConnectProposal { - client_public_key, - connect_secret, - client_metadata: client_metadata.map(normalize_client_metadata).transpose()?, - requested_permissions: normalize_permissions(requested_permissions), - }, - )) - } - - pub fn list_audit_records( - &self, - ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard.audit_records.clone()) - } - - pub fn audit_records_for_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> { - let guard = self - .state - .read() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - Ok(guard - .audit_records - .iter() - .filter(|record| &record.connection_id == connection_id) - .cloned() - .collect()) - } - - pub fn register_connection( - &self, - draft: RadrootsNostrSignerConnectionDraft, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let signer_identity = state - .signer_identity - .clone() - .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?; - validate_public_identity(&signer_identity)?; - validate_public_identity(&draft.user_identity)?; - - let connect_secret_hash = draft - .connect_secret - .as_deref() - .and_then(RadrootsNostrSignerConnectSecretHash::from_secret); - if let Some(secret_hash) = connect_secret_hash.as_ref() - && state.connections.iter().any(|record| { - record.connect_secret_hash.as_ref() == Some(secret_hash) - && (!record.is_terminal() || record.connect_secret_is_consumed()) - }) - { - return Err(RadrootsNostrSignerError::ConnectSecretAlreadyInUse); - } - - if state.connections.iter().any(|record| { - !record.is_terminal() - && record.client_public_key == draft.client_public_key - && record.user_identity.id() == draft.user_identity.id() - }) { - return Err(RadrootsNostrSignerError::ConnectionAlreadyExists { - client_public_key: draft.client_public_key.to_hex(), - user_identity_id: draft.user_identity.id().to_string(), - }); - } - - let created_at_unix = now_unix_secs(); - let record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::new_v7(), - signer_identity, - RadrootsNostrSignerConnectionDraft { - client_public_key: draft.client_public_key, - user_identity: draft.user_identity, - connect_secret: draft.connect_secret, - client_metadata: draft - .client_metadata - .map(normalize_client_metadata) - .transpose()?, - requested_permissions: normalize_permissions(draft.requested_permissions), - relays: normalize_relays(draft.relays), - approval_requirement: draft.approval_requirement, - }, - created_at_unix, - ); - state.connections.push(record.clone()); - Ok(record) - }) - } - - pub fn set_granted_permissions( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let updated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot update granted permissions for {} connection", - status_label(record.status) - ))); - } - - let granted_permissions = normalize_permissions(granted_permissions); - validate_granted_permissions(&record.requested_permissions, &granted_permissions)?; - record.granted_permissions = granted_permissions - .as_slice() - .iter() - .cloned() - .map(|permission| { - RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix) - }) - .collect(); - record.touch_updated(updated_at_unix); - Ok(record.clone()) - }) - } - - pub fn approve_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - granted_permissions: Permissions, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let updated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.approval_requirement != RadrootsNostrSignerApprovalRequirement::ExplicitUser { - return Err(RadrootsNostrSignerError::InvalidState( - "approval not required for connection".into(), - )); - } - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot approve {} connection", - status_label(record.status) - ))); - } - - let granted_permissions = normalize_permissions(granted_permissions); - validate_granted_permissions(&record.requested_permissions, &granted_permissions)?; - record.granted_permissions = granted_permissions - .as_slice() - .iter() - .cloned() - .map(|permission| { - RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix) - }) - .collect(); - record.approval_state = RadrootsNostrSignerApprovalState::Approved; - record.status = RadrootsNostrSignerConnectionStatus::Active; - record.status_reason = None; - record.touch_updated(updated_at_unix); - Ok(record.clone()) - }) - } - - pub fn reject_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let updated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot reject {} connection", - status_label(record.status) - ))); - } - - record.approval_state = RadrootsNostrSignerApprovalState::Rejected; - record.status = RadrootsNostrSignerConnectionStatus::Rejected; - record.status_reason = normalize_optional_string(reason); - record.touch_updated(updated_at_unix); - Ok(record.clone()) - }) - } - - pub fn revoke_connection( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - reason: Option<String>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let updated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.status == RadrootsNostrSignerConnectionStatus::Revoked { - return Ok(record.clone()); - } - - record.status = RadrootsNostrSignerConnectionStatus::Revoked; - record.status_reason = normalize_optional_string(reason); - record.touch_updated(updated_at_unix); - Ok(record.clone()) - }) - } - - pub fn update_relays( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - relays: Vec<RelayUrl>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let updated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot update relays for {} connection", - status_label(record.status) - ))); - } - - record.relays = normalize_relays(relays); - record.touch_updated(updated_at_unix); - Ok(record.clone()) - }) - } - - pub fn require_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - auth_url: impl AsRef<str>, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let required_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot require auth for {} connection", - status_label(record.status) - ))); - } - - let challenge = - RadrootsNostrSignerAuthChallenge::new(auth_url.as_ref(), required_at_unix)?; - record.require_auth_challenge(challenge); - Ok(record.clone()) - }) - } - - pub fn set_pending_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot set pending request for {} connection", - status_label(record.status) - ))); - } - if record.auth_state != RadrootsNostrSignerAuthState::Pending { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not pending for connection".into(), - )); - } - - let pending_request = - RadrootsNostrSignerPendingRequest::new(request_message, now_unix_secs())?; - record.set_pending_request(pending_request); - Ok(record.clone()) - }) - } - - pub fn authorize_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot authorize auth challenge for {} connection", - status_label(record.status) - ))); - } - if record.auth_state != RadrootsNostrSignerAuthState::Pending { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not pending for connection".into(), - )); - } - - let pending_request = record.authorize_auth_challenge(now_unix_secs()); - Ok(RadrootsNostrSignerAuthorizationOutcome::new( - record.clone(), - pending_request, - )) - }) - } - - pub fn restore_pending_auth_challenge( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - pending_request: RadrootsNostrSignerPendingRequest, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let restored_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot restore auth challenge for {} connection", - status_label(record.status) - ))); - } - if record.auth_state != RadrootsNostrSignerAuthState::Authorized { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not authorized for connection".into(), - )); - } - if record.auth_challenge.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge missing for connection".into(), - )); - } - - record.restore_pending_auth_challenge(pending_request, restored_at_unix); - Ok(record.clone()) - }) - } - - pub fn begin_connect_secret_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let connection_index = find_connection_index(state, connection_id)?; - let record = &state.connections[connection_index]; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot begin connect secret finalization for {} connection", - status_label(record.status) - ))); - } - if record.connect_secret_hash.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "connection does not have a connect secret".into(), - )); - } - if record.connect_secret_is_consumed() { - return Err(RadrootsNostrSignerError::InvalidState( - "connect secret already consumed for connection".into(), - )); - } - ensure_no_active_publish_workflow( - state, - connection_id, - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization, - )?; - - let workflow = - RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( - connection_id.clone(), - now_unix_secs(), - ); - state.publish_workflows.push(workflow.clone()); - Ok(workflow) - }) - } - - pub fn begin_auth_replay_publish_finalization( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let authorized_at_unix = now_unix_secs(); - let connection_index = find_connection_index(state, connection_id)?; - let record = &state.connections[connection_index]; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot begin auth replay finalization for {} connection", - status_label(record.status) - ))); - } - if record.auth_state != RadrootsNostrSignerAuthState::Pending { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not pending for connection".into(), - )); - } - if record.auth_challenge.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge missing for connection".into(), - )); - } - let pending_request = record.pending_request.clone().ok_or_else(|| { - RadrootsNostrSignerError::InvalidState( - "pending request missing for auth replay finalization".into(), - ) - })?; - ensure_no_active_publish_workflow( - state, - connection_id, - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization, - )?; - - let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization( - connection_id.clone(), - pending_request, - authorized_at_unix, - ); - state.publish_workflows.push(workflow.clone()); - Ok(workflow) - }) - } - - pub fn mark_publish_workflow_published( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let workflow = find_publish_workflow_mut(state, workflow_id)?; - workflow.mark_published(now_unix_secs()); - Ok(workflow.clone()) - }) - } - - pub fn finalize_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let workflow_index = find_publish_workflow_index(state, workflow_id)?; - let workflow = state.publish_workflows[workflow_index].clone(); - if workflow.state != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize { - return Err(RadrootsNostrSignerError::InvalidState( - "publish workflow has not reached published state".into(), - )); - } - - let record = find_connection_mut(state, &workflow.connection_id)?; - let finalized = match workflow.kind { - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => { - if record.connect_secret_hash.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "connection does not have a connect secret".into(), - )); - } - if record.connect_secret_is_consumed() { - return Err(RadrootsNostrSignerError::InvalidState( - "connect secret already consumed for connection".into(), - )); - } - record.mark_connect_secret_consumed(now_unix_secs()); - record.clone() - } - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => { - if record.auth_state != RadrootsNostrSignerAuthState::Pending { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not pending for connection".into(), - )); - } - if record.auth_challenge.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge missing for connection".into(), - )); - } - let expected_pending_request = - workflow.pending_request.clone().ok_or_else(|| { - RadrootsNostrSignerError::InvalidState( - "auth replay workflow missing pending request".into(), - ) - })?; - if record.pending_request.as_ref() != Some(&expected_pending_request) { - return Err(RadrootsNostrSignerError::InvalidState( - "pending request does not match auth replay workflow".into(), - )); - } - let authorized_at_unix = workflow.authorized_at_unix.ok_or_else(|| { - RadrootsNostrSignerError::InvalidState( - "auth replay workflow missing authorized timestamp".into(), - ) - })?; - let replay = record.authorize_auth_challenge(authorized_at_unix); - debug_assert_eq!( - replay.as_ref(), - Some(&expected_pending_request), - "auth replay finalization returned unexpected pending request" - ); - record.clone() - } - }; - - state.publish_workflows.remove(workflow_index); - Ok(finalized) - }) - } - - pub fn cancel_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let workflow_index = find_publish_workflow_index(state, workflow_id)?; - Ok(state.publish_workflows.remove(workflow_index)) - }) - } - - pub fn mark_authenticated( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let authenticated_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - record.mark_authenticated(authenticated_at_unix); - Ok(record.clone()) - }) - } - - pub fn mark_connect_secret_consumed( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let consumed_at_unix = now_unix_secs(); - let record = find_connection_mut(state, connection_id)?; - if record.connect_secret_hash.is_none() { - return Err(RadrootsNostrSignerError::InvalidState( - "connection does not have a connect secret".into(), - )); - } - record.mark_connect_secret_consumed(consumed_at_unix); - Ok(record.clone()) - }) - } - - pub fn evaluate_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - if matches!(request_message.request, Request::Connect { .. }) { - return Err(RadrootsNostrSignerError::InvalidState( - "connect requests must be evaluated via evaluate_connect_request".into(), - )); - } - - self.update_state_with(|state| { - let request_at_unix = now_unix_secs(); - let request_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?; - let record = find_connection_mut(state, connection_id)?; - let method = request_message.request.method(); - let action = evaluate_request_action(record, &request_message, request_at_unix)?; - record.mark_request(request_at_unix); - - let audit = RadrootsNostrSignerRequestAuditRecord::new( - request_id.clone(), - connection_id.clone(), - method.clone(), - request_decision(&action), - action.audit_message(), - request_at_unix, - ); - let connection = record.clone(); - state.audit_records.push(audit.clone()); - - Ok(RadrootsNostrSignerRequestEvaluation { - request_id, - method, - connection, - audit, - action, - }) - }) - } - - pub fn evaluate_auth_replay_publish_workflow( - &self, - workflow_id: &RadrootsNostrSignerWorkflowId, - ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let request_at_unix = now_unix_secs(); - let workflow = state - .publish_workflows - .iter() - .find(|record| &record.workflow_id == workflow_id) - .cloned() - .ok_or_else(|| { - RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string()) - })?; - if workflow.kind != RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization { - return Err(RadrootsNostrSignerError::InvalidState( - "publish workflow is not an auth replay finalization".into(), - )); - } - - let pending_request = workflow.pending_request.clone().ok_or_else(|| { - RadrootsNostrSignerError::InvalidState( - "auth replay workflow missing pending request".into(), - ) - })?; - let request_message = pending_request.request_message(); - let request_id = pending_request.request_id(); - let method = request_message.request.method(); - - let record = find_connection_mut(state, &workflow.connection_id)?; - if record.is_terminal() { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "cannot evaluate auth replay workflow for {} connection", - status_label(record.status) - ))); - } - if record.auth_state != RadrootsNostrSignerAuthState::Pending { - return Err(RadrootsNostrSignerError::InvalidState( - "auth challenge not pending for connection".into(), - )); - } - if record.pending_request.as_ref() != Some(&pending_request) { - return Err(RadrootsNostrSignerError::InvalidState( - "pending request does not match auth replay workflow".into(), - )); - } - - let mut effective_connection = record.clone(); - effective_connection.auth_state = RadrootsNostrSignerAuthState::Authorized; - effective_connection.pending_request = None; - if let Some(auth_challenge) = effective_connection.auth_challenge.as_mut() { - auth_challenge.authorized_at_unix = workflow.authorized_at_unix; - } - let request = &request_message; - let action = - evaluate_request_action(&mut effective_connection, request, request_at_unix)?; - effective_connection.mark_request(request_at_unix); - record.mark_request(request_at_unix); - - let audit = RadrootsNostrSignerRequestAuditRecord::new( - request_id.clone(), - workflow.connection_id.clone(), - method.clone(), - request_decision(&action), - action.audit_message(), - request_at_unix, - ); - replace_or_insert_auth_replay_audit(state, audit.clone())?; - - Ok(RadrootsNostrSignerRequestEvaluation { - request_id, - method, - connection: effective_connection, - audit, - action, - }) - }) - } - - pub fn record_request( - &self, - connection_id: &RadrootsNostrSignerConnectionId, - request_id: impl AsRef<str>, - method: Method, - decision: RadrootsNostrSignerRequestDecision, - message: Option<String>, - ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { - self.update_state_with(|state| { - let created_at_unix = now_unix_secs(); - let request_id = RadrootsNostrSignerRequestId::parse(request_id.as_ref())?; - let record = find_connection_mut(state, connection_id)?; - record.mark_request(created_at_unix); - - let audit = RadrootsNostrSignerRequestAuditRecord::new( - request_id, - connection_id.clone(), - method, - decision, - normalize_optional_string(message), - created_at_unix, - ); - state.audit_records.push(audit.clone()); - Ok(audit) - }) - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn update_state( - &self, - update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>, - ) -> Result<(), RadrootsNostrSignerError> { - self.update_state_with(|state| { - update(state)?; - Ok(()) - }) - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn update_state_with<T>( - &self, - update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<T, RadrootsNostrSignerError>, - ) -> Result<T, RadrootsNostrSignerError> { - let mut guard = self - .state - .write() - .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; - let mut next = guard.clone(); - let value = update(&mut next)?; - self.store.save(&next)?; - *guard = next; - Ok(value) - } - - fn resolve_connect_request_context( - &self, - remote_signer_public_key: PublicKey, - secret: Option<String>, - ) -> Result< - (Option<String>, Option<RadrootsNostrSignerConnectionRecord>), - RadrootsNostrSignerError, - > { - let signer_identity = self - .signer_identity()? - .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?; - let signer_public_key = parse_identity_public_key(&signer_identity)?; - if remote_signer_public_key != signer_public_key { - return Err(RadrootsNostrSignerError::InvalidState( - "remote signer public key mismatch".into(), - )); - } - - let connect_secret = normalize_optional_string(secret); - let existing_connection = - self.find_connection_by_connect_secret(connect_secret.as_deref().unwrap_or_default())?; - Ok((connect_secret, existing_connection)) - } -} - -fn find_connection_mut<'a>( - state: &'a mut RadrootsNostrSignerStoreState, - connection_id: &RadrootsNostrSignerConnectionId, -) -> Result<&'a mut RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { - state - .connections - .iter_mut() - .find(|record| &record.connection_id == connection_id) - .ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string())) -} - -fn find_connection_index( - state: &RadrootsNostrSignerStoreState, - connection_id: &RadrootsNostrSignerConnectionId, -) -> Result<usize, RadrootsNostrSignerError> { - for (index, record) in state.connections.iter().enumerate() { - if &record.connection_id == connection_id { - return Ok(index); - } - } - Err(RadrootsNostrSignerError::ConnectionNotFound( - connection_id.to_string(), - )) -} - -fn find_publish_workflow_index( - state: &RadrootsNostrSignerStoreState, - workflow_id: &RadrootsNostrSignerWorkflowId, -) -> Result<usize, RadrootsNostrSignerError> { - state - .publish_workflows - .iter() - .position(|record| &record.workflow_id == workflow_id) - .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string())) -} - -fn find_publish_workflow_mut<'a>( - state: &'a mut RadrootsNostrSignerStoreState, - workflow_id: &RadrootsNostrSignerWorkflowId, -) -> Result<&'a mut RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerError> { - state - .publish_workflows - .iter_mut() - .find(|record| &record.workflow_id == workflow_id) - .ok_or_else(|| RadrootsNostrSignerError::PublishWorkflowNotFound(workflow_id.to_string())) -} - -fn ensure_no_active_publish_workflow( - state: &RadrootsNostrSignerStoreState, - connection_id: &RadrootsNostrSignerConnectionId, - kind: RadrootsNostrSignerPublishWorkflowKind, -) -> Result<(), RadrootsNostrSignerError> { - if state - .publish_workflows - .iter() - .any(|record| &record.connection_id == connection_id && record.kind == kind) - { - return Err(RadrootsNostrSignerError::InvalidState(format!( - "publish workflow already active for {}", - publish_workflow_kind_label(kind) - ))); - } - Ok(()) -} - -fn validate_public_identity(_identity: &PublicIdentity) -> Result<(), RadrootsNostrSignerError> { - Ok(()) -} - -fn validate_granted_permissions( - requested_permissions: &Permissions, - granted_permissions: &Permissions, -) -> Result<(), RadrootsNostrSignerError> { - if requested_permissions.is_empty() { - return Ok(()); - } - - let requested = requested_permissions.as_slice(); - if let Some(permission) = granted_permissions - .as_slice() - .iter() - .find(|permission| !requested.contains(permission)) - { - return Err(RadrootsNostrSignerError::InvalidGrantedPermission( - permission.to_string(), - )); - } - Ok(()) -} - -fn evaluate_request_action( - record: &mut RadrootsNostrSignerConnectionRecord, - request_message: &RequestMessage, - request_at_unix: u64, -) -> Result<RadrootsNostrSignerRequestAction, RadrootsNostrSignerError> { - if record.is_terminal() { - return Ok(RadrootsNostrSignerRequestAction::Denied { - reason: format!("connection is {}", status_label(record.status)), - }); - } - if record.status != RadrootsNostrSignerConnectionStatus::Active { - return Ok(RadrootsNostrSignerRequestAction::Denied { - reason: format!("connection is {}", status_label(record.status)), - }); - } - if record.auth_state == RadrootsNostrSignerAuthState::Pending { - let auth_challenge = - record - .auth_challenge - .clone() - .ok_or(RadrootsNostrSignerError::InvalidState( - "auth challenge missing for pending auth state".into(), - ))?; - let pending_request = - RadrootsNostrSignerPendingRequest::new(request_message.clone(), request_at_unix)?; - record.set_pending_request(pending_request.clone()); - return Ok(RadrootsNostrSignerRequestAction::Challenged { - auth_challenge, - pending_request, - }); - } - - let effective_permissions = record.effective_permissions(); - if !request_allowed_by_permissions(&effective_permissions, &request_message.request) { - return Ok(RadrootsNostrSignerRequestAction::Denied { - reason: format!("unauthorized {}", request_message.request.method()), - }); - } - - Ok(RadrootsNostrSignerRequestAction::Allowed { - required_permission: required_permission_for_request(&request_message.request), - response_hint: response_hint_for_request(record, &request_message.request)?, - }) -} - -fn normalize_permissions(permissions: Permissions) -> Permissions { - let mut permissions = permissions.into_vec(); - permissions.sort(); - permissions.dedup(); - permissions.into() -} - -fn normalize_client_metadata( - mut metadata: ClientMetadata, -) -> Result<ClientMetadata, RadrootsNostrSignerError> { - metadata.requested_permissions = Permissions::default(); - Ok(metadata.normalized()?) -} - -fn normalize_relays(relays: Vec<RelayUrl>) -> Vec<RelayUrl> { - let mut relays = relays; - relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); - relays.dedup_by(|left, right| left.as_str() == right.as_str()); - relays -} - -fn normalize_optional_string(value: Option<String>) -> Option<String> { - value.and_then(|value| { - let trimmed = value.trim().to_owned(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) -} - -fn status_label(status: RadrootsNostrSignerConnectionStatus) -> &'static str { - match status { - RadrootsNostrSignerConnectionStatus::Pending => "pending", - RadrootsNostrSignerConnectionStatus::Active => "active", - RadrootsNostrSignerConnectionStatus::Rejected => "rejected", - RadrootsNostrSignerConnectionStatus::Revoked => "revoked", - } -} - -fn publish_workflow_kind_label(kind: RadrootsNostrSignerPublishWorkflowKind) -> &'static str { - match kind { - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization => { - "connect_secret_finalization" - } - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization => { - "auth_replay_finalization" - } - } -} - -fn request_decision( - action: &RadrootsNostrSignerRequestAction, -) -> RadrootsNostrSignerRequestDecision { - match action { - RadrootsNostrSignerRequestAction::Allowed { .. } => { - RadrootsNostrSignerRequestDecision::Allowed - } - RadrootsNostrSignerRequestAction::Denied { .. } => { - RadrootsNostrSignerRequestDecision::Denied - } - RadrootsNostrSignerRequestAction::Challenged { .. } => { - RadrootsNostrSignerRequestDecision::Challenged - } - } -} - -fn parse_identity_public_key( - identity: &PublicIdentity, -) -> Result<PublicKey, RadrootsNostrSignerError> { - PublicKey::from_hex(&identity.public_key().to_hex()).map_err(|_| { - RadrootsNostrSignerError::InvalidState("identity public key is invalid".into()) - }) -} - -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn replace_or_insert_auth_replay_audit( - state: &mut RadrootsNostrSignerStoreState, - replacement: RadrootsNostrSignerRequestAuditRecord, -) -> Result<(), RadrootsNostrSignerError> { - let Some(existing) = state - .audit_records - .iter_mut() - .find(|record| record.request_id == replacement.request_id) - else { - state.audit_records.push(replacement); - return Ok(()); - }; - if existing.connection_id != replacement.connection_id || existing.method != replacement.method - { - return Err(RadrootsNostrSignerError::InvalidState( - "auth replay audit does not match the original request".into(), - )); - } - *existing = replacement; - Ok(()) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::*; - use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; - use crate::signer::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction, - RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, - }; - use crate::signer::store::RadrootsNostrSignerStore; - use crate::signer::test_support::{ - api_primary_https, fixture_alice_identity, primary_relay, secondary_relay, - synthetic_public_identity, synthetic_public_key, tertiary_relay, - }; - use nostr::{PublicKey, Timestamp}; - use radroots_nostr_connect::{Permission, message::UnsignedEvent as ConnectUnsignedEvent}; - use serde_json::json; - use std::sync::Arc; - use std::thread; - - fn public_identity(index: u32) -> PublicIdentity { - synthetic_public_identity(index) - } - - fn public_key(index: u32) -> PublicKey { - synthetic_public_key(index) - } - - fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { - radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") - } - - fn permission(method: Method, parameter: Option<&str>) -> Permission { - match parameter { - Some(parameter) => Permission::with_parameter(method, parameter), - None => Permission::new(method), - } - } - - fn request_message(id: &str) -> RequestMessage { - RequestMessage::new(id, radroots_nostr_connect::Request::Ping) - } - - fn request_message_with_request(id: &str, request: Request) -> RequestMessage { - RequestMessage::new(id, request) - } - - fn unsigned_event(kind: u16) -> ConnectUnsignedEvent { - ConnectUnsignedEvent::from_json( - &json!({ - "pubkey": public_key(0xa1).to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": "hello" - }) - .to_string(), - ) - .expect("unsigned event") - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_connection_lookup( - lookup: RadrootsNostrSignerSessionLookup, - ) -> RadrootsNostrSignerConnectionRecord { - match lookup { - RadrootsNostrSignerSessionLookup::Connection(found) => *found, - other => panic!("unexpected lookup result: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_ambiguous_lookup( - lookup: RadrootsNostrSignerSessionLookup, - ) -> Vec<RadrootsNostrSignerConnectionRecord> { - match lookup { - RadrootsNostrSignerSessionLookup::Ambiguous(found) => found, - other => panic!("unexpected ambiguous lookup result: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_existing_connect( - evaluation: RadrootsNostrSignerConnectEvaluation, - ) -> RadrootsNostrSignerConnectionRecord { - match evaluation { - RadrootsNostrSignerConnectEvaluation::ExistingConnection(found) => *found, - other => panic!("unexpected existing connect result: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_registration_connect( - evaluation: RadrootsNostrSignerConnectEvaluation, - ) -> crate::signer::evaluation::RadrootsNostrSignerConnectProposal { - match evaluation { - RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal, - other => panic!("unexpected registration connect result: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_none_lookup(lookup: RadrootsNostrSignerSessionLookup) { - match lookup { - RadrootsNostrSignerSessionLookup::None => {} - other => panic!("unexpected non-empty lookup result: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_allowed_user_public_key(action: &RadrootsNostrSignerRequestAction) { - match action { - RadrootsNostrSignerRequestAction::Allowed { - required_permission: None, - response_hint: RadrootsNostrSignerRequestResponseHint::UserPublicKey(_), - } => {} - other => panic!("unexpected allowed pubkey action: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_allowed_without_response_hint(action: &RadrootsNostrSignerRequestAction) { - match action { - RadrootsNostrSignerRequestAction::Allowed { - required_permission: Some(_), - response_hint: RadrootsNostrSignerRequestResponseHint::None, - } => {} - other => panic!("unexpected allowed no-hint action: {other:?}"), - } - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn expect_challenged_action(action: &RadrootsNostrSignerRequestAction) { - match action { - RadrootsNostrSignerRequestAction::Challenged { .. } => {} - other => panic!("unexpected challenged action: {other:?}"), - } - } - - fn poison_manager_state(manager: &RadrootsNostrSignerManager) { - let shared = manager.state.clone(); - let _ = thread::spawn(move || { - let _guard = shared.write().expect("write"); - panic!("poison signer state"); - }) - .join(); - } - - fn assert_same_public_identity(left: &PublicIdentity, right: &PublicIdentity) { - assert_eq!(left, right); - } - - fn assert_same_connection( - left: &RadrootsNostrSignerConnectionRecord, - right: &RadrootsNostrSignerConnectionRecord, - ) { - assert_eq!(left.connection_id, right.connection_id); - assert_eq!(left.client_public_key, right.client_public_key); - assert_same_public_identity(&left.signer_identity, &right.signer_identity); - assert_same_public_identity(&left.user_identity, &right.user_identity); - assert_eq!(left.connect_secret_hash, right.connect_secret_hash); - assert_eq!( - left.connect_secret_consumed_at_unix, - right.connect_secret_consumed_at_unix - ); - assert_eq!(left.requested_permissions, right.requested_permissions); - assert_eq!(left.granted_permissions, right.granted_permissions); - assert_eq!(left.relays, right.relays); - assert_eq!(left.approval_requirement, right.approval_requirement); - assert_eq!(left.approval_state, right.approval_state); - assert_eq!(left.auth_state, right.auth_state); - assert_eq!(left.auth_challenge, right.auth_challenge); - assert_eq!(left.pending_request, right.pending_request); - assert_eq!(left.status, right.status); - assert_eq!(left.status_reason, right.status_reason); - assert_eq!(left.created_at_unix, right.created_at_unix); - assert_eq!(left.updated_at_unix, right.updated_at_unix); - assert_eq!( - left.last_authenticated_at_unix, - right.last_authenticated_at_unix - ); - assert_eq!(left.last_request_at_unix, right.last_request_at_unix); - } - - struct LoadErrorStore; - - impl RadrootsNostrSignerStore for LoadErrorStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - Err(RadrootsNostrSignerError::Store("store load failed".into())) - } - - fn save( - &self, - _state: &RadrootsNostrSignerStoreState, - ) -> Result<(), RadrootsNostrSignerError> { - Ok(()) - } - } - - struct SaveErrorStore { - state: RwLock<RadrootsNostrSignerStoreState>, - } - - impl SaveErrorStore { - fn new(state: RadrootsNostrSignerStoreState) -> Self { - Self { - state: RwLock::new(state), - } - } - } - - impl RadrootsNostrSignerStore for SaveErrorStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - self.state - .read() - .map(|guard| guard.clone()) - .map_err(|_| RadrootsNostrSignerError::Store("save error store poisoned".into())) - } - - fn save( - &self, - _state: &RadrootsNostrSignerStoreState, - ) -> Result<(), RadrootsNostrSignerError> { - Err(RadrootsNostrSignerError::Store("store save failed".into())) - } - } - - #[test] - fn auth_replay_audit_replacement_rejects_identity_mismatches() { - let audit = |connection_id: &str, method: Method| { - RadrootsNostrSignerRequestAuditRecord::new( - RadrootsNostrSignerRequestId::parse("req-auth-replay").expect("request id"), - RadrootsNostrSignerConnectionId::parse(connection_id).expect("connection id"), - method, - RadrootsNostrSignerRequestDecision::Allowed, - None, - 1, - ) - }; - let mut state = RadrootsNostrSignerStoreState::default(); - replace_or_insert_auth_replay_audit(&mut state, audit("conn-auth-replay", Method::Ping)) - .expect("insert audit"); - replace_or_insert_auth_replay_audit(&mut state, audit("conn-auth-replay", Method::Ping)) - .expect("replace matching audit"); - - for replacement in [ - audit("conn-other", Method::Ping), - audit("conn-auth-replay", Method::Logout), - ] { - let error = replace_or_insert_auth_replay_audit(&mut state, replacement) - .expect_err("reject mismatched audit"); - assert!( - error - .to_string() - .contains("auth replay audit does not match the original request") - ); - } - } - - #[test] - fn manager_new_in_memory_and_invalid_schema_paths() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - assert!( - manager - .signer_identity() - .expect("signer identity") - .is_none() - ); - - let load_error_store = Arc::new(LoadErrorStore); - load_error_store - .save(&RadrootsNostrSignerStoreState::default()) - .expect("load error store save"); - let load_result = RadrootsNostrSignerManager::new(load_error_store); - assert!(load_result.is_err()); - let err = match load_result { - Ok(_) => panic!("load error"), - Err(err) => err, - }; - assert!(err.to_string().contains("store load failed")); - - let store = Arc::new(RadrootsNostrMemorySignerStore::new()); - let state = RadrootsNostrSignerStoreState { - version: 2, - ..Default::default() - }; - store.save(&state).expect("save"); - let version_result = RadrootsNostrSignerManager::new(store); - assert!(version_result.is_err()); - let err = match version_result { - Ok(_) => panic!("invalid version"), - Err(err) => err, - }; - assert!( - err.to_string() - .contains("unsupported signer schema version") - ); - } - - #[test] - fn set_signer_identity_persists_invariant_checked_value() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - let signer_identity = fixture_alice_identity(); - manager - .set_signer_identity(signer_identity.clone()) - .expect("set signer"); - - let loaded = manager - .signer_identity() - .expect("identity") - .expect("loaded"); - assert_same_public_identity(&loaded, &signer_identity); - } - - #[test] - fn register_connection_requires_signer_identity_and_normalizes_inputs() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - let err = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x3), - public_identity(0x4), - )) - .expect_err("missing signer"); - assert!(err.to_string().contains("missing signer identity")); - - manager - .set_signer_identity(public_identity(0x5)) - .expect("set signer"); - - let sign_event = permission(Method::SignEvent, Some("kind:1")); - let ping = permission(Method::Ping, None); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x6), public_identity(0x7)) - .with_connect_secret(" secret ") - .with_requested_permissions( - vec![sign_event.clone(), ping.clone(), sign_event.clone()].into(), - ) - .with_relays(vec![primary_relay(), secondary_relay(), secondary_relay()]), - ) - .expect("register"); - - assert!( - record - .connect_secret_hash - .as_ref() - .expect("connect secret hash") - .matches_secret("secret") - ); - assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Active); - assert_eq!( - record.approval_state, - RadrootsNostrSignerApprovalState::NotRequired - ); - assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired); - assert_eq!(record.requested_permissions.as_slice(), &[ping, sign_event]); - assert_eq!(record.relays, vec![secondary_relay(), primary_relay()]); - } - - #[test] - fn register_connection_normalizes_display_only_client_metadata() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(fixture_alice_identity()) - .expect("set signer identity"); - let requested_permissions = vec![permission(Method::Ping, None)].into(); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x90), public_identity(0x91)) - .with_requested_permissions(requested_permissions) - .with_client_metadata(ClientMetadata { - requested_permissions: vec![permission(Method::Nip44Encrypt, None)].into(), - name: Some(" Example Client ".into()), - url: Some("https://client.example.com".into()), - image: None, - }), - ) - .expect("register metadata connection"); - - let metadata = record.client_metadata.expect("stored client metadata"); - assert_eq!(metadata.name.as_deref(), Some("Example Client")); - assert_eq!(metadata.url.as_deref(), Some("https://client.example.com/")); - assert!(metadata.requested_permissions.is_empty()); - assert_eq!( - record.requested_permissions.as_slice(), - &[permission(Method::Ping, None)] - ); - } - - #[test] - fn register_connection_enforces_identity_and_uniqueness_rules() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x8)) - .expect("set signer"); - - let user_identity = public_identity(0x9); - let client_public_key = public_key(0x10); - let pending = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity.clone()) - .with_connect_secret("shared-secret") - .with_approval_requirement( - RadrootsNostrSignerApprovalRequirement::ExplicitUser, - ), - ) - .expect("register"); - assert_eq!(pending.status, RadrootsNostrSignerConnectionStatus::Pending); - - let duplicate_connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity) - .with_connect_secret("other-secret"), - ) - .expect_err("duplicate connection"); - assert!( - duplicate_connection - .to_string() - .contains("connection already exists") - ); - - let duplicate_secret = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x11), public_identity(0x12)) - .with_connect_secret("shared-secret"), - ) - .expect_err("duplicate secret"); - assert!( - duplicate_secret - .to_string() - .contains("connect secret already in use") - ); - } - - #[test] - fn manager_query_helpers_find_connections() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x15)) - .expect("set signer"); - - let client_public_key = public_key(0x16); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(client_public_key, public_identity(0x17)) - .with_connect_secret("lookup-secret"), - ) - .expect("register"); - - let by_id = manager - .get_connection(&record.connection_id) - .expect("get connection"); - let by_client = manager - .find_connections_by_client_public_key(&client_public_key) - .expect("find by client"); - let by_secret = manager - .find_connection_by_connect_secret(" lookup-secret ") - .expect("find by secret"); - let empty_secret = manager - .find_connection_by_connect_secret(" ") - .expect("empty secret"); - let all_connections = manager.list_connections().expect("list connections"); - - assert_same_connection(&by_id.expect("by id"), &record); - assert_eq!(by_client.len(), 1); - assert_same_connection(&by_client[0], &record); - assert_same_connection(&by_secret.expect("by secret"), &record); - assert!(empty_secret.is_none()); - assert_eq!(all_connections.len(), 1); - assert_same_connection(&all_connections[0], &record); - } - - #[test] - fn granted_permissions_and_approval_enforce_subset_rules() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x18)) - .expect("set signer"); - let requested = vec![ - permission(Method::SignEvent, Some("kind:1")), - permission(Method::Ping, None), - ]; - let granted = vec![requested[1].clone()]; - let invalid = vec![permission(Method::Nip44Encrypt, Some("kind:1"))]; - let pending = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x19), public_identity(0x20)) - .with_requested_permissions(requested.clone().into()) - .with_approval_requirement( - RadrootsNostrSignerApprovalRequirement::ExplicitUser, - ), - ) - .expect("register"); - - let invalid_set = manager - .set_granted_permissions(&pending.connection_id, invalid.clone().into()) - .expect_err("invalid set grants"); - assert!( - invalid_set - .to_string() - .contains("invalid granted permission") - ); - - let set_grants = manager - .set_granted_permissions(&pending.connection_id, granted.clone().into()) - .expect("set grants"); - assert_eq!( - set_grants.granted_permissions().as_slice(), - granted.as_slice() - ); - assert_eq!( - set_grants.status, - RadrootsNostrSignerConnectionStatus::Pending - ); - - let approved = manager - .approve_connection(&pending.connection_id, granted.clone().into()) - .expect("approve"); - assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active); - assert_eq!( - approved.approval_state, - RadrootsNostrSignerApprovalState::Approved - ); - assert_eq!( - approved.granted_permissions().as_slice(), - granted.as_slice() - ); - - let reapprove = manager - .approve_connection(&pending.connection_id, granted.into()) - .expect("reapprove active"); - assert_eq!( - reapprove.status, - RadrootsNostrSignerConnectionStatus::Active - ); - - let auto = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x21), - public_identity(0x22), - )) - .expect("register auto"); - let err = manager - .approve_connection(&auto.connection_id, Permissions::default()) - .expect_err("approval not required"); - assert!(err.to_string().contains("approval not required")); - - let terminal_pending = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x40), public_identity(0x41)) - .with_connect_secret("terminal-secret") - .with_approval_requirement( - RadrootsNostrSignerApprovalRequirement::ExplicitUser, - ), - ) - .expect("register terminal"); - manager - .reject_connection(&terminal_pending.connection_id, Some("terminal".into())) - .expect("reject terminal"); - let terminal_approve = manager - .approve_connection( - &terminal_pending.connection_id, - vec![requested[0].clone()].into(), - ) - .expect_err("approve rejected"); - assert!( - terminal_approve - .to_string() - .contains("cannot approve rejected connection") - ); - - let unrestricted = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x23), - public_identity(0x24), - )) - .expect("register unrestricted"); - let unrestricted_grants = manager - .set_granted_permissions(&unrestricted.connection_id, invalid.into()) - .expect("unrestricted grants"); - assert_eq!(unrestricted_grants.granted_permissions.len(), 1); - } - - #[test] - fn reject_revoke_and_relay_updates_cover_terminal_paths() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x25)) - .expect("set signer"); - let rejected = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x26), public_identity(0x27)) - .with_connect_secret("shared-secret") - .with_approval_requirement( - RadrootsNostrSignerApprovalRequirement::ExplicitUser, - ), - ) - .expect("register reject"); - let rejected = manager - .reject_connection(&rejected.connection_id, Some("denied".into())) - .expect("reject"); - assert_eq!( - rejected.status, - RadrootsNostrSignerConnectionStatus::Rejected - ); - assert_eq!(rejected.status_reason.as_deref(), Some("denied")); - - let reject_err = manager - .reject_connection(&rejected.connection_id, None) - .expect_err("reject terminal"); - assert!( - reject_err - .to_string() - .contains("cannot reject rejected connection") - ); - - let relay_err = manager - .update_relays(&rejected.connection_id, vec![primary_relay()]) - .expect_err("update rejected"); - assert!( - relay_err - .to_string() - .contains("cannot update relays for rejected connection") - ); - let rejected_lookup = manager - .find_connection_by_connect_secret("shared-secret") - .expect("lookup rejected secret"); - assert!(rejected_lookup.is_none()); - - let active = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x28), - public_identity(0x29), - )) - .expect("register active"); - let active = manager - .update_relays( - &active.connection_id, - vec![tertiary_relay(), secondary_relay(), secondary_relay()], - ) - .expect("update relays"); - assert_eq!(active.relays, vec![secondary_relay(), tertiary_relay()]); - - let revoked = manager - .revoke_connection(&active.connection_id, Some("manual".into())) - .expect("revoke"); - assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked); - assert_eq!(revoked.status_reason.as_deref(), Some("manual")); - - let revoke_again = manager - .revoke_connection(&active.connection_id, None) - .expect("revoke twice idempotently"); - assert_eq!( - revoke_again.status, - RadrootsNostrSignerConnectionStatus::Revoked - ); - assert_eq!(revoke_again.status_reason.as_deref(), Some("manual")); - assert_eq!(revoke_again.updated_at_unix, revoked.updated_at_unix); - - let grants_err = manager - .set_granted_permissions( - &active.connection_id, - vec![permission(Method::Ping, None)].into(), - ) - .expect_err("update grants revoked"); - assert!( - grants_err - .to_string() - .contains("cannot update granted permissions for revoked connection") - ); - - let require_auth_err = manager - .require_auth_challenge(&active.connection_id, api_primary_https()) - .expect_err("require auth revoked"); - assert!( - require_auth_err - .to_string() - .contains("cannot require auth for revoked connection") - ); - - let pending_request_err = manager - .set_pending_request(&active.connection_id, request_message("req-terminal")) - .expect_err("pending request revoked"); - assert!( - pending_request_err - .to_string() - .contains("cannot set pending request for revoked connection") - ); - - let authorize_auth_err = manager - .authorize_auth_challenge(&active.connection_id) - .expect_err("authorize auth revoked"); - assert!( - authorize_auth_err - .to_string() - .contains("cannot authorize auth challenge for revoked connection") - ); - } - - #[test] - fn authentication_and_request_audit_paths_are_recorded() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x30)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x31), - public_identity(0x32), - )) - .expect("register"); - - let authenticated = manager - .mark_authenticated(&record.connection_id) - .expect("auth"); - assert!(authenticated.last_authenticated_at_unix.is_some()); - - let consumed = manager - .mark_connect_secret_consumed(&record.connection_id) - .expect_err("consume missing secret"); - assert!( - consumed - .to_string() - .contains("connection does not have a connect secret") - ); - - let audit = manager - .record_request( - &record.connection_id, - " request-1 ", - Method::Ping, - RadrootsNostrSignerRequestDecision::Challenged, - Some(" challenge ".into()), - ) - .expect("record request"); - assert_eq!(audit.request_id.as_str(), "request-1"); - assert_eq!(audit.message.as_deref(), Some("challenge")); - - let blank_message_audit = manager - .record_request( - &record.connection_id, - "request-2", - Method::Ping, - RadrootsNostrSignerRequestDecision::Denied, - Some(" ".into()), - ) - .expect("record blank message"); - assert!(blank_message_audit.message.is_none()); - - let all_audits = manager.list_audit_records().expect("list audits"); - let connection_audits = manager - .audit_records_for_connection(&record.connection_id) - .expect("connection audits"); - let stored = manager - .get_connection(&record.connection_id) - .expect("get") - .expect("stored"); - assert_eq!(all_audits, vec![audit.clone(), blank_message_audit.clone()]); - assert_eq!(connection_audits, vec![audit, blank_message_audit]); - assert!(stored.last_request_at_unix.is_some()); - - let request_err = manager - .record_request( - &record.connection_id, - " ", - Method::Ping, - RadrootsNostrSignerRequestDecision::Denied, - None, - ) - .expect_err("invalid request id"); - assert!(request_err.to_string().contains("invalid request id")); - } - - #[test] - fn auth_challenge_and_pending_request_state_are_persisted_and_replayed() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x34)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x35), - public_identity(0x36), - )) - .expect("register"); - - let required = manager - .require_auth_challenge( - &record.connection_id, - format!(" {}/flow ", api_primary_https()).as_str(), - ) - .expect("require auth"); - assert_eq!(required.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!( - required - .auth_challenge - .as_ref() - .expect("auth challenge") - .auth_url, - format!("{}/flow", api_primary_https()) - ); - assert!(required.pending_request.is_none()); - - let pending = manager - .set_pending_request(&record.connection_id, request_message(" req-auth ")) - .expect("set pending request"); - assert_eq!( - pending - .pending_request - .as_ref() - .expect("pending request") - .request_id() - .as_str(), - "req-auth" - ); - - let authorized = manager - .authorize_auth_challenge(&record.connection_id) - .expect("authorize"); - assert_eq!( - authorized.connection.auth_state, - RadrootsNostrSignerAuthState::Authorized - ); - assert!(authorized.connection.last_authenticated_at_unix.is_some()); - assert!(authorized.connection.pending_request.is_none()); - assert_eq!( - authorized - .pending_request - .as_ref() - .expect("replayed request") - .request_message() - .id, - "req-auth" - ); - assert_eq!( - authorized - .connection - .auth_challenge - .as_ref() - .expect("authorized challenge") - .authorized_at_unix, - authorized.connection.last_authenticated_at_unix - ); - - let invalid_url = manager - .require_auth_challenge(&record.connection_id, "not-a-url") - .expect_err("invalid auth url"); - assert!(invalid_url.to_string().contains("invalid auth url")); - - let no_pending_auth = manager - .set_pending_request(&record.connection_id, request_message("req-again")) - .expect_err("pending request without auth challenge"); - assert!( - no_pending_auth - .to_string() - .contains("auth challenge not pending for connection") - ); - - let no_authorize = manager - .authorize_auth_challenge(&record.connection_id) - .expect_err("authorize without pending auth challenge"); - assert!( - no_authorize - .to_string() - .contains("auth challenge not pending for connection") - ); - } - - #[test] - fn restored_authorized_auth_challenge_requeues_pending_request() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x134)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x135), - public_identity(0x136), - )) - .expect("register"); - - manager - .require_auth_challenge( - &record.connection_id, - format!("{}/flow", api_primary_https()).as_str(), - ) - .expect("require auth"); - manager - .set_pending_request(&record.connection_id, request_message("req-replay")) - .expect("set pending"); - - let authorized = manager - .authorize_auth_challenge(&record.connection_id) - .expect("authorize"); - let pending_request = authorized.pending_request.expect("pending request"); - - let restored = manager - .restore_pending_auth_challenge(&record.connection_id, pending_request.clone()) - .expect("restore pending challenge"); - assert_eq!(restored.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!( - restored - .auth_challenge - .as_ref() - .expect("challenge") - .authorized_at_unix, - None - ); - assert!(restored.last_authenticated_at_unix.is_none()); - assert_eq!( - restored - .pending_request - .as_ref() - .expect("pending request") - .request_id() - .as_str(), - pending_request.request_id().as_str() - ); - } - - #[test] - fn connect_secret_consumption_persists_and_remains_idempotent() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x37)) - .expect("set signer"); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x38), public_identity(0x39)) - .with_connect_secret("one-shot-secret"), - ) - .expect("register"); - - let consumed = manager - .mark_connect_secret_consumed(&record.connection_id) - .expect("consume secret"); - assert!(consumed.connect_secret_is_consumed()); - assert!(consumed.connect_secret_consumed_at_unix.is_some()); - - let consumed_again = manager - .mark_connect_secret_consumed(&record.connection_id) - .expect("consume secret again"); - assert_eq!( - consumed_again.connect_secret_consumed_at_unix, - consumed.connect_secret_consumed_at_unix - ); - - let found = manager - .find_connection_by_connect_secret("one-shot-secret") - .expect("find consumed secret") - .expect("stored secret"); - assert!(found.connect_secret_is_consumed()); - assert_eq!( - found.connect_secret_consumed_at_unix, - consumed.connect_secret_consumed_at_unix - ); - } - - #[test] - fn connect_secret_publish_workflow_is_persisted_and_finalized() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x237)) - .expect("set signer"); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x238), public_identity(0x239)) - .with_connect_secret("workflow-secret"), - ) - .expect("register"); - - let workflow = manager - .begin_connect_secret_publish_finalization(&record.connection_id) - .expect("begin workflow"); - assert_eq!( - workflow.kind, - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization - ); - assert_eq!( - workflow.state, - RadrootsNostrSignerPublishWorkflowState::PendingPublish - ); - assert!(workflow.pending_request.is_none()); - assert!( - !manager - .get_connection(&record.connection_id) - .expect("get") - .expect("stored") - .connect_secret_is_consumed() - ); - assert_eq!( - manager.list_publish_workflows().expect("list workflows"), - vec![workflow.clone()] - ); - - let published = manager - .mark_publish_workflow_published(&workflow.workflow_id) - .expect("mark published"); - assert_eq!( - published.state, - RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize - ); - - let finalized = manager - .finalize_publish_workflow(&workflow.workflow_id) - .expect("finalize workflow"); - assert!(finalized.connect_secret_is_consumed()); - assert!( - manager - .list_publish_workflows() - .expect("list workflows") - .is_empty() - ); - assert!( - manager - .find_connection_by_connect_secret("workflow-secret") - .expect("find secret") - .expect("stored") - .connect_secret_is_consumed() - ); - } - - #[test] - fn auth_replay_publish_workflow_is_persisted_and_finalized() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x23a)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x23b), - public_identity(0x23c), - )) - .expect("register"); - - manager - .require_auth_challenge( - &record.connection_id, - format!("{}/flow", api_primary_https()).as_str(), - ) - .expect("require auth"); - let pending = manager - .set_pending_request(&record.connection_id, request_message("req-auth-workflow")) - .expect("set pending"); - let pending_request = pending.pending_request.expect("pending request"); - - let workflow = manager - .begin_auth_replay_publish_finalization(&record.connection_id) - .expect("begin auth replay workflow"); - assert_eq!( - workflow.kind, - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization - ); - assert_eq!(workflow.pending_request.as_ref(), Some(&pending_request)); - assert!(workflow.authorized_at_unix.is_some()); - - let stored_before_publish = manager - .get_connection(&record.connection_id) - .expect("get") - .expect("stored"); - assert_eq!( - stored_before_publish.auth_state, - RadrootsNostrSignerAuthState::Pending - ); - assert_eq!( - stored_before_publish.pending_request.as_ref(), - Some(&pending_request) - ); - - manager - .mark_publish_workflow_published(&workflow.workflow_id) - .expect("mark published"); - let finalized = manager - .finalize_publish_workflow(&workflow.workflow_id) - .expect("finalize auth replay"); - assert_eq!( - finalized.auth_state, - RadrootsNostrSignerAuthState::Authorized - ); - assert!(finalized.pending_request.is_none()); - assert_eq!( - finalized - .auth_challenge - .as_ref() - .expect("challenge") - .authorized_at_unix, - workflow.authorized_at_unix - ); - assert_eq!( - finalized.last_authenticated_at_unix, - workflow.authorized_at_unix - ); - assert!( - manager - .list_publish_workflows() - .expect("list workflows") - .is_empty() - ); - } - - #[test] - fn canceling_auth_replay_publish_workflow_preserves_pending_request() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x23d)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x23e), - public_identity(0x23f), - )) - .expect("register"); - - manager - .require_auth_challenge( - &record.connection_id, - format!("{}/flow", api_primary_https()).as_str(), - ) - .expect("require auth"); - let pending = manager - .set_pending_request(&record.connection_id, request_message("req-auth-cancel")) - .expect("set pending"); - let pending_request = pending.pending_request.expect("pending request"); - - let workflow = manager - .begin_auth_replay_publish_finalization(&record.connection_id) - .expect("begin auth replay workflow"); - let canceled = manager - .cancel_publish_workflow(&workflow.workflow_id) - .expect("cancel workflow"); - assert_eq!(canceled.workflow_id, workflow.workflow_id); - - let stored = manager - .get_connection(&record.connection_id) - .expect("get") - .expect("stored"); - assert_eq!(stored.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!(stored.pending_request.as_ref(), Some(&pending_request)); - assert!( - manager - .list_publish_workflows() - .expect("list workflows") - .is_empty() - ); - } - - #[test] - fn evaluate_auth_replay_publish_workflow_uses_authorized_view_without_mutating_state() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x240)) - .expect("set signer"); - let record = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x241), - public_identity(0x242), - )) - .expect("register"); - - manager - .set_granted_permissions( - &record.connection_id, - vec!["get_public_key".parse().expect("permission")].into(), - ) - .expect("grant permissions"); - manager - .require_auth_challenge( - &record.connection_id, - format!("{}/flow", api_primary_https()).as_str(), - ) - .expect("require auth"); - let challenged = manager - .evaluate_request( - &record.connection_id, - RequestMessage::new("req-auth-preview", Request::GetPublicKey), - ) - .expect("evaluate challenged request"); - assert_eq!( - challenged.audit.decision, - RadrootsNostrSignerRequestDecision::Challenged - ); - let pending_request = challenged - .connection - .pending_request - .expect("pending request"); - - let workflow = manager - .begin_auth_replay_publish_finalization(&record.connection_id) - .expect("begin auth replay workflow"); - let evaluation = manager - .evaluate_auth_replay_publish_workflow(&workflow.workflow_id) - .expect("evaluate auth replay workflow"); - - assert_eq!( - evaluation.request_id.as_str(), - pending_request.request_id().as_str() - ); - assert_eq!( - evaluation.connection.auth_state, - RadrootsNostrSignerAuthState::Authorized - ); - assert!(evaluation.connection.pending_request.is_none()); - assert!(matches!( - evaluation.action, - RadrootsNostrSignerRequestAction::Allowed { .. } - )); - - let stored = manager - .get_connection(&record.connection_id) - .expect("get") - .expect("stored"); - assert_eq!(stored.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!(stored.pending_request.as_ref(), Some(&pending_request)); - let audits = manager.list_audit_records().expect("list audits"); - assert_eq!(audits.len(), 1); - assert_eq!(audits[0].request_id.as_str(), "req-auth-preview"); - assert_eq!( - audits[0].decision, - RadrootsNostrSignerRequestDecision::Allowed - ); - } - - #[test] - fn publish_workflow_duplicate_and_missing_paths_are_rejected() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x240)) - .expect("set signer"); - let record = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x241), public_identity(0x242)) - .with_connect_secret("duplicate-secret"), - ) - .expect("register"); - - let workflow = manager - .begin_connect_secret_publish_finalization(&record.connection_id) - .expect("begin workflow"); - let duplicate = manager - .begin_connect_secret_publish_finalization(&record.connection_id) - .expect_err("duplicate workflow"); - assert!( - duplicate - .to_string() - .contains("publish workflow already active") - ); - - let missing_workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-missing").expect("id"); - let missing_mark = manager - .mark_publish_workflow_published(&missing_workflow_id) - .expect_err("missing mark"); - let missing_finalize = manager - .finalize_publish_workflow(&missing_workflow_id) - .expect_err("missing finalize"); - let missing_cancel = manager - .cancel_publish_workflow(&missing_workflow_id) - .expect_err("missing cancel"); - - for err in [missing_mark, missing_finalize, missing_cancel] { - assert!(err.to_string().contains("publish workflow not found")); - } - - let unpublished_finalize = manager - .finalize_publish_workflow(&workflow.workflow_id) - .expect_err("unpublished finalize"); - assert!( - unpublished_finalize - .to_string() - .contains("publish workflow has not reached published state") - ); - } - - #[test] - fn publish_workflow_entrypoints_reject_invalid_connection_states() { - let manager = RadrootsNostrSignerManager::new_in_memory(); - manager - .set_signer_identity(public_identity(0x300)) - .expect("set signer"); - let missing_connection_id = - RadrootsNostrSignerConnectionId::parse("conn-missing-publish").expect("connection id"); - let restore_pending_request = - RadrootsNostrSignerPendingRequest::new(request_message("req-restore-invalid"), 61) - .expect("pending request"); - - let missing_restore_err = manager - .restore_pending_auth_challenge(&missing_connection_id, restore_pending_request.clone()) - .expect_err("missing restore connection"); - assert!( - missing_restore_err - .to_string() - .contains("connection not found") - ); - - let terminal_restore = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x301), - public_identity(0x302), - )) - .expect("register terminal restore"); - manager - .reject_connection(&terminal_restore.connection_id, Some("closed".into())) - .expect("reject terminal restore"); - let terminal_restore_err = manager - .restore_pending_auth_challenge( - &terminal_restore.connection_id, - restore_pending_request.clone(), - ) - .expect_err("terminal restore error"); - assert!( - terminal_restore_err - .to_string() - .contains("cannot restore auth challenge for rejected connection") - ); - - let unauthorized_restore = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x303), - public_identity(0x304), - )) - .expect("register unauthorized restore"); - let unauthorized_restore_err = manager - .restore_pending_auth_challenge( - &unauthorized_restore.connection_id, - restore_pending_request.clone(), - ) - .expect_err("unauthorized restore error"); - assert!( - unauthorized_restore_err - .to_string() - .contains("auth challenge not authorized for connection") - ); - - let missing_challenge_restore = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x305), - public_identity(0x306), - )) - .expect("register missing challenge restore"); - manager - .require_auth_challenge( - &missing_challenge_restore.connection_id, - format!("{}/restore", api_primary_https()).as_str(), - ) - .expect("require auth"); - manager - .set_pending_request( - &missing_challenge_restore.connection_id, - request_message("req-restore-missing-challenge"), - ) - .expect("set pending"); - let replay = manager - .authorize_auth_challenge(&missing_challenge_restore.connection_id) - .expect("authorize") - .pending_request - .expect("pending request"); - { - let mut state = manager.state.write().expect("write"); - let record = state - .connections - .iter_mut() - .find(|record| record.connection_id == missing_challenge_restore.connection_id) - .expect("stored connection"); - record.auth_challenge = None; - } - let missing_challenge_restore_err = manager - .restore_pending_auth_challenge(&missing_challenge_restore.connection_id, replay) - .expect_err("missing challenge restore error"); - assert!( - missing_challenge_restore_err - .to_string() - .contains("auth challenge missing for connection") - ); - - let terminal_connect = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x307), public_identity(0x308)) - .with_connect_secret("terminal-connect-secret"), - ) - .expect("register terminal connect"); - manager - .reject_connection(&terminal_connect.connection_id, Some("closed".into())) - .expect("reject terminal connect"); - let terminal_connect_err = manager - .begin_connect_secret_publish_finalization(&terminal_connect.connection_id) - .expect_err("terminal connect workflow"); - assert!( - terminal_connect_err - .to_string() - .contains("cannot begin connect secret finalization for rejected connection") - ); - - let no_secret_connect = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x309), - public_identity(0x30a), - )) - .expect("register no secret connect"); - let no_secret_connect_err = manager - .begin_connect_secret_publish_finalization(&no_secret_connect.connection_id) - .expect_err("missing secret workflow"); - assert!( - no_secret_connect_err - .to_string() - .contains("connection does not have a connect secret") - ); - - let consumed_connect = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new(public_key(0x30b), public_identity(0x30c)) - .with_connect_secret("consumed-connect-secret"), - ) - .expect("register consumed connect"); - manager - .mark_connect_secret_consumed(&consumed_connect.connection_id) - .expect("consume connect secret"); - let consumed_connect_err = manager - .begin_connect_secret_publish_finalization(&consumed_connect.connection_id) - .expect_err("consumed secret workflow"); - assert!( - consumed_connect_err - .to_string() - .contains("connect secret already consumed for connection") - ); - - let missing_mark_consumed_err = manager - .mark_connect_secret_consumed(&missing_connection_id) - .expect_err("missing mark connect secret consumed"); - assert!( - missing_mark_consumed_err - .to_string() - .contains("connection not found") - ); - - let terminal_auth = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x30d), - public_identity(0x30e), - )) - .expect("register terminal auth"); - manager - .reject_connection(&terminal_auth.connection_id, Some("closed".into())) - .expect("reject terminal auth"); - let terminal_auth_err = manager - .begin_auth_replay_publish_finalization(&terminal_auth.connection_id) - .expect_err("terminal auth workflow"); - assert!( - terminal_auth_err - .to_string() - .contains("cannot begin auth replay finalization for rejected connection") - ); - - let not_pending_auth = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - public_key(0x30f), - public_identity(0x310), - )) - .expect("register not pending auth"); - let not_pending_auth_err = manager - .begin_auth_replay_publish_finalization(&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/mod.rs b/src/signer/mod.rs @@ -1,65 +0,0 @@ -//! Myc-owned signer-service state, policy, persistence, and NIP-46 execution. -//! -//! Generic signing requests and receipts come from `radroots_signing`, while -//! protocol parsing comes from `radroots_nostr_connect`. Approval, session, -//! persistence, and service execution remain local to this host. - -pub mod backend; -pub mod capability; -pub mod error; -pub mod evaluation; -pub mod manager; -pub mod migrations; -pub mod model; -pub mod nip46; -pub mod sqlite; -pub mod store; - -#[cfg(test)] -mod test_fixtures; -#[cfg(test)] -mod test_support; - -pub mod prelude { - pub use super::backend::{ - RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend, - RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition, - RadrootsNostrSignerSignOutput, - }; - pub use super::capability::{ - RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, - RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerCapability, - }; - pub use super::error::RadrootsNostrSignerError; - pub use super::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, - RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation, - RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, - }; - pub use super::manager::RadrootsNostrSignerManager; - pub use super::model::{ - RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge, - RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome, - RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerPublishWorkflowKind, - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestId, RadrootsNostrSignerSecretDigestAlgorithm, - RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId, - }; - pub use super::nip46::{ - RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome, - RadrootsNostrSignerNip46Codec, RadrootsNostrSignerNip46ConnectDecision, - RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Policy, - RadrootsNostrSignerNip46Signer, connect_response_outcome, handled_request_for_action, - response_from_hint, - }; - pub use super::sqlite::RadrootsNostrSignerSqliteDb; - pub use super::store::{ - RadrootsNostrFileSignerStore, RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore, - RadrootsNostrSqliteSignerStore, - }; -} diff --git a/src/signer/model.rs b/src/signer/model.rs @@ -1,1594 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::error::RadrootsNostrSignerError; -use hex::encode as hex_encode; -use nostr::{PublicKey, RelayUrl}; -use radroots_nostr_connect::{ - Method, Permission, message::RequestMessage, permission::Permissions, uri::ClientMetadata, -}; -use serde::{Deserialize, Deserializer, Serialize}; -use sha2::{Digest, Sha256}; -use std::fmt; -use std::str::FromStr; -use url::Url; -use uuid::Uuid; - -pub const RADROOTS_NOSTR_SIGNER_STORE_VERSION: u32 = 1; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RadrootsNostrSignerConnectionId(String); - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RadrootsNostrSignerRequestId(String); - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RadrootsNostrSignerWorkflowId(String); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrSignerApprovalRequirement { - NotRequired, - ExplicitUser, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrSignerApprovalState { - NotRequired, - Pending, - Approved, - Rejected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrSignerConnectionStatus { - Pending, - Active, - Rejected, - Revoked, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsNostrSignerPublishWorkflowKind { - ConnectSecretFinalization, - AuthReplayFinalization, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsNostrSignerPublishWorkflowState { - PendingPublish, - PublishedPendingFinalize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrSignerRequestDecision { - Allowed, - Denied, - Challenged, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsNostrSignerAuthState { - #[default] - NotRequired, - Pending, - Authorized, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsNostrSignerSecretDigestAlgorithm { - Sha256, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrSignerConnectSecretHash { - pub algorithm: RadrootsNostrSignerSecretDigestAlgorithm, - pub digest_hex: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct RadrootsNostrSignerAuthChallenge { - pub auth_url: String, - pub required_at_unix: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authorized_at_unix: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrSignerPendingRequest { - pub request_message: RequestMessage, - pub created_at_unix: u64, -} - -#[derive(Debug, Clone)] -pub struct RadrootsNostrSignerAuthorizationOutcome { - pub connection: RadrootsNostrSignerConnectionRecord, - pub pending_request: Option<RadrootsNostrSignerPendingRequest>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrSignerPermissionGrant { - #[serde( - serialize_with = "serialize_permission", - deserialize_with = "deserialize_permission" - )] - pub permission: Permission, - pub granted_at_unix: u64, -} - -#[derive(Debug, Clone)] -pub struct RadrootsNostrSignerConnectionDraft { - pub client_public_key: PublicKey, - pub user_identity: PublicIdentity, - pub connect_secret: Option<String>, - pub client_metadata: Option<ClientMetadata>, - pub requested_permissions: Permissions, - pub relays: Vec<RelayUrl>, - pub approval_requirement: RadrootsNostrSignerApprovalRequirement, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrSignerConnectionRecord { - pub connection_id: RadrootsNostrSignerConnectionId, - pub client_public_key: PublicKey, - pub signer_identity: PublicIdentity, - pub user_identity: PublicIdentity, - #[serde( - default, - alias = "connect_secret", - deserialize_with = "deserialize_connect_secret_hash_option", - skip_serializing_if = "Option::is_none" - )] - pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connect_secret_consumed_at_unix: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_metadata: Option<ClientMetadata>, - pub requested_permissions: Permissions, - #[serde(default)] - pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>, - #[serde(default)] - pub relays: Vec<RelayUrl>, - pub approval_requirement: RadrootsNostrSignerApprovalRequirement, - pub approval_state: RadrootsNostrSignerApprovalState, - #[serde(default)] - pub auth_state: RadrootsNostrSignerAuthState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_challenge: Option<RadrootsNostrSignerAuthChallenge>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pending_request: Option<RadrootsNostrSignerPendingRequest>, - pub status: RadrootsNostrSignerConnectionStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status_reason: Option<String>, - pub created_at_unix: u64, - pub updated_at_unix: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_authenticated_at_unix: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_request_at_unix: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrSignerRequestAuditRecord { - pub request_id: RadrootsNostrSignerRequestId, - pub connection_id: RadrootsNostrSignerConnectionId, - pub method: Method, - pub decision: RadrootsNostrSignerRequestDecision, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option<String>, - pub created_at_unix: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrSignerPublishWorkflowRecord { - pub workflow_id: RadrootsNostrSignerWorkflowId, - pub connection_id: RadrootsNostrSignerConnectionId, - pub kind: RadrootsNostrSignerPublishWorkflowKind, - pub state: RadrootsNostrSignerPublishWorkflowState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pending_request: Option<RadrootsNostrSignerPendingRequest>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authorized_at_unix: Option<u64>, - pub created_at_unix: u64, - pub updated_at_unix: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrSignerStoreState { - pub version: u32, - pub signer_identity: Option<PublicIdentity>, - pub connections: Vec<RadrootsNostrSignerConnectionRecord>, - pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>, - #[serde(default)] - pub publish_workflows: Vec<RadrootsNostrSignerPublishWorkflowRecord>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -enum RadrootsNostrSignerConnectSecretHashRepr { - Hash(RadrootsNostrSignerConnectSecretHash), - LegacyPlaintext(String), -} - -impl RadrootsNostrSignerConnectionId { - pub fn new_v7() -> Self { - Self(Uuid::now_v7().to_string()) - } - - pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RadrootsNostrSignerError::InvalidConnectionId( - value.to_owned(), - )); - } - Ok(Self(trimmed.to_owned())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl fmt::Display for RadrootsNostrSignerConnectionId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef<str> for RadrootsNostrSignerConnectionId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl FromStr for RadrootsNostrSignerConnectionId { - type Err = RadrootsNostrSignerError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} - -impl RadrootsNostrSignerRequestId { - pub fn new_v7() -> Self { - Self(Uuid::now_v7().to_string()) - } - - pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RadrootsNostrSignerError::InvalidRequestId(value.to_owned())); - } - Ok(Self(trimmed.to_owned())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl RadrootsNostrSignerWorkflowId { - pub fn new_v7() -> Self { - Self(Uuid::now_v7().to_string()) - } - - pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RadrootsNostrSignerError::InvalidWorkflowId( - value.to_owned(), - )); - } - Ok(Self(trimmed.to_owned())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl fmt::Display for RadrootsNostrSignerWorkflowId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef<str> for RadrootsNostrSignerWorkflowId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl FromStr for RadrootsNostrSignerWorkflowId { - type Err = RadrootsNostrSignerError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} - -impl fmt::Display for RadrootsNostrSignerRequestId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef<str> for RadrootsNostrSignerRequestId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl FromStr for RadrootsNostrSignerRequestId { - type Err = RadrootsNostrSignerError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} - -impl RadrootsNostrSignerConnectSecretHash { - pub fn from_secret(secret: &str) -> Option<Self> { - normalize_optional_string(secret).map(|normalized| { - let mut hasher = Sha256::new(); - hasher.update(normalized.as_bytes()); - Self { - algorithm: RadrootsNostrSignerSecretDigestAlgorithm::Sha256, - digest_hex: hex_encode(hasher.finalize()), - } - }) - } - - pub fn matches_secret(&self, secret: &str) -> bool { - Self::from_secret(secret).as_ref() == Some(self) - } - - fn normalize(self) -> Result<Self, String> { - let digest_hex = self.digest_hex.trim().to_ascii_lowercase(); - if digest_hex.len() != 64 || !digest_hex.chars().all(|ch| ch.is_ascii_hexdigit()) { - return Err("invalid connect secret digest".into()); - } - Ok(Self { - algorithm: self.algorithm, - digest_hex, - }) - } -} - -impl RadrootsNostrSignerAuthChallenge { - pub fn new(auth_url: &str, required_at_unix: u64) -> Result<Self, RadrootsNostrSignerError> { - let auth_url = normalize_optional_string(auth_url) - .ok_or_else(|| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.to_owned()))?; - let auth_url: String = Url::parse(&auth_url) - .map_err(|_| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.clone()))? - .into(); - Ok(Self { - auth_url, - required_at_unix, - authorized_at_unix: None, - }) - } - - pub fn mark_authorized(&mut self, authorized_at_unix: u64) { - self.authorized_at_unix = Some(authorized_at_unix); - } -} - -impl<'de> Deserialize<'de> for RadrootsNostrSignerAuthChallenge { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct RawAuthChallenge { - auth_url: String, - required_at_unix: u64, - #[serde(default)] - authorized_at_unix: Option<u64>, - } - - let raw = RawAuthChallenge::deserialize(deserializer)?; - let mut challenge = - Self::new(&raw.auth_url, raw.required_at_unix).map_err(serde::de::Error::custom)?; - challenge.authorized_at_unix = raw.authorized_at_unix; - Ok(challenge) - } -} - -impl RadrootsNostrSignerPendingRequest { - pub fn new( - request_message: RequestMessage, - created_at_unix: u64, - ) -> Result<Self, RadrootsNostrSignerError> { - let normalized_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?; - Ok(Self { - request_message: RequestMessage::new(normalized_id.as_str(), request_message.request), - created_at_unix, - }) - } - - pub fn request_message(&self) -> RequestMessage { - self.request_message.clone() - } - - pub fn request_id(&self) -> RadrootsNostrSignerRequestId { - RadrootsNostrSignerRequestId::parse(&self.request_message.id) - .expect("pending request ids are validated on construction") - } -} - -impl RadrootsNostrSignerAuthorizationOutcome { - pub fn new( - connection: RadrootsNostrSignerConnectionRecord, - pending_request: Option<RadrootsNostrSignerPendingRequest>, - ) -> Self { - Self { - connection, - pending_request, - } - } -} - -impl RadrootsNostrSignerPermissionGrant { - pub fn new(permission: Permission, granted_at_unix: u64) -> Self { - Self { - permission, - granted_at_unix, - } - } -} - -impl RadrootsNostrSignerConnectionDraft { - pub fn new(client_public_key: PublicKey, user_identity: PublicIdentity) -> Self { - Self { - client_public_key, - user_identity, - connect_secret: None, - client_metadata: None, - requested_permissions: Permissions::default(), - relays: Vec::new(), - approval_requirement: RadrootsNostrSignerApprovalRequirement::NotRequired, - } - } - - pub fn with_connect_secret(mut self, connect_secret: impl Into<String>) -> Self { - self.connect_secret = Some(connect_secret.into()); - self - } - - pub fn with_requested_permissions(mut self, requested_permissions: Permissions) -> Self { - self.requested_permissions = requested_permissions; - self - } - - pub fn with_client_metadata(mut self, client_metadata: ClientMetadata) -> Self { - self.client_metadata = Some(client_metadata); - self - } - - pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self { - self.relays = relays; - self - } - - pub fn with_approval_requirement( - mut self, - approval_requirement: RadrootsNostrSignerApprovalRequirement, - ) -> Self { - self.approval_requirement = approval_requirement; - self - } -} - -impl RadrootsNostrSignerConnectionRecord { - pub fn new( - connection_id: RadrootsNostrSignerConnectionId, - signer_identity: PublicIdentity, - draft: RadrootsNostrSignerConnectionDraft, - created_at_unix: u64, - ) -> Self { - let (approval_state, status) = match draft.approval_requirement { - RadrootsNostrSignerApprovalRequirement::NotRequired => ( - RadrootsNostrSignerApprovalState::NotRequired, - RadrootsNostrSignerConnectionStatus::Active, - ), - RadrootsNostrSignerApprovalRequirement::ExplicitUser => ( - RadrootsNostrSignerApprovalState::Pending, - RadrootsNostrSignerConnectionStatus::Pending, - ), - }; - - Self { - connection_id, - client_public_key: draft.client_public_key, - signer_identity, - user_identity: draft.user_identity, - connect_secret_hash: draft - .connect_secret - .as_deref() - .and_then(RadrootsNostrSignerConnectSecretHash::from_secret), - connect_secret_consumed_at_unix: None, - client_metadata: draft.client_metadata, - requested_permissions: draft.requested_permissions, - granted_permissions: Vec::new(), - relays: draft.relays, - approval_requirement: draft.approval_requirement, - approval_state, - auth_state: RadrootsNostrSignerAuthState::NotRequired, - auth_challenge: None, - pending_request: None, - status, - status_reason: None, - created_at_unix, - updated_at_unix: created_at_unix, - last_authenticated_at_unix: None, - last_request_at_unix: None, - } - } - - pub fn granted_permissions(&self) -> Permissions { - self.granted_permissions - .iter() - .map(|grant| grant.permission.clone()) - .collect::<Vec<_>>() - .into() - } - - pub fn effective_permissions(&self) -> Permissions { - let granted_permissions = self.granted_permissions(); - if !granted_permissions.is_empty() { - granted_permissions - } else if self.approval_state == RadrootsNostrSignerApprovalState::NotRequired { - self.requested_permissions.clone() - } else { - Permissions::default() - } - } - - pub fn is_terminal(&self) -> bool { - matches!( - self.status, - RadrootsNostrSignerConnectionStatus::Rejected - | RadrootsNostrSignerConnectionStatus::Revoked - ) - } - - pub fn connect_secret_is_consumed(&self) -> bool { - self.connect_secret_hash.is_some() && self.connect_secret_consumed_at_unix.is_some() - } - - pub fn touch_updated(&mut self, updated_at_unix: u64) { - self.updated_at_unix = updated_at_unix; - } - - pub fn mark_authenticated(&mut self, authenticated_at_unix: u64) { - self.last_authenticated_at_unix = Some(authenticated_at_unix); - self.updated_at_unix = authenticated_at_unix; - } - - pub fn mark_request(&mut self, request_at_unix: u64) { - self.last_request_at_unix = Some(request_at_unix); - self.updated_at_unix = request_at_unix; - } - - pub fn mark_connect_secret_consumed(&mut self, consumed_at_unix: u64) { - if self.connect_secret_hash.is_none() || self.connect_secret_consumed_at_unix.is_some() { - return; - } - self.connect_secret_consumed_at_unix = Some(consumed_at_unix); - self.updated_at_unix = consumed_at_unix; - } - - pub fn require_auth_challenge(&mut self, auth_challenge: RadrootsNostrSignerAuthChallenge) { - self.auth_state = RadrootsNostrSignerAuthState::Pending; - self.auth_challenge = Some(auth_challenge.clone()); - self.pending_request = None; - self.updated_at_unix = auth_challenge.required_at_unix; - } - - pub fn set_pending_request(&mut self, pending_request: RadrootsNostrSignerPendingRequest) { - self.pending_request = Some(pending_request.clone()); - self.updated_at_unix = pending_request.created_at_unix; - } - - pub fn authorize_auth_challenge( - &mut self, - authorized_at_unix: u64, - ) -> Option<RadrootsNostrSignerPendingRequest> { - self.auth_state = RadrootsNostrSignerAuthState::Authorized; - if let Some(auth_challenge) = self.auth_challenge.as_mut() { - auth_challenge.mark_authorized(authorized_at_unix); - } - self.last_authenticated_at_unix = Some(authorized_at_unix); - self.updated_at_unix = authorized_at_unix; - self.pending_request.take() - } - - pub fn restore_pending_auth_challenge( - &mut self, - pending_request: RadrootsNostrSignerPendingRequest, - restored_at_unix: u64, - ) { - self.auth_state = RadrootsNostrSignerAuthState::Pending; - if let Some(auth_challenge) = self.auth_challenge.as_mut() { - let previous_authorized_at_unix = auth_challenge.authorized_at_unix.take(); - if self.last_authenticated_at_unix == previous_authorized_at_unix { - self.last_authenticated_at_unix = None; - } - } - self.pending_request = Some(pending_request); - self.updated_at_unix = restored_at_unix; - } -} - -impl RadrootsNostrSignerRequestAuditRecord { - pub fn new( - request_id: RadrootsNostrSignerRequestId, - connection_id: RadrootsNostrSignerConnectionId, - method: Method, - decision: RadrootsNostrSignerRequestDecision, - message: Option<String>, - created_at_unix: u64, - ) -> Self { - Self { - request_id, - connection_id, - method, - decision, - message, - created_at_unix, - } - } -} - -impl RadrootsNostrSignerPublishWorkflowRecord { - pub fn new_connect_secret_finalization( - connection_id: RadrootsNostrSignerConnectionId, - created_at_unix: u64, - ) -> Self { - Self { - workflow_id: RadrootsNostrSignerWorkflowId::new_v7(), - connection_id, - kind: RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization, - state: RadrootsNostrSignerPublishWorkflowState::PendingPublish, - pending_request: None, - authorized_at_unix: None, - created_at_unix, - updated_at_unix: created_at_unix, - } - } - - pub fn new_auth_replay_finalization( - connection_id: RadrootsNostrSignerConnectionId, - pending_request: RadrootsNostrSignerPendingRequest, - authorized_at_unix: u64, - ) -> Self { - Self { - workflow_id: RadrootsNostrSignerWorkflowId::new_v7(), - connection_id, - kind: RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization, - state: RadrootsNostrSignerPublishWorkflowState::PendingPublish, - pending_request: Some(pending_request), - authorized_at_unix: Some(authorized_at_unix), - created_at_unix: authorized_at_unix, - updated_at_unix: authorized_at_unix, - } - } - - pub fn mark_published(&mut self, updated_at_unix: u64) { - self.state = RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize; - self.updated_at_unix = updated_at_unix; - } -} - -impl Default for RadrootsNostrSignerStoreState { - fn default() -> Self { - Self { - version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, - signer_identity: None, - connections: Vec::new(), - audit_records: Vec::new(), - publish_workflows: Vec::new(), - } - } -} - -fn serialize_permission<S>(permission: &Permission, serializer: S) -> Result<S::Ok, S::Error> -where - S: serde::Serializer, -{ - serializer.serialize_str(&permission.to_string()) -} - -fn deserialize_permission<'de, D>(deserializer: D) -> Result<Permission, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = String::deserialize(deserializer)?; - value.parse().map_err(serde::de::Error::custom) -} - -fn deserialize_connect_secret_hash_option<'de, D>( - deserializer: D, -) -> Result<Option<RadrootsNostrSignerConnectSecretHash>, D::Error> -where - D: Deserializer<'de>, -{ - let value = Option::<RadrootsNostrSignerConnectSecretHashRepr>::deserialize(deserializer)?; - match value { - None => Ok(None), - Some(RadrootsNostrSignerConnectSecretHashRepr::Hash(hash)) => { - hash.normalize().map(Some).map_err(serde::de::Error::custom) - } - Some(RadrootsNostrSignerConnectSecretHashRepr::LegacyPlaintext(secret)) => { - Ok(RadrootsNostrSignerConnectSecretHash::from_secret(&secret)) - } - } -} - -fn normalize_optional_string(value: &str) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; - use crate::signer::test_support::{ - api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key, - primary_relay, synthetic_public_identity, synthetic_public_key, - }; - use nostr::PublicKey; - use serde_json::json; - use std::str::FromStr; - use tempfile::tempdir; - - fn public_identity(index: u32) -> PublicIdentity { - synthetic_public_identity(index) - } - - fn public_key(index: u32) -> PublicKey { - synthetic_public_key(index) - } - - fn request_message(id: &str) -> RequestMessage { - RequestMessage::new(id, radroots_nostr_connect::Request::Ping) - } - - #[test] - fn connection_and_request_ids_parse_and_display() { - let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection"); - let request_id = RadrootsNostrSignerRequestId::parse("req-1").expect("request"); - let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-1").expect("workflow"); - - assert_eq!(connection_id.as_str(), "conn-1"); - assert_eq!(request_id.as_str(), "req-1"); - assert_eq!(workflow_id.as_str(), "wf-1"); - assert_eq!(connection_id.as_ref(), "conn-1"); - assert_eq!(request_id.as_ref(), "req-1"); - assert_eq!(workflow_id.as_ref(), "wf-1"); - assert_eq!(connection_id.to_string(), "conn-1"); - assert_eq!(request_id.to_string(), "req-1"); - assert_eq!(workflow_id.to_string(), "wf-1"); - assert_eq!(connection_id.clone().into_string(), "conn-1"); - assert_eq!(request_id.clone().into_string(), "req-1"); - assert_eq!(workflow_id.clone().into_string(), "wf-1"); - - let parsed_connection = - RadrootsNostrSignerConnectionId::from_str("conn-1").expect("from_str connection"); - let parsed_request = - RadrootsNostrSignerRequestId::from_str("req-1").expect("from_str request"); - let parsed_workflow = - RadrootsNostrSignerWorkflowId::from_str("wf-1").expect("from_str workflow"); - assert_eq!(parsed_connection, connection_id); - assert_eq!(parsed_request, request_id); - assert_eq!(parsed_workflow, workflow_id); - } - - #[test] - fn generated_ids_are_non_empty() { - let connection_id = RadrootsNostrSignerConnectionId::new_v7(); - let request_id = RadrootsNostrSignerRequestId::new_v7(); - let workflow_id = RadrootsNostrSignerWorkflowId::new_v7(); - - assert!(!connection_id.as_ref().is_empty()); - assert!(!request_id.as_ref().is_empty()); - assert!(!workflow_id.as_ref().is_empty()); - } - - #[test] - fn ids_reject_empty_values() { - let connection_err = - RadrootsNostrSignerConnectionId::parse(" ").expect_err("empty connection"); - let request_err = RadrootsNostrSignerRequestId::parse("").expect_err("empty request"); - let workflow_err = RadrootsNostrSignerWorkflowId::parse(" ").expect_err("empty workflow"); - - assert!(connection_err.to_string().contains("invalid connection id")); - assert!(request_err.to_string().contains("invalid request id")); - assert!(workflow_err.to_string().contains("invalid workflow id")); - } - - #[test] - fn connection_draft_builders_apply_values() { - let permission = Permission::with_parameter(Method::SignEvent, "kind:1"); - let relay = primary_relay(); - let metadata = ClientMetadata { - requested_permissions: Permissions::default(), - name: Some("Example Client".into()), - url: None, - image: None, - }; - let draft = RadrootsNostrSignerConnectionDraft::new( - fixture_carol_public_key(), - fixture_bob_identity(), - ) - .with_connect_secret(" secret ") - .with_client_metadata(metadata.clone()) - .with_requested_permissions(vec![permission.clone()].into()) - .with_relays(vec![relay.clone()]) - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); - - assert_eq!(draft.connect_secret.as_deref(), Some(" secret ")); - assert_eq!(draft.client_metadata.as_ref(), Some(&metadata)); - assert_eq!(draft.requested_permissions.as_slice(), &[permission]); - assert_eq!(draft.relays, vec![relay]); - assert_eq!( - draft.approval_requirement, - RadrootsNostrSignerApprovalRequirement::ExplicitUser - ); - } - - #[test] - fn connection_record_defaults_follow_approval_requirement_and_tracking_helpers() { - let signer_identity = fixture_alice_identity(); - let user_identity = fixture_bob_identity(); - let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id"); - let draft = - RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity) - .with_connect_secret(" secret ") - .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); - let mut record = - RadrootsNostrSignerConnectionRecord::new(connection_id, signer_identity, draft, 10); - - assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Pending); - assert_eq!( - record.approval_state, - RadrootsNostrSignerApprovalState::Pending - ); - assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired); - assert!( - record - .connect_secret_hash - .as_ref() - .expect("connect secret hash") - .matches_secret("secret") - ); - assert!(!record.connect_secret_is_consumed()); - assert!(!record.is_terminal()); - - record.touch_updated(12); - record.mark_authenticated(14); - record.mark_request(16); - record.mark_connect_secret_consumed(17); - record.require_auth_challenge( - RadrootsNostrSignerAuthChallenge::new( - format!("{}/path", api_primary_https()).as_str(), - 18, - ) - .expect("auth challenge"), - ); - record.set_pending_request( - RadrootsNostrSignerPendingRequest::new(request_message("req-1"), 20) - .expect("pending request"), - ); - let replay = record.authorize_auth_challenge(22).expect("replay"); - let no_challenge_replay = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::parse("conn-1b").expect("id"), - public_identity(0x9), - RadrootsNostrSignerConnectionDraft::new(public_key(0x10), public_identity(0x11)), - 24, - ) - .authorize_auth_challenge(25); - - assert_eq!(record.updated_at_unix, 22); - assert_eq!(record.connect_secret_consumed_at_unix, Some(17)); - assert!(record.connect_secret_is_consumed()); - assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Authorized); - assert_eq!( - record - .auth_challenge - .as_ref() - .expect("auth challenge") - .authorized_at_unix, - Some(22) - ); - assert!(record.pending_request.is_none()); - assert_eq!(record.last_authenticated_at_unix, Some(22)); - assert_eq!(record.last_request_at_unix, Some(16)); - assert_eq!(replay.request_id().as_str(), "req-1"); - assert!(no_challenge_replay.is_none()); - - record.restore_pending_auth_challenge(replay, 23); - - assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Pending); - assert_eq!( - record - .auth_challenge - .as_ref() - .expect("restored challenge") - .authorized_at_unix, - None - ); - assert_eq!(record.last_authenticated_at_unix, None); - assert_eq!(record.updated_at_unix, 23); - assert_eq!( - record - .pending_request - .as_ref() - .expect("restored pending request") - .request_id() - .as_str(), - "req-1" - ); - } - - #[test] - fn connection_record_noop_consumption_and_restore_paths_preserve_state() { - let mut no_secret_record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::parse("conn-no-secret").expect("id"), - public_identity(0x12), - RadrootsNostrSignerConnectionDraft::new(public_key(0x13), public_identity(0x14)), - 30, - ); - let no_secret_updated_at = no_secret_record.updated_at_unix; - assert!(!no_secret_record.connect_secret_is_consumed()); - - no_secret_record.mark_connect_secret_consumed(31); - - assert_eq!(no_secret_record.connect_secret_consumed_at_unix, None); - assert_eq!(no_secret_record.updated_at_unix, no_secret_updated_at); - assert!(!no_secret_record.connect_secret_is_consumed()); - - let restored_without_challenge = - RadrootsNostrSignerPendingRequest::new(request_message("req-no-challenge"), 32) - .expect("pending request"); - no_secret_record.last_authenticated_at_unix = Some(29); - no_secret_record.restore_pending_auth_challenge(restored_without_challenge.clone(), 33); - - assert_eq!(no_secret_record.last_authenticated_at_unix, Some(29)); - assert_eq!( - no_secret_record.pending_request.as_ref(), - Some(&restored_without_challenge) - ); - assert_eq!(no_secret_record.updated_at_unix, 33); - - let mut restored_record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::parse("conn-restore-preserve").expect("id"), - public_identity(0x15), - RadrootsNostrSignerConnectionDraft::new(public_key(0x16), public_identity(0x17)), - 40, - ); - restored_record.require_auth_challenge( - RadrootsNostrSignerAuthChallenge::new( - format!("{}/preserve", api_primary_https()).as_str(), - 41, - ) - .expect("auth challenge"), - ); - restored_record.set_pending_request( - RadrootsNostrSignerPendingRequest::new(request_message("req-preserve"), 42) - .expect("pending request"), - ); - let replay = restored_record - .authorize_auth_challenge(43) - .expect("authorize challenge"); - restored_record.last_authenticated_at_unix = Some(99); - - restored_record.restore_pending_auth_challenge(replay.clone(), 44); - - assert_eq!( - restored_record.auth_state, - RadrootsNostrSignerAuthState::Pending - ); - assert_eq!(restored_record.last_authenticated_at_unix, Some(99)); - assert_eq!( - restored_record - .auth_challenge - .as_ref() - .expect("restored challenge") - .authorized_at_unix, - None - ); - assert_eq!(restored_record.pending_request.as_ref(), Some(&replay)); - assert_eq!(restored_record.updated_at_unix, 44); - } - - #[test] - fn granted_permissions_and_request_audit_build_correctly() { - let permission = Permission::new(Method::Ping); - let grant = RadrootsNostrSignerPermissionGrant::new(permission.clone(), 42); - let mut record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"), - public_identity(0x6), - RadrootsNostrSignerConnectionDraft::new(public_key(0x7), public_identity(0x8)), - 20, - ); - record.granted_permissions = vec![grant]; - let audit = RadrootsNostrSignerRequestAuditRecord::new( - RadrootsNostrSignerRequestId::parse("req-2").expect("request"), - RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"), - Method::Ping, - RadrootsNostrSignerRequestDecision::Allowed, - Some("ok".into()), - 25, - ); - - assert_eq!(record.granted_permissions().as_slice(), &[permission]); - assert_eq!(audit.message.as_deref(), Some("ok")); - assert_eq!(audit.created_at_unix, 25); - - let json = serde_json::to_string(&record.granted_permissions[0]).expect("serialize grant"); - let decoded: RadrootsNostrSignerPermissionGrant = - serde_json::from_str(&json).expect("deserialize grant"); - assert_eq!(decoded.permission, Permission::new(Method::Ping)); - } - - #[test] - fn publish_workflow_records_cover_connect_secret_and_auth_replay_lifecycle() { - let connection_id = RadrootsNostrSignerConnectionId::parse("conn-workflow").expect("id"); - let pending_request = - RadrootsNostrSignerPendingRequest::new(request_message("req-workflow"), 41) - .expect("pending request"); - - let connect_secret = - RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( - connection_id.clone(), - 40, - ); - assert_eq!( - connect_secret.kind, - RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization - ); - assert_eq!( - connect_secret.state, - RadrootsNostrSignerPublishWorkflowState::PendingPublish - ); - assert!(connect_secret.pending_request.is_none()); - assert!(connect_secret.authorized_at_unix.is_none()); - - let mut auth_replay = - RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization( - connection_id, - pending_request.clone(), - 42, - ); - assert_eq!( - auth_replay.kind, - RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization - ); - assert_eq!( - auth_replay.state, - RadrootsNostrSignerPublishWorkflowState::PendingPublish - ); - assert_eq!(auth_replay.pending_request, Some(pending_request)); - assert_eq!(auth_replay.authorized_at_unix, Some(42)); - - auth_replay.mark_published(43); - assert_eq!( - auth_replay.state, - RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize - ); - assert_eq!(auth_replay.updated_at_unix, 43); - } - - #[test] - fn effective_permissions_prefers_grants_then_auto_requested_then_empty() { - let requested: Permissions = vec![Permission::new(Method::Nip04Encrypt)].into(); - let auto_record = RadrootsNostrSignerConnectionRecord::new( - RadrootsNostrSignerConnectionId::new_v7(), - public_identity(0x31), - RadrootsNostrSignerConnectionDraft::new(public_key(0x32), public_identity(0x33)) - .with_requested_permissions(requested.clone()), - 1, - ); - assert_eq!(auto_record.effective_permissions(), requested); - - let mut granted_record = auto_record.clone(); - granted_record.granted_permissions = vec![RadrootsNostrSignerPermissionGrant::new( - Permission::new(Method::Ping), - 2, - )]; - assert_eq!( - granted_record.effective_permissions(), - vec![Permission::new(Method::Ping)].into() - ); - - let mut approved_without_grants = auto_record; - approved_without_grants.approval_state = RadrootsNostrSignerApprovalState::Approved; - assert!(approved_without_grants.effective_permissions().is_empty()); - } - - #[test] - fn permission_serde_helpers_round_trip_through_wrapper() { - #[derive(Debug, Serialize, Deserialize)] - struct PermissionWrapper { - #[serde( - serialize_with = "serialize_permission", - deserialize_with = "deserialize_permission" - )] - permission: Permission, - } - - let wrapper = PermissionWrapper { - permission: Permission::with_parameter(Method::SignEvent, "kind:1"), - }; - - let json = serde_json::to_vec_pretty(&wrapper).expect("serialize wrapper"); - let temp = tempdir().expect("tempdir"); - let path = temp.path().join("permission.json"); - std::fs::write(&path, &json).expect("write permission"); - let file = std::fs::File::open(&path).expect("open permission"); - let reader = std::io::BufReader::new(file); - let decoded: PermissionWrapper = - serde_json::from_reader(reader).expect("deserialize wrapper"); - - assert_eq!(decoded.permission, wrapper.permission); - - let value = serde_json::to_value(&wrapper).expect("serialize wrapper to value"); - let decoded_from_value: PermissionWrapper = - serde_json::from_value(value).expect("deserialize wrapper from value"); - assert_eq!(decoded_from_value.permission, wrapper.permission); - - let invalid = serde_json::from_str::<PermissionWrapper>(r#"{"permission":1}"#) - .expect_err("invalid permission type"); - assert!(invalid.to_string().contains("invalid type")); - - let invalid_from_value = - serde_json::from_value::<PermissionWrapper>(json!({ "permission": 1 })) - .expect_err("invalid permission type from value"); - assert!(invalid_from_value.to_string().contains("invalid type")); - - let invalid_path = temp.path().join("invalid-permission.json"); - std::fs::write(&invalid_path, br#"{"permission":1}"#).expect("write invalid permission"); - let invalid_file = std::fs::File::open(&invalid_path).expect("open invalid permission"); - let invalid_reader = std::io::BufReader::new(invalid_file); - let invalid_from_reader = serde_json::from_reader::<_, PermissionWrapper>(invalid_reader) - .expect_err("invalid permission type from reader"); - assert!(invalid_from_reader.to_string().contains("invalid type")); - } - - #[test] - fn connect_secret_hash_and_pending_request_helpers_validate_inputs() { - let hash = - RadrootsNostrSignerConnectSecretHash::from_secret(" secret ").expect("secret hash"); - assert!(hash.matches_secret("secret")); - assert!(!hash.matches_secret("other")); - assert!(RadrootsNostrSignerConnectSecretHash::from_secret(" ").is_none()); - - let pending = RadrootsNostrSignerPendingRequest::new(request_message("req-2"), 30) - .expect("pending request"); - assert_eq!(pending.request_id().as_str(), "req-2"); - assert_eq!(pending.request_message().id, "req-2"); - - let invalid_pending = RadrootsNostrSignerPendingRequest::new(request_message(" "), 30) - .expect_err("invalid pending request id"); - assert!(invalid_pending.to_string().contains("invalid request id")); - - let auth_url = format!(" {} ", api_primary_https()); - let challenge = - RadrootsNostrSignerAuthChallenge::new(auth_url.as_str(), 31).expect("challenge"); - assert_eq!(challenge.auth_url, format!("{}/", api_primary_https())); - - let invalid_challenge = - RadrootsNostrSignerAuthChallenge::new("not-a-url", 31).expect_err("invalid challenge"); - assert!(invalid_challenge.to_string().contains("invalid auth url")); - - let empty_challenge = - RadrootsNostrSignerAuthChallenge::new(" ", 31).expect_err("empty challenge"); - assert!(empty_challenge.to_string().contains("invalid auth url")); - } - - #[test] - fn auth_challenge_deserialize_rejects_invalid_urls_across_entrypoints() { - let invalid_json = json!({ - "auth_url": " ", - "required_at_unix": 44 - }); - - let invalid_from_value = - serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_json.clone()) - .expect_err("invalid auth challenge from value"); - assert!(invalid_from_value.to_string().contains("invalid auth url")); - - let invalid_from_str = - serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(&invalid_json.to_string()) - .expect_err("invalid auth challenge from str"); - assert!(invalid_from_str.to_string().contains("invalid auth url")); - - let temp = tempdir().expect("tempdir"); - let path = temp.path().join("invalid-auth-challenge.json"); - std::fs::write( - &path, - serde_json::to_vec(&invalid_json).expect("serialize invalid auth challenge"), - ) - .expect("write invalid auth challenge"); - let file = std::fs::File::open(&path).expect("open invalid auth challenge"); - let reader = std::io::BufReader::new(file); - let invalid_from_reader = - serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(reader) - .expect_err("invalid auth challenge from reader"); - assert!(invalid_from_reader.to_string().contains("invalid auth url")); - - let invalid_shape_json = json!({ - "auth_url": 1, - "required_at_unix": 44 - }); - let invalid_shape_from_value = - serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_shape_json.clone()) - .expect_err("invalid auth challenge shape from value"); - assert!( - invalid_shape_from_value - .to_string() - .contains("invalid type") - ); - - let invalid_shape_from_str = serde_json::from_str::<RadrootsNostrSignerAuthChallenge>( - &invalid_shape_json.to_string(), - ) - .expect_err("invalid auth challenge shape from str"); - assert!(invalid_shape_from_str.to_string().contains("invalid type")); - - let invalid_shape_path = temp.path().join("invalid-auth-challenge-shape.json"); - std::fs::write( - &invalid_shape_path, - serde_json::to_vec(&invalid_shape_json) - .expect("serialize invalid auth challenge shape"), - ) - .expect("write invalid auth challenge shape"); - let invalid_shape_file = - std::fs::File::open(&invalid_shape_path).expect("open invalid auth challenge shape"); - let invalid_shape_reader = std::io::BufReader::new(invalid_shape_file); - let invalid_shape_from_reader = - serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(invalid_shape_reader) - .expect_err("invalid auth challenge shape from reader"); - assert!( - invalid_shape_from_reader - .to_string() - .contains("invalid type") - ); - } - - #[test] - fn connection_record_serde_migrates_legacy_connect_secret_and_validates_new_fields() { - let record_json = json!({ - "connection_id": "conn-legacy", - "client_public_key": public_key(0x9).to_hex(), - "signer_identity": public_identity(0x10), - "user_identity": public_identity(0x11), - "connect_secret": " legacy-secret ", - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "status_reason": null, - "created_at_unix": 1, - "updated_at_unix": 1, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - }); - - let decoded_without_secret: RadrootsNostrSignerConnectionRecord = - serde_json::from_value(json!({ - "connection_id": "conn-no-secret", - "client_public_key": public_key(0x8).to_hex(), - "signer_identity": public_identity(0x7), - "user_identity": public_identity(0x6), - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 0, - "updated_at_unix": 0, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect("deserialize record without secret"); - assert!(decoded_without_secret.connect_secret_hash.is_none()); - assert!(decoded_without_secret.client_metadata.is_none()); - assert!( - decoded_without_secret - .connect_secret_consumed_at_unix - .is_none() - ); - - let decoded_with_null_secret: RadrootsNostrSignerConnectionRecord = - serde_json::from_value(json!({ - "connection_id": "conn-null-secret", - "client_public_key": public_key(0x5).to_hex(), - "signer_identity": public_identity(0x4), - "user_identity": public_identity(0x3), - "connect_secret_hash": null, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 0, - "updated_at_unix": 0, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect("deserialize record with null secret"); - assert!(decoded_with_null_secret.connect_secret_hash.is_none()); - assert!( - decoded_with_null_secret - .connect_secret_consumed_at_unix - .is_none() - ); - - let decoded: RadrootsNostrSignerConnectionRecord = - serde_json::from_value(record_json).expect("deserialize legacy record"); - assert!( - decoded - .connect_secret_hash - .as_ref() - .expect("connect secret hash") - .matches_secret("legacy-secret") - ); - - let encoded = serde_json::to_value(&decoded).expect("serialize record"); - assert!(encoded.get("connect_secret").is_none()); - assert!(encoded.get("connect_secret_hash").is_some()); - assert!(encoded.get("connect_secret_consumed_at_unix").is_none()); - assert_eq!( - encoded - .get("auth_state") - .and_then(serde_json::Value::as_str), - Some("NotRequired") - ); - - let valid_hash = RadrootsNostrSignerConnectSecretHash::from_secret("explicit-secret") - .expect("valid hash"); - let decoded_new_format: RadrootsNostrSignerConnectionRecord = - serde_json::from_value(json!({ - "connection_id": "conn-new", - "client_public_key": public_key(0x15).to_hex(), - "signer_identity": public_identity(0x16), - "user_identity": public_identity(0x17), - "connect_secret_hash": { - "algorithm": "sha256", - "digest_hex": valid_hash.digest_hex - }, - "connect_secret_consumed_at_unix": 23, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 3, - "updated_at_unix": 3, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect("deserialize new-format record"); - assert!( - decoded_new_format - .connect_secret_hash - .as_ref() - .expect("new-format hash") - .matches_secret("explicit-secret") - ); - assert_eq!(decoded_new_format.connect_secret_consumed_at_unix, Some(23)); - assert!(decoded_new_format.connect_secret_is_consumed()); - - let temp = tempdir().expect("tempdir"); - let path = temp.path().join("connection-record.json"); - let reader_json = json!({ - "connection_id": "conn-reader", - "client_public_key": public_key(0x21).to_hex(), - "signer_identity": public_identity(0x22), - "user_identity": public_identity(0x23), - "connect_secret_hash": { - "algorithm": "sha256", - "digest_hex": RadrootsNostrSignerConnectSecretHash::from_secret("reader-secret") - .expect("reader hash") - .digest_hex - }, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "auth_state": "Pending", - "auth_challenge": { - "auth_url": format!("{}/reader", api_primary_https()), - "required_at_unix": 5 - }, - "status": "Active", - "created_at_unix": 5, - "updated_at_unix": 5, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - }); - std::fs::write( - &path, - serde_json::to_vec(&reader_json).expect("serialize reader json"), - ) - .expect("write reader json"); - let file = std::fs::File::open(&path).expect("open reader json"); - let reader = std::io::BufReader::new(file); - let decoded_from_reader: RadrootsNostrSignerConnectionRecord = - serde_json::from_reader(reader).expect("deserialize reader record"); - assert!( - decoded_from_reader - .connect_secret_hash - .as_ref() - .expect("reader hash") - .matches_secret("reader-secret") - ); - assert_eq!( - decoded_from_reader - .auth_challenge - .as_ref() - .expect("reader auth challenge") - .auth_url, - format!("{}/reader", api_primary_https()) - ); - - let invalid_hash_json = json!({ - "connection_id": "conn-invalid", - "client_public_key": public_key(0x12).to_hex(), - "signer_identity": public_identity(0x13), - "user_identity": public_identity(0x14), - "connect_secret_hash": { - "algorithm": "sha256", - "digest_hex": "not-hex" - }, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "auth_state": "Authorized", - "auth_challenge": { - "auth_url": api_primary_https(), - "required_at_unix": 2 - }, - "status_reason": null, - "created_at_unix": 2, - "updated_at_unix": 2, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - }); - let invalid_hash = - serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(invalid_hash_json) - .expect_err("invalid hash"); - assert!( - invalid_hash - .to_string() - .contains("invalid connect secret digest") - ); - - let invalid_nonhex_hash = - serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({ - "connection_id": "conn-invalid-nonhex", - "client_public_key": public_key(0x18).to_hex(), - "signer_identity": public_identity(0x19), - "user_identity": public_identity(0x20), - "connect_secret_hash": { - "algorithm": "sha256", - "digest_hex": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" - }, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 4, - "updated_at_unix": 4, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect_err("invalid nonhex hash"); - assert!( - invalid_nonhex_hash - .to_string() - .contains("invalid connect secret digest") - ); - - let invalid_connect_secret_hash_type = - serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({ - "connection_id": "conn-invalid-type", - "client_public_key": public_key(0x24).to_hex(), - "signer_identity": public_identity(0x25), - "user_identity": public_identity(0x26), - "connect_secret_hash": 7, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 6, - "updated_at_unix": 6, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect_err("invalid connect secret hash type"); - assert!(!invalid_connect_secret_hash_type.to_string().is_empty()); - - let invalid_connect_secret_hash_path = temp.path().join("invalid-connect-secret-type.json"); - std::fs::write( - &invalid_connect_secret_hash_path, - serde_json::to_vec(&json!({ - "connection_id": "conn-invalid-type-reader", - "client_public_key": public_key(0x27).to_hex(), - "signer_identity": public_identity(0x28), - "user_identity": public_identity(0x29), - "connect_secret_hash": 9, - "requested_permissions": "", - "granted_permissions": [], - "relays": [], - "approval_requirement": "NotRequired", - "approval_state": "NotRequired", - "status": "Active", - "created_at_unix": 7, - "updated_at_unix": 7, - "last_authenticated_at_unix": null, - "last_request_at_unix": null - })) - .expect("serialize invalid connect secret hash type"), - ) - .expect("write invalid connect secret hash type"); - let invalid_connect_secret_hash_file = - std::fs::File::open(&invalid_connect_secret_hash_path) - .expect("open invalid connect secret hash type"); - let invalid_connect_secret_hash_reader = - std::io::BufReader::new(invalid_connect_secret_hash_file); - let invalid_connect_secret_hash_from_reader = serde_json::from_reader::< - _, - RadrootsNostrSignerConnectionRecord, - >(invalid_connect_secret_hash_reader) - .expect_err("invalid connect secret hash type from reader"); - assert!( - !invalid_connect_secret_hash_from_reader - .to_string() - .is_empty() - ); - } - - #[test] - fn store_state_default_is_empty() { - let state = RadrootsNostrSignerStoreState::default(); - assert_eq!(state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION); - assert!(state.signer_identity.is_none()); - assert!(state.connections.is_empty()); - assert!(state.audit_records.is_empty()); - } -} diff --git a/src/signer/nip46.rs b/src/signer/nip46.rs @@ -1,2191 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use nostr::{ - JsonUtil, UnsignedEvent, - filter::{Alphabet, SingleLetterTag}, -}; -use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl}; -use radroots_nostr::event::Event as RadrootsNostrEvent; -use radroots_nostr::event::GenericBuilder; -use radroots_nostr::event::Kind as RadrootsNostrKind; -use radroots_nostr::event::Timestamp as RadrootsNostrTimestamp; -use radroots_nostr::filter::Filter as RadrootsNostrFilter; -use radroots_nostr::tag::Tag as RadrootsNostrTag; -use radroots_nostr_connect::{ - Error as ConnectError, Request, Response, - message::{ - RPC_KIND, RequestMessage, SignedEvent as ConnectSignedEvent, - UnsignedEvent as ConnectUnsignedEvent, - }, - permission::Permissions, -}; - -use crate::signer::backend::RadrootsNostrSignerBackend; -use crate::signer::error::RadrootsNostrSignerError; -use crate::signer::evaluation::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction, - RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint, - RadrootsNostrSignerSessionLookup, -}; -use crate::signer::model::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionId, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord, - RadrootsNostrSignerRequestDecision, -}; - -/// Cryptographic operations required by the external NIP-46 protocol. -/// -/// NIP-46 `sign_event` accepts caller-supplied unsigned Nostr events. Signing -/// one is protocol interoperability only and does not establish a Radroots -/// typed product-authoring contract. -pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync { - fn signer_public_key_hex(&self) -> String; - fn decrypt_request( - &self, - client_public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError>; - fn encrypt_response( - &self, - client_public_key: &RadrootsNostrPublicKey, - payload: &str, - ) -> Result<String, RadrootsNostrSignerError>; - fn user_identity(&self) -> PublicIdentity; - /// Signs a caller-supplied NIP-46 unsigned event without claiming typed - /// Radroots product authoring. - fn sign_user_event( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError>; - fn nip04_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, RadrootsNostrSignerError>; - fn nip04_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError>; - fn nip44_encrypt( - &self, - public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, RadrootsNostrSignerError>; - fn nip44_decrypt( - &self, - public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsNostrSignerNip46ConnectDecision { - Allow, - RequireApproval, - Deny, -} - -pub trait RadrootsNostrSignerNip46Policy<B: RadrootsNostrSignerBackend>: - Clone + Send + Sync -{ - fn connect_decision( - &self, - client_public_key: &RadrootsNostrPublicKey, - ) -> RadrootsNostrSignerNip46ConnectDecision; - - fn connect_rate_limit_denied_reason( - &self, - client_public_key: &RadrootsNostrPublicKey, - ) -> Option<String>; - - fn approval_requirement_for_client( - &self, - client_public_key: &RadrootsNostrPublicKey, - ) -> Option<RadrootsNostrSignerApprovalRequirement>; - - fn filtered_requested_permissions(&self, requested_permissions: &Permissions) -> Permissions; - - fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions; - - fn prepare_request( - &self, - backend: &B, - connection: &RadrootsNostrSignerConnectionRecord, - request_message: &RequestMessage, - ) -> Result<Option<String>, RadrootsNostrSignerError>; -} - -#[derive(Clone)] -pub struct RadrootsNostrSignerNip46Codec<S> { - signer: S, -} - -#[derive(Clone)] -pub struct RadrootsNostrSignerNip46Handler<B, P, S> { - backend: B, - policy: P, - relays: Vec<RadrootsNostrRelayUrl>, - codec: RadrootsNostrSignerNip46Codec<S>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrSignerHandledRequest { - Respond { - response: Box<Response>, - connection_id: Option<RadrootsNostrSignerConnectionId>, - consume_connect_secret_for: Option<RadrootsNostrSignerConnectionId>, - }, - Ignore, -} - -#[derive(Debug, Clone)] -pub struct RadrootsNostrSignerHandledRequestOutcome { - pub handled_request: RadrootsNostrSignerHandledRequest, - pub audit: Option<RadrootsNostrSignerRequestAuditRecord>, -} - -enum RadrootsNostrSignerPreparedRequestEvaluation { - Denied { - reason: String, - audit: RadrootsNostrSignerRequestAuditRecord, - }, - Evaluation(Box<RadrootsNostrSignerRequestEvaluation>), -} - -impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { - pub fn new(signer: S) -> Self { - Self { signer } - } - - pub fn filter(&self) -> Result<RadrootsNostrFilter, RadrootsNostrSignerError> { - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(RPC_KIND)) - .since(RadrootsNostrTimestamp::now()); - Ok(filter.custom_tags( - SingleLetterTag::lowercase(Alphabet::P), - vec![self.signer.signer_public_key_hex()], - )) - } - - pub fn parse_request_event( - &self, - event: &RadrootsNostrEvent, - ) -> Result<RequestMessage, RadrootsNostrSignerError> { - let decrypted = self.signer.decrypt_request(&event.pubkey, &event.content)?; - Ok(serde_json::from_str(&decrypted).map_err(ConnectError::from)?) - } - - pub fn build_response_event( - &self, - client_public_key: RadrootsNostrPublicKey, - request_id: impl Into<String>, - response: Response, - ) -> Result<GenericBuilder, RadrootsNostrSignerError> { - let envelope = response.into_envelope(request_id.into())?; - let payload = serde_json::to_string(&envelope).map_err(ConnectError::from)?; - let ciphertext = self.signer.encrypt_response(&client_public_key, &payload)?; - - Ok( - GenericBuilder::new(RadrootsNostrKind::Custom(RPC_KIND), ciphertext) - .tags(vec![RadrootsNostrTag::public_key(client_public_key)]), - ) - } - - /// Produces a NIP-46 response for an externally supplied unsigned event. - /// - /// A successful response proves only protocol signing. It does not confer - /// a Radroots typed-authoring or product-admission claim. - pub fn sign_event_response( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<Response, RadrootsNostrSignerError> { - let unsigned_event = ConnectUnsignedEvent::from_json(&unsigned_event.as_json())?; - Ok(self.sign_event_response_value(unsigned_event)) - } - - fn sign_event_response_value(&self, unsigned_event: ConnectUnsignedEvent) -> Response { - let unsigned_event = match serde_json::from_str::<UnsignedEvent>(&unsigned_event.as_json()) - { - Ok(unsigned_event) => unsigned_event, - Err(error) => { - return Response::Error { - result: None, - error: format!("invalid sign_event payload: {error}"), - }; - } - }; - let user_public_key = self.signer.user_identity().public_key().to_hex(); - if unsigned_event.pubkey.to_hex() != user_public_key { - return Response::Error { - result: None, - error: "sign_event pubkey does not match the managed user identity".to_owned(), - }; - } - - match self.signer.sign_user_event(unsigned_event) { - Ok(event) => match ConnectSignedEvent::from_json(&event.as_json()) { - Ok(event) => Response::SignedEvent(event), - Err(error) => Response::Error { - result: None, - error: format!("failed to encode signed event: {error}"), - }, - }, - Err(error) => Response::Error { - result: None, - error: format!("failed to sign event: {error}"), - }, - } - } - - pub fn crypto_response(&self, request: Request) -> Result<Response, RadrootsNostrSignerError> { - Ok(self.crypto_response_value(request)) - } - - fn crypto_response_value(&self, request: Request) -> Response { - match request { - Request::Nip04Encrypt { - public_key, - plaintext, - } => match nostr_public_key(public_key) - .and_then(|public_key| self.signer.nip04_encrypt(&public_key, &plaintext)) - { - Ok(ciphertext) => Response::Nip04Encrypt(ciphertext), - Err(error) => Response::Error { - result: None, - error: format!("nip04 encrypt failed: {error}"), - }, - }, - Request::Nip04Decrypt { - public_key, - ciphertext, - } => match nostr_public_key(public_key) - .and_then(|public_key| self.signer.nip04_decrypt(&public_key, &ciphertext)) - { - Ok(plaintext) => Response::Nip04Decrypt(plaintext), - Err(error) => Response::Error { - result: None, - error: format!("nip04 decrypt failed: {error}"), - }, - }, - Request::Nip44Encrypt { - public_key, - plaintext, - } => match nostr_public_key(public_key) - .and_then(|public_key| self.signer.nip44_encrypt(&public_key, &plaintext)) - { - Ok(ciphertext) => Response::Nip44Encrypt(ciphertext), - Err(error) => Response::Error { - result: None, - error: format!("nip44 encrypt failed: {error}"), - }, - }, - Request::Nip44Decrypt { - public_key, - ciphertext, - } => match nostr_public_key(public_key) - .and_then(|public_key| self.signer.nip44_decrypt(&public_key, &ciphertext)) - { - Ok(plaintext) => Response::Nip44Decrypt(plaintext), - Err(error) => Response::Error { - result: None, - error: format!("nip44 decrypt failed: {error}"), - }, - }, - other => Response::Error { - result: None, - error: format!("request `{}` is not a crypto method", other.method()), - }, - } - } -} - -impl<B, P, S> RadrootsNostrSignerNip46Handler<B, P, S> -where - B: RadrootsNostrSignerBackend + Clone, - P: RadrootsNostrSignerNip46Policy<B>, - S: RadrootsNostrSignerNip46Signer, -{ - pub fn new(backend: B, policy: P, relays: Vec<RadrootsNostrRelayUrl>, signer: S) -> Self { - Self { - backend, - policy, - relays, - codec: RadrootsNostrSignerNip46Codec::new(signer), - } - } - - pub fn filter(&self) -> Result<RadrootsNostrFilter, RadrootsNostrSignerError> { - self.codec.filter() - } - - pub fn parse_request_event( - &self, - event: &RadrootsNostrEvent, - ) -> Result<RequestMessage, RadrootsNostrSignerError> { - self.codec.parse_request_event(event) - } - - pub fn build_response_event( - &self, - client_public_key: RadrootsNostrPublicKey, - request_id: impl Into<String>, - response: Response, - ) -> Result<GenericBuilder, RadrootsNostrSignerError> { - self.codec - .build_response_event(client_public_key, request_id, response) - } - - pub fn handle_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - match request_message.request.clone() { - Request::Connect { secret, .. } => { - self.handle_connect_request(client_public_key, request_message.request, secret) - } - Request::SignEvent(unsigned_event) => { - self.handle_sign_event_request(client_public_key, request_message, unsigned_event) - } - Request::Nip04Encrypt { .. } - | Request::Nip04Decrypt { .. } - | Request::Nip44Encrypt { .. } - | Request::Nip44Decrypt { .. } => { - self.handle_crypto_request(client_public_key, request_message) - } - Request::GetPublicKey - | Request::GetSessionCapability - | Request::Ping - | Request::SwitchRelays => self.handle_base_request(client_public_key, request_message), - _ => Ok(RadrootsNostrSignerHandledRequestOutcome::new( - RadrootsNostrSignerHandledRequest::respond(Response::Error { - result: None, - error: format!( - "method `{}` is not implemented yet", - request_message.request.method() - ), - }), - None, - )), - } - } - - pub fn handle_authorized_request_evaluation( - &self, - request_message: RequestMessage, - evaluation: RadrootsNostrSignerRequestEvaluation, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - let audit = evaluation.audit.clone(); - let handled_request = self.handled_request_for_evaluation(request_message, evaluation)?; - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - handled_request, - Some(audit), - )) - } - - fn handle_connect_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request: Request, - secret: Option<String>, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - let connect_decision = self.policy.connect_decision(&client_public_key); - if let Some(connect_secret) = secret.as_deref() - && let Some(connection) = self - .backend - .find_connection_by_connect_secret(connect_secret)? - && connection.connect_secret_is_consumed() - { - return Ok(RadrootsNostrSignerHandledRequestOutcome::ignore()); - } - if !matches!( - connect_decision, - RadrootsNostrSignerNip46ConnectDecision::Deny - ) && let Some(reason) = self - .policy - .connect_rate_limit_denied_reason(&client_public_key) - { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond( - Response::Error { - result: None, - error: reason, - }, - )); - } - - let evaluation = self - .backend - .evaluate_connect_request(client_public_key, request)?; - - match evaluation { - RadrootsNostrSignerConnectEvaluation::ExistingConnection(connection) => { - if matches!( - connect_decision, - RadrootsNostrSignerNip46ConnectDecision::Deny - ) { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond( - Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - }, - )); - } - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - connect_response_outcome(&connection, secret), - None, - )) - } - RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => { - let requested_permissions = self - .policy - .filtered_requested_permissions(&proposal.requested_permissions); - let Some(approval_requirement) = self - .policy - .approval_requirement_for_client(&client_public_key) - else { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond( - Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - }, - )); - }; - let draft = proposal - .into_connection_draft(self.codec.signer.user_identity()) - .with_requested_permissions(requested_permissions) - .with_relays(self.relays.clone()) - .with_approval_requirement(approval_requirement); - let connection = self.backend.register_connection(draft)?; - if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired { - let granted_permissions = self - .policy - .auto_granted_permissions(&connection.requested_permissions); - let _ = self - .backend - .set_granted_permissions(&connection.connection_id, granted_permissions)?; - } - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - connect_response_outcome(&connection, secret), - None, - )) - } - } - } - - fn handle_base_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - let connection = match self.lookup_connection(client_public_key)? { - Ok(connection) => connection, - Err(response) => { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response)); - } - }; - - match self.evaluate_request_with_policy(&connection, request_message)? { - RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => { - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - Response::Error { - result: None, - error: reason, - }, - ), - Some(audit), - )) - } - RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => { - let evaluation = *evaluation; - let audit = evaluation.audit.clone(); - let response_hint = match &evaluation.action { - RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { - Some(response_hint.clone()) - } - _ => None, - }; - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - handled_request_for_action(&evaluation.connection, evaluation.action, || { - Ok(response_from_hint( - &evaluation.connection, - response_hint.expect("allowed action carries response hint"), - )) - })?, - Some(audit), - )) - } - } - } - - fn handle_sign_event_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - unsigned_event: ConnectUnsignedEvent, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - let connection = match self.lookup_connection(client_public_key)? { - Ok(connection) => connection, - Err(response) => { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response)); - } - }; - - match self.evaluate_request_with_policy(&connection, request_message)? { - RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => { - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - Response::Error { - result: None, - error: reason, - }, - ), - Some(audit), - )) - } - RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => { - let evaluation = *evaluation; - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - self.handled_request_for_authorized_action( - &evaluation.connection, - evaluation.action, - || Ok(self.codec.sign_event_response_value(unsigned_event)), - )?, - Some(evaluation.audit), - )) - } - } - } - - fn handle_crypto_request( - &self, - client_public_key: RadrootsNostrPublicKey, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { - let request = request_message.request.clone(); - let connection = match self.lookup_connection(client_public_key)? { - Ok(connection) => connection, - Err(response) => { - return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response)); - } - }; - - match self.evaluate_request_with_policy(&connection, request_message)? { - RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => { - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - Response::Error { - result: None, - error: reason, - }, - ), - Some(audit), - )) - } - RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => { - let evaluation = *evaluation; - Ok(RadrootsNostrSignerHandledRequestOutcome::new( - self.handled_request_for_authorized_action( - &evaluation.connection, - evaluation.action, - || Ok(self.codec.crypto_response_value(request)), - )?, - Some(evaluation.audit), - )) - } - } - } - - fn handled_request_for_evaluation( - &self, - request_message: RequestMessage, - evaluation: RadrootsNostrSignerRequestEvaluation, - ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError> { - match request_message.request.clone() { - Request::SignEvent(unsigned_event) => self.handled_request_for_authorized_action( - &evaluation.connection, - evaluation.action, - || Ok(self.codec.sign_event_response_value(unsigned_event)), - ), - Request::Nip04Encrypt { .. } - | Request::Nip04Decrypt { .. } - | Request::Nip44Encrypt { .. } - | Request::Nip44Decrypt { .. } => self.handled_request_for_authorized_action( - &evaluation.connection, - evaluation.action, - || Ok(self.codec.crypto_response_value(request_message.request)), - ), - Request::GetPublicKey - | Request::GetSessionCapability - | Request::Ping - | Request::SwitchRelays => { - let response_hint = match &evaluation.action { - RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { - Some(response_hint.clone()) - } - _ => None, - }; - self.handled_request_for_authorized_action( - &evaluation.connection, - evaluation.action, - || { - Ok(response_from_hint( - &evaluation.connection, - response_hint.expect("allowed action carries response hint"), - )) - }, - ) - } - other => Ok(RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(evaluation.connection.connection_id.clone()), - Response::Error { - result: None, - error: format!("method `{}` is not implemented yet", other.method()), - }, - )), - } - } - - fn handled_request_for_authorized_action<F>( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - action: RadrootsNostrSignerRequestAction, - on_allowed: F, - ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError> - where - F: FnOnce() -> Result<Response, RadrootsNostrSignerError>, - { - handled_request_for_action(connection, action, on_allowed) - } - - fn evaluate_request_with_policy( - &self, - connection: &RadrootsNostrSignerConnectionRecord, - request_message: RequestMessage, - ) -> Result<RadrootsNostrSignerPreparedRequestEvaluation, RadrootsNostrSignerError> { - if let Some(reason) = - self.policy - .prepare_request(&self.backend, connection, &request_message)? - { - let audit = self.backend.record_request( - &connection.connection_id, - &request_message.id, - request_message.request.method(), - RadrootsNostrSignerRequestDecision::Denied, - Some(reason.clone()), - )?; - return Ok(RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit }); - } - - Ok(RadrootsNostrSignerPreparedRequestEvaluation::Evaluation( - Box::new( - self.backend - .evaluate_request(&connection.connection_id, request_message)?, - ), - )) - } - - fn lookup_connection( - &self, - client_public_key: RadrootsNostrPublicKey, - ) -> Result<Result<RadrootsNostrSignerConnectionRecord, Response>, RadrootsNostrSignerError> - { - Ok( - match self.backend.lookup_session(&client_public_key, None)? { - RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(*connection), - RadrootsNostrSignerSessionLookup::None => Err(Response::Error { - result: None, - error: "unauthorized".to_owned(), - }), - RadrootsNostrSignerSessionLookup::Ambiguous(_) => Err(Response::Error { - result: None, - error: "ambiguous client sessions".to_owned(), - }), - }, - ) - } -} - -impl RadrootsNostrSignerHandledRequest { - pub fn respond(response: Response) -> Self { - Self::respond_for_connection(None, response) - } - - pub fn respond_for_connection( - connection_id: Option<RadrootsNostrSignerConnectionId>, - response: Response, - ) -> Self { - Self::Respond { - response: Box::new(response), - connection_id, - consume_connect_secret_for: None, - } - } - - pub fn into_publish_parts( - self, - ) -> Option<( - Response, - Option<RadrootsNostrSignerConnectionId>, - Option<RadrootsNostrSignerConnectionId>, - )> { - match self { - Self::Respond { - response, - connection_id, - consume_connect_secret_for, - } => Some((*response, connection_id, consume_connect_secret_for)), - Self::Ignore => None, - } - } -} - -impl RadrootsNostrSignerHandledRequestOutcome { - pub fn new( - handled_request: RadrootsNostrSignerHandledRequest, - audit: Option<RadrootsNostrSignerRequestAuditRecord>, - ) -> Self { - Self { - handled_request, - audit, - } - } - - pub fn respond(response: Response) -> Self { - Self::new(RadrootsNostrSignerHandledRequest::respond(response), None) - } - - pub fn ignore() -> Self { - Self::new(RadrootsNostrSignerHandledRequest::Ignore, None) - } -} - -pub fn connect_response_outcome( - connection: &RadrootsNostrSignerConnectionRecord, - secret: Option<String>, -) -> RadrootsNostrSignerHandledRequest { - let consume_connect_secret_for = secret.as_ref().map(|_| connection.connection_id.clone()); - RadrootsNostrSignerHandledRequest::Respond { - response: Box::new(match secret { - Some(secret) => Response::ConnectSecretEcho(secret), - None => Response::ConnectAcknowledged, - }), - connection_id: Some(connection.connection_id.clone()), - consume_connect_secret_for, - } -} - -pub fn response_from_hint( - connection: &RadrootsNostrSignerConnectionRecord, - hint: RadrootsNostrSignerRequestResponseHint, -) -> Response { - match hint { - RadrootsNostrSignerRequestResponseHint::Pong => Response::Pong, - RadrootsNostrSignerRequestResponseHint::UserPublicKey(public_key) => { - Response::UserPublicKey(public_key) - } - RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => { - Response::RemoteSessionCapability(capability) - } - RadrootsNostrSignerRequestResponseHint::RelayList(relays) => match connection - .relays - .iter() - .map(|relay| radroots_nostr_connect::uri::RelayUrl::parse(&relay.to_string())) - .collect::<Result<Vec<_>, _>>() - { - Ok(connection_relays) if relays == connection_relays => Response::RelayList(relays), - Ok(connection_relays) => Response::RelayList(connection_relays), - Err(error) => Response::Error { - result: None, - error: format!("invalid connection relay state: {error}"), - }, - }, - RadrootsNostrSignerRequestResponseHint::None => Response::Error { - result: None, - error: "request evaluation did not provide a response hint".to_owned(), - }, - } -} - -fn nostr_public_key( - public_key: radroots_identity::PublicKey, -) -> Result<RadrootsNostrPublicKey, RadrootsNostrSignerError> { - radroots_nostr::key::public_key_to_nostr(public_key).map_err(Into::into) -} - -pub fn handled_request_for_action<F>( - connection: &RadrootsNostrSignerConnectionRecord, - action: RadrootsNostrSignerRequestAction, - on_allowed: F, -) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError> -where - F: FnOnce() -> Result<Response, RadrootsNostrSignerError>, -{ - Ok(match action { - RadrootsNostrSignerRequestAction::Denied { reason } => { - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - Response::Error { - result: None, - error: reason, - }, - ) - } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - Response::AuthUrl(auth_challenge.auth_url), - ) - } - RadrootsNostrSignerRequestAction::Allowed { .. } => { - RadrootsNostrSignerHandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - on_allowed()?, - ) - } - }) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::{ - RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome, - RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Handler, - RadrootsNostrSignerNip46Policy, RadrootsNostrSignerNip46Signer, - }; - use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; - use crate::signer::backend::{RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend}; - use crate::signer::error::RadrootsNostrSignerError; - use crate::signer::evaluation::{ - RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint, - }; - use crate::signer::manager::RadrootsNostrSignerManager; - use crate::signer::model::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, - RadrootsNostrSignerStoreState, - }; - use crate::signer::store::RadrootsNostrSignerStore; - use crate::signer::test_support::{ - fixture_alice_identity, fixture_carol_public_key, primary_relay, - }; - use nostr::PublicKey as RadrootsNostrPublicKey; - use nostr::{JsonUtil, Keys, SecretKey, Timestamp, UnsignedEvent}; - use radroots_identity::PublicKey as IdentityPublicKey; - use radroots_nostr::event::Event as RadrootsNostrEvent; - use radroots_nostr::event::GenericBuilder; - use radroots_nostr::event::Kind as RadrootsNostrKind; - use radroots_nostr::tag::TagKind as RadrootsNostrTagKind; - use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl; - use radroots_nostr_connect::{ - Method, Permission, Request, Response, - message::{ - RPC_KIND, RemoteSessionCapability, RequestMessage, - UnsignedEvent as ConnectUnsignedEvent, - }, - permission::Permissions, - }; - use std::sync::{ - Arc, RwLock, - atomic::{AtomicBool, Ordering}, - }; - - #[derive(Clone)] - struct TestSigner { - signer_identity: Keys, - user_identity: Keys, - sign_events: bool, - fail_crypto: bool, - } - - #[derive(Clone)] - struct TestPolicy { - connect_decision: RadrootsNostrSignerNip46ConnectDecision, - rate_limit_reason: Option<&'static str>, - approval_requirement: Option<RadrootsNostrSignerApprovalRequirement>, - prepare_denial: Option<&'static str>, - } - - #[derive(Clone, Default)] - struct ToggleSaveStore { - state: Arc<RwLock<RadrootsNostrSignerStoreState>>, - fail_saves: Arc<AtomicBool>, - } - - impl RadrootsNostrSignerStore for ToggleSaveStore { - fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { - self.state - .read() - .map(|state| state.clone()) - .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) - } - - fn save( - &self, - state: &RadrootsNostrSignerStoreState, - ) -> Result<(), RadrootsNostrSignerError> { - if self.fail_saves.load(Ordering::SeqCst) { - return Err(RadrootsNostrSignerError::Store( - "test store save failure".into(), - )); - } - self.state - .write() - .map(|mut stored| *stored = state.clone()) - .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) - } - } - - impl Default for TestPolicy { - fn default() -> Self { - Self { - connect_decision: RadrootsNostrSignerNip46ConnectDecision::Allow, - rate_limit_reason: None, - approval_requirement: Some(RadrootsNostrSignerApprovalRequirement::NotRequired), - prepare_denial: None, - } - } - } - - impl RadrootsNostrSignerNip46Signer for TestSigner { - fn signer_public_key_hex(&self) -> String { - self.signer_identity.public_key().to_hex() - } - - fn decrypt_request( - &self, - _client_public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError> { - Ok(ciphertext.to_owned()) - } - - fn encrypt_response( - &self, - _client_public_key: &RadrootsNostrPublicKey, - payload: &str, - ) -> Result<String, RadrootsNostrSignerError> { - Ok(payload.to_owned()) - } - - fn user_identity(&self) -> PublicIdentity { - public_identity_from_keys(&self.user_identity) - } - - fn sign_user_event( - &self, - unsigned_event: UnsignedEvent, - ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError> { - if self.sign_events { - return unsigned_event - .sign_with_keys(&self.user_identity) - .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string())); - } - Err(RadrootsNostrSignerError::Sign( - "test signer does not sign events".to_owned(), - )) - } - - fn nip04_encrypt( - &self, - _public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, RadrootsNostrSignerError> { - if self.fail_crypto { - return Err(RadrootsNostrSignerError::Sign( - "test crypto failure".to_owned(), - )); - } - Ok(plaintext.to_owned()) - } - - fn nip04_decrypt( - &self, - _public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError> { - if self.fail_crypto { - return Err(RadrootsNostrSignerError::Sign( - "test crypto failure".to_owned(), - )); - } - Ok(ciphertext.to_owned()) - } - - fn nip44_encrypt( - &self, - _public_key: &RadrootsNostrPublicKey, - plaintext: &str, - ) -> Result<String, RadrootsNostrSignerError> { - if self.fail_crypto { - return Err(RadrootsNostrSignerError::Sign( - "test crypto failure".to_owned(), - )); - } - Ok(plaintext.to_owned()) - } - - fn nip44_decrypt( - &self, - _public_key: &RadrootsNostrPublicKey, - ciphertext: &str, - ) -> Result<String, RadrootsNostrSignerError> { - if self.fail_crypto { - return Err(RadrootsNostrSignerError::Sign( - "test crypto failure".to_owned(), - )); - } - Ok(ciphertext.to_owned()) - } - } - - impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for TestPolicy { - fn connect_decision( - &self, - _client_public_key: &RadrootsNostrPublicKey, - ) -> RadrootsNostrSignerNip46ConnectDecision { - self.connect_decision - } - - fn connect_rate_limit_denied_reason( - &self, - _client_public_key: &RadrootsNostrPublicKey, - ) -> Option<String> { - self.rate_limit_reason.map(ToOwned::to_owned) - } - - fn approval_requirement_for_client( - &self, - _client_public_key: &RadrootsNostrPublicKey, - ) -> Option<RadrootsNostrSignerApprovalRequirement> { - self.approval_requirement - } - - fn filtered_requested_permissions( - &self, - requested_permissions: &Permissions, - ) -> Permissions { - requested_permissions.clone() - } - - fn auto_granted_permissions(&self, requested_permissions: &Permissions) -> Permissions { - requested_permissions.clone() - } - - fn prepare_request( - &self, - _backend: &B, - _connection: &crate::signer::model::RadrootsNostrSignerConnectionRecord, - _request_message: &RequestMessage, - ) -> Result<Option<String>, RadrootsNostrSignerError> { - Ok(self.prepare_denial.map(ToOwned::to_owned)) - } - } - - fn test_signer() -> TestSigner { - test_signer_with_options(false, false) - } - - fn keys_from_secret(secret_key_hex: &str) -> Keys { - Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key")) - } - - fn public_identity_from_keys(keys: &Keys) -> PublicIdentity { - PublicIdentity::new(keys.public_key()).expect("identity public key") - } - - fn connect_public_key(public_key: RadrootsNostrPublicKey) -> IdentityPublicKey { - radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") - } - - fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl { - ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay") - } - - fn test_signer_with_options(sign_events: bool, fail_crypto: bool) -> TestSigner { - TestSigner { - signer_identity: keys_from_secret( - "1111111111111111111111111111111111111111111111111111111111111111", - ), - user_identity: keys_from_secret( - "2222222222222222222222222222222222222222222222222222222222222222", - ), - sign_events, - fail_crypto, - } - } - - fn embedded_backend() -> RadrootsNostrEmbeddedSignerBackend { - RadrootsNostrEmbeddedSignerBackend::new( - crate::signer::manager::RadrootsNostrSignerManager::new_in_memory(), - test_signer().signer_identity.clone(), - ) - .expect("embedded backend") - } - - fn handler_with_backend( - backend: RadrootsNostrEmbeddedSignerBackend, - ) -> RadrootsNostrSignerNip46Handler<RadrootsNostrEmbeddedSignerBackend, TestPolicy, TestSigner> - { - handler_with_policy(backend, TestPolicy::default()) - } - - fn handler_with_policy( - backend: RadrootsNostrEmbeddedSignerBackend, - policy: TestPolicy, - ) -> RadrootsNostrSignerNip46Handler<RadrootsNostrEmbeddedSignerBackend, TestPolicy, TestSigner> - { - RadrootsNostrSignerNip46Handler::new(backend, policy, vec![primary_relay()], test_signer()) - } - - fn connect_request(secret: Option<&str>) -> RequestMessage { - connect_request_with_permissions(secret, vec![Permission::new(Method::Nip04Encrypt)]) - } - - fn connect_request_with_permissions( - secret: Option<&str>, - permissions: Vec<Permission>, - ) -> RequestMessage { - let signer_public_key = test_signer().signer_identity.public_key(); - RequestMessage::new( - "req-connect", - Request::Connect { - remote_signer_public_key: connect_public_key(signer_public_key), - secret: secret.map(ToOwned::to_owned), - requested_permissions: permissions.into(), - client_metadata: None, - }, - ) - } - - fn all_runtime_permissions() -> Vec<Permission> { - vec![ - Permission::new(Method::SignEvent), - Permission::new(Method::Nip04Encrypt), - Permission::new(Method::Nip04Decrypt), - Permission::new(Method::Nip44Encrypt), - Permission::new(Method::Nip44Decrypt), - Permission::new(Method::SwitchRelays), - ] - } - - fn request_message(id: &str, request: Request) -> RequestMessage { - RequestMessage::new(id, request) - } - - fn unsigned_user_event(kind: u16) -> UnsignedEvent { - serde_json::from_value(serde_json::json!({ - "pubkey": test_signer().user_identity.public_key().to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": "hello", - })) - .expect("unsigned event") - } - - fn connect_unsigned_event(kind: u16) -> ConnectUnsignedEvent { - let event = unsigned_user_event(kind); - ConnectUnsignedEvent::from_json(&event.as_json()).expect("connect unsigned event") - } - - fn registered_connection( - backend: &RadrootsNostrEmbeddedSignerBackend, - client_public_key: &RadrootsNostrPublicKey, - ) -> RadrootsNostrSignerConnectionRecord { - backend - .find_connections_by_client_public_key(client_public_key) - .expect("connections") - .into_iter() - .next() - .expect("connection") - } - - fn connect_with_permissions( - handler: &RadrootsNostrSignerNip46Handler< - RadrootsNostrEmbeddedSignerBackend, - TestPolicy, - TestSigner, - >, - client_public_key: RadrootsNostrPublicKey, - permissions: Vec<Permission>, - ) { - let outcome = handler - .handle_request( - client_public_key, - connect_request_with_permissions(None, permissions), - ) - .expect("connect"); - assert!(matches!( - outcome.handled_request, - RadrootsNostrSignerHandledRequest::Respond { .. } - )); - } - - fn response_from_outcome(outcome: RadrootsNostrSignerHandledRequestOutcome) -> Response { - match outcome.handled_request { - RadrootsNostrSignerHandledRequest::Respond { response, .. } => *response, - other => panic!("unexpected handled request: {other:?}"), - } - } - - #[test] - fn codec_and_handler_facades_cover_rpc_event_surface() { - let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer()); - let _ = codec.filter().expect("codec filter"); - let client_public_key = fixture_carol_public_key(); - let request = request_message("req-parse", Request::Ping); - let raw = serde_json::to_string(&request).expect("serialize request"); - let event = GenericBuilder::new(RadrootsNostrKind::Custom(RPC_KIND), raw) - .sign_with_keys(&Keys::generate()) - .expect("sign request event"); - - let parsed = codec.parse_request_event(&event).expect("parse request"); - assert_eq!(parsed, request); - - let response_builder = codec - .build_response_event(client_public_key, "req-parse", Response::Pong) - .expect("response builder"); - let response_event = response_builder - .sign_with_keys(&Keys::generate()) - .expect("sign response event"); - assert_eq!(response_event.kind, RadrootsNostrKind::Custom(RPC_KIND)); - assert!(response_event.tags.iter().any(|tag| { - tag.kind() == RadrootsNostrTagKind::p() - && tag.content() == Some(client_public_key.to_hex().as_str()) - })); - - let handler = handler_with_backend(embedded_backend()); - let _ = handler.filter().expect("handler filter"); - assert_eq!( - handler.parse_request_event(&event).expect("handler parse"), - request - ); - let handler_event = handler - .build_response_event( - client_public_key, - "req-handler", - Response::ConnectAcknowledged, - ) - .expect("handler response") - .sign_with_keys(&Keys::generate()) - .expect("sign handler response event"); - assert_eq!(handler_event.kind, RadrootsNostrKind::Custom(RPC_KIND)); - } - - #[test] - fn codec_crypto_and_signing_responses_cover_method_matrix() { - let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer()); - let client_public_key = fixture_carol_public_key(); - - assert_eq!( - codec - .crypto_response(Request::Nip04Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain".to_owned(), - }) - .expect("nip04 encrypt"), - Response::Nip04Encrypt("plain".to_owned()) - ); - assert_eq!( - codec - .crypto_response(Request::Nip04Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher".to_owned(), - }) - .expect("nip04 decrypt"), - Response::Nip04Decrypt("cipher".to_owned()) - ); - assert_eq!( - codec - .crypto_response(Request::Nip44Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain44".to_owned(), - }) - .expect("nip44 encrypt"), - Response::Nip44Encrypt("plain44".to_owned()) - ); - assert_eq!( - codec - .crypto_response(Request::Nip44Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher44".to_owned(), - }) - .expect("nip44 decrypt"), - Response::Nip44Decrypt("cipher44".to_owned()) - ); - - let non_crypto = codec - .crypto_response(Request::Ping) - .expect("non crypto response"); - assert!(matches!(non_crypto, Response::Error { .. })); - - let failing_codec = - super::RadrootsNostrSignerNip46Codec::new(test_signer_with_options(false, true)); - for request in [ - Request::Nip04Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain".to_owned(), - }, - Request::Nip04Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher".to_owned(), - }, - Request::Nip44Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain44".to_owned(), - }, - Request::Nip44Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher44".to_owned(), - }, - ] { - assert!(matches!( - failing_codec - .crypto_response(request) - .expect("failing crypto response"), - Response::Error { .. } - )); - } - - let signing = codec - .sign_event_response(unsigned_user_event(1)) - .expect("signing response"); - match signing { - Response::Error { error, .. } => { - assert!(error.contains("failed to sign event")); - } - other => panic!("unexpected sign response: {other:?}"), - } - - let signed = - super::RadrootsNostrSignerNip46Codec::new(test_signer_with_options(true, false)) - .sign_event_response(unsigned_user_event(1)) - .expect("signed response"); - assert!(matches!(signed, Response::SignedEvent(_))); - } - - #[test] - fn handler_connect_policy_paths_cover_registration_branches() { - let client_public_key = fixture_carol_public_key(); - - let rate_limited = handler_with_policy( - embedded_backend(), - TestPolicy { - rate_limit_reason: Some("slow down"), - ..TestPolicy::default() - }, - ) - .handle_request(client_public_key, connect_request(None)) - .expect("rate limit outcome"); - assert_eq!( - response_from_outcome(rate_limited), - Response::Error { - result: None, - error: "slow down".to_owned(), - } - ); - - let denied_registration = handler_with_policy( - embedded_backend(), - TestPolicy { - approval_requirement: None, - ..TestPolicy::default() - }, - ) - .handle_request(client_public_key, connect_request(None)) - .expect("registration denial"); - assert_eq!( - response_from_outcome(denied_registration), - Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - } - ); - - let approval_backend = embedded_backend(); - let approval_handler = handler_with_policy( - approval_backend.clone(), - TestPolicy { - approval_requirement: Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser), - ..TestPolicy::default() - }, - ); - let _ = approval_handler - .handle_request(client_public_key, connect_request(None)) - .expect("approval connect"); - let approval_connection = registered_connection(&approval_backend, &client_public_key); - assert_eq!( - approval_connection.approval_requirement, - RadrootsNostrSignerApprovalRequirement::ExplicitUser - ); - } - - #[test] - fn handler_connect_existing_connection_paths_cover_policy_edges() { - let client_public_key = fixture_carol_public_key(); - let secret = "connect-secret"; - let existing_backend = embedded_backend(); - let existing_handler = handler_with_backend(existing_backend.clone()); - - let first = existing_handler - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("initial connect"); - assert_eq!( - response_from_outcome(first), - Response::ConnectSecretEcho(secret.to_owned()) - ); - let existing = existing_handler - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("existing connect by secret"); - assert_eq!( - response_from_outcome(existing), - Response::ConnectSecretEcho(secret.to_owned()) - ); - - let denied_backend = embedded_backend(); - let denied_handler = handler_with_backend(denied_backend.clone()); - let _ = denied_handler - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("denied seed connect"); - let denying_handler = handler_with_policy( - denied_backend, - TestPolicy { - connect_decision: RadrootsNostrSignerNip46ConnectDecision::Deny, - ..TestPolicy::default() - }, - ); - let denied = denying_handler - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("existing connect denied"); - assert_eq!( - response_from_outcome(denied), - Response::Error { - result: None, - error: "client public key denied by policy".to_owned(), - } - ); - } - - #[test] - fn handler_request_paths_cover_base_sign_crypto_denied_and_challenged() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let client_public_key = fixture_carol_public_key(); - connect_with_permissions(&handler, client_public_key, all_runtime_permissions()); - - assert!(matches!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message("req-pubkey", Request::GetPublicKey), - ) - .expect("pubkey") - ), - Response::UserPublicKey(_) - )); - assert!(matches!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message("req-capability", Request::GetSessionCapability,), - ) - .expect("capability") - ), - Response::RemoteSessionCapability(_) - )); - assert_eq!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message("req-relays", Request::SwitchRelays), - ) - .expect("relays") - ), - Response::RelayList(vec![connect_relay(primary_relay())]) - ); - assert!(matches!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message("req-sign", Request::SignEvent(connect_unsigned_event(1)),), - ) - .expect("sign") - ), - Response::Error { .. } - )); - assert_eq!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message( - "req-nip04-decrypt", - Request::Nip04Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher".to_owned(), - }, - ), - ) - .expect("nip04 decrypt") - ), - Response::Nip04Decrypt("cipher".to_owned()) - ); - assert_eq!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message( - "req-nip44-encrypt", - Request::Nip44Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain".to_owned(), - }, - ), - ) - .expect("nip44 encrypt") - ), - Response::Nip44Encrypt("plain".to_owned()) - ); - - let unimplemented = handler - .handle_request( - client_public_key, - request_message( - "req-custom", - Request::Custom { - method: Method::custom("publish_note").expect("valid custom NIP-46 method"), - params: vec![], - }, - ), - ) - .expect("custom"); - assert!(matches!( - response_from_outcome(unimplemented), - Response::Error { .. } - )); - - let limited_backend = embedded_backend(); - let limited_handler = handler_with_backend(limited_backend); - connect_with_permissions( - &limited_handler, - client_public_key, - vec![Permission::new(Method::Nip04Encrypt)], - ); - let denied_crypto = limited_handler - .handle_request( - client_public_key, - request_message( - "req-denied", - Request::Nip04Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher".to_owned(), - }, - ), - ) - .expect("denied crypto"); - assert!(matches!( - response_from_outcome(denied_crypto), - Response::Error { .. } - )); - - let denied_backend = embedded_backend(); - let open_handler = handler_with_backend(denied_backend.clone()); - connect_with_permissions(&open_handler, client_public_key, all_runtime_permissions()); - let denying_handler = handler_with_policy( - denied_backend, - TestPolicy { - prepare_denial: Some("policy blocked"), - ..TestPolicy::default() - }, - ); - let denied_base = denying_handler - .handle_request( - client_public_key, - request_message("req-policy-denied", Request::Ping), - ) - .expect("policy denied"); - assert!(matches!( - response_from_outcome(denied_base), - Response::Error { .. } - )); - let denied_sign = denying_handler - .handle_request( - client_public_key, - request_message( - "req-policy-denied-sign", - Request::SignEvent(connect_unsigned_event(1)), - ), - ) - .expect("policy denied sign"); - assert!(matches!( - response_from_outcome(denied_sign), - Response::Error { .. } - )); - let denied_crypto = denying_handler - .handle_request( - client_public_key, - request_message( - "req-policy-denied-crypto", - Request::Nip44Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain".to_owned(), - }, - ), - ) - .expect("policy denied crypto"); - assert!(matches!( - response_from_outcome(denied_crypto), - Response::Error { .. } - )); - - let challenge_backend = embedded_backend(); - let challenge_handler = handler_with_backend(challenge_backend.clone()); - connect_with_permissions( - &challenge_handler, - client_public_key, - all_runtime_permissions(), - ); - let challenged = registered_connection(&challenge_backend, &client_public_key); - challenge_backend - .manager() - .require_auth_challenge(&challenged.connection_id, "https://example.test/auth") - .expect("require challenge"); - let auth_url = challenge_handler - .handle_request( - client_public_key, - request_message("req-challenge", Request::Ping), - ) - .expect("challenge"); - assert_eq!( - response_from_outcome(auth_url), - Response::AuthUrl("https://example.test/auth".to_owned()) - ); - } - - #[test] - fn policy_denial_propagates_audit_persistence_failures() { - let store = ToggleSaveStore::default(); - let manager = RadrootsNostrSignerManager::new(Arc::new(store.clone())) - .expect("manager with toggle store"); - let backend = - RadrootsNostrEmbeddedSignerBackend::new(manager, test_signer().signer_identity.clone()) - .expect("embedded backend"); - let client_public_key = fixture_carol_public_key(); - connect_with_permissions( - &handler_with_backend(backend.clone()), - client_public_key, - Vec::new(), - ); - store.fail_saves.store(true, Ordering::SeqCst); - - let handler = handler_with_policy( - backend, - TestPolicy { - prepare_denial: Some("policy blocked"), - ..TestPolicy::default() - }, - ); - let error = handler - .handle_request( - client_public_key, - request_message("req-audit-save", Request::Ping), - ) - .expect_err("audit persistence failure"); - assert!(error.to_string().contains("test store save failure")); - } - - #[test] - fn handler_rejects_unauthorized_base_sign_and_crypto_requests() { - let handler = handler_with_backend(embedded_backend()); - let client_public_key = fixture_carol_public_key(); - - for request in [ - Request::Ping, - Request::SignEvent(connect_unsigned_event(1)), - Request::Nip04Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "cipher".to_owned(), - }, - ] { - let outcome = handler - .handle_request( - client_public_key, - request_message("req-unauthorized", request), - ) - .expect("unauthorized request"); - assert_eq!( - response_from_outcome(outcome), - Response::Error { - result: None, - error: "unauthorized".to_owned(), - } - ); - } - } - - #[test] - fn handler_allowed_sign_and_crypto_requests_execute_codec_paths() { - let backend = embedded_backend(); - let handler = RadrootsNostrSignerNip46Handler::new( - backend, - TestPolicy::default(), - vec![primary_relay()], - test_signer_with_options(true, false), - ); - let client_public_key = fixture_carol_public_key(); - connect_with_permissions( - &handler, - client_public_key, - vec![ - Permission::with_parameter(Method::SignEvent, "kind:1"), - Permission::new(Method::Nip04Encrypt), - ], - ); - - assert!(matches!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message( - "req-allowed-sign", - Request::SignEvent(connect_unsigned_event(1)), - ), - ) - .expect("allowed sign") - ), - Response::SignedEvent(_) - )); - assert_eq!( - response_from_outcome( - handler - .handle_request( - client_public_key, - request_message( - "req-allowed-nip04-encrypt", - Request::Nip04Encrypt { - public_key: connect_public_key(client_public_key), - plaintext: "plain".to_owned(), - }, - ), - ) - .expect("allowed nip04 encrypt") - ), - Response::Nip04Encrypt("plain".to_owned()) - ); - } - - #[test] - fn handler_authorized_evaluation_facade_covers_request_variants() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let client_public_key = fixture_carol_public_key(); - connect_with_permissions(&handler, client_public_key, all_runtime_permissions()); - let connection = registered_connection(&backend, &client_public_key); - - let base = request_message("req-eval-ping", Request::Ping); - let base_eval = backend - .evaluate_request(&connection.connection_id, base.clone()) - .expect("base evaluation"); - assert_eq!( - response_from_outcome( - handler - .handle_authorized_request_evaluation(base, base_eval) - .expect("base authorized") - ), - Response::Pong - ); - let mut denied_base_eval = backend - .evaluate_request( - &connection.connection_id, - request_message("req-eval-denied-ping", Request::Ping), - ) - .expect("denied base evaluation"); - denied_base_eval.action = RadrootsNostrSignerRequestAction::Denied { - reason: "blocked".to_owned(), - }; - assert!(matches!( - response_from_outcome( - handler - .handle_authorized_request_evaluation( - request_message("req-eval-denied-ping", Request::Ping), - denied_base_eval, - ) - .expect("denied base authorized") - ), - Response::Error { .. } - )); - - let crypto = request_message( - "req-eval-crypto", - Request::Nip44Decrypt { - public_key: connect_public_key(client_public_key), - ciphertext: "sealed".to_owned(), - }, - ); - let crypto_eval = backend - .evaluate_request(&connection.connection_id, crypto.clone()) - .expect("crypto evaluation"); - assert_eq!( - response_from_outcome( - handler - .handle_authorized_request_evaluation(crypto, crypto_eval) - .expect("crypto authorized") - ), - Response::Nip44Decrypt("sealed".to_owned()) - ); - - let sign = request_message( - "req-eval-sign", - Request::SignEvent(connect_unsigned_event(1)), - ); - let sign_eval = backend - .evaluate_request(&connection.connection_id, sign.clone()) - .expect("sign evaluation"); - assert!(matches!( - response_from_outcome( - handler - .handle_authorized_request_evaluation(sign, sign_eval) - .expect("sign authorized") - ), - Response::Error { .. } - )); - - let custom = request_message( - "req-eval-custom", - Request::Custom { - method: Method::custom("do_work").expect("valid custom NIP-46 method"), - params: vec![], - }, - ); - let custom_eval = backend - .evaluate_request(&connection.connection_id, custom.clone()) - .expect("custom evaluation"); - assert!(matches!( - response_from_outcome( - handler - .handle_authorized_request_evaluation(custom, custom_eval) - .expect("custom authorized") - ), - Response::Error { .. } - )); - } - - #[test] - fn standalone_response_helpers_cover_publish_parts_and_hints() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let client_public_key = fixture_carol_public_key(); - connect_with_permissions(&handler, client_public_key, all_runtime_permissions()); - let connection = registered_connection(&backend, &client_public_key); - - let parts = super::connect_response_outcome(&connection, Some("secret".to_owned())) - .into_publish_parts() - .expect("publish parts"); - assert_eq!(parts.0, Response::ConnectSecretEcho("secret".to_owned())); - assert_eq!(parts.1, Some(connection.connection_id.clone())); - assert_eq!(parts.2, Some(connection.connection_id.clone())); - assert!( - RadrootsNostrSignerHandledRequest::Ignore - .into_publish_parts() - .is_none() - ); - assert!( - RadrootsNostrSignerHandledRequest::respond(Response::Pong) - .into_publish_parts() - .is_some() - ); - assert_eq!( - response_from_outcome(RadrootsNostrSignerHandledRequestOutcome::respond( - Response::Pong, - )), - Response::Pong - ); - - assert_eq!( - super::response_from_hint( - &connection, - RadrootsNostrSignerRequestResponseHint::UserPublicKey(connect_public_key( - client_public_key, - )), - ), - Response::UserPublicKey(connect_public_key(client_public_key)) - ); - let capability = RemoteSessionCapability { - user_public_key: connect_public_key(client_public_key), - relays: vec![connect_relay(primary_relay())], - permissions: all_runtime_permissions().into(), - }; - assert_eq!( - super::response_from_hint( - &connection, - RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability.clone(),), - ), - Response::RemoteSessionCapability(capability) - ); - assert_eq!( - super::response_from_hint( - &connection, - RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay( - primary_relay(), - )]), - ), - Response::RelayList(vec![connect_relay(primary_relay())]) - ); - assert_eq!( - super::response_from_hint( - &connection, - RadrootsNostrSignerRequestResponseHint::RelayList(Vec::new()), - ), - Response::RelayList(vec![connect_relay(primary_relay())]) - ); - assert!(matches!( - super::response_from_hint(&connection, RadrootsNostrSignerRequestResponseHint::None), - Response::Error { .. } - )); - - let denied = super::handled_request_for_action( - &connection, - RadrootsNostrSignerRequestAction::Denied { - reason: "blocked".to_owned(), - }, - || Ok(Response::Pong), - ) - .expect("denied action"); - assert!(matches!( - denied, - RadrootsNostrSignerHandledRequest::Respond { .. } - )); - - let allowed = super::handled_request_for_action( - &connection, - RadrootsNostrSignerRequestAction::Allowed { - required_permission: None, - response_hint: RadrootsNostrSignerRequestResponseHint::Pong, - }, - || Ok(Response::Pong), - ) - .expect("allowed action"); - assert!(matches!( - allowed, - RadrootsNostrSignerHandledRequest::Respond { .. } - )); - - let challenged = super::handled_request_for_action( - &connection, - RadrootsNostrSignerRequestAction::Challenged { - auth_challenge: RadrootsNostrSignerAuthChallenge::new( - "https://example.test/auth", - 1, - ) - .expect("challenge"), - pending_request: RadrootsNostrSignerPendingRequest::new( - request_message("req-pending", Request::Ping), - 1, - ) - .expect("pending"), - }, - || Ok(Response::Pong), - ) - .expect("challenged action"); - assert!(matches!( - challenged, - RadrootsNostrSignerHandledRequest::Respond { .. } - )); - } - - #[test] - fn handler_reports_ambiguous_client_sessions() { - let backend = embedded_backend(); - let client_public_key = fixture_carol_public_key(); - backend - .register_connection(RadrootsNostrSignerConnectionDraft::new( - client_public_key, - test_signer().user_identity(), - )) - .expect("first connection"); - backend - .register_connection(RadrootsNostrSignerConnectionDraft::new( - client_public_key, - public_identity_from_keys(&keys_from_secret( - "3333333333333333333333333333333333333333333333333333333333333333", - )), - )) - .expect("second connection"); - - let outcome = handler_with_backend(backend) - .handle_request( - client_public_key, - request_message("req-ambiguous", Request::Ping), - ) - .expect("ambiguous request"); - assert_eq!( - response_from_outcome(outcome), - Response::Error { - result: None, - error: "ambiguous client sessions".to_owned(), - } - ); - } - - #[test] - fn handler_registers_connections_and_returns_audit_for_authorized_requests() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let client_public_key = fixture_carol_public_key(); - - let connect = handler - .handle_request(client_public_key, connect_request(None)) - .expect("connect outcome"); - assert!(connect.audit.is_none()); - match connect.handled_request { - RadrootsNostrSignerHandledRequest::Respond { response, .. } => { - assert_eq!(*response, Response::ConnectAcknowledged); - } - other => panic!("unexpected connect outcome: {other:?}"), - } - - let ping = handler - .handle_request( - client_public_key, - RequestMessage::new("req-ping", Request::Ping), - ) - .expect("ping outcome"); - match ping.handled_request { - RadrootsNostrSignerHandledRequest::Respond { response, .. } => { - assert_eq!(*response, Response::Pong); - } - other => panic!("unexpected ping outcome: {other:?}"), - } - let audit = ping.audit.expect("audit"); - assert_eq!(audit.request_id.as_str(), "req-ping"); - assert_eq!( - backend - .find_connections_by_client_public_key(&client_public_key) - .expect("connections") - .len(), - 1 - ); - } - - #[test] - fn handler_ignores_reused_consumed_connect_secrets() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let client_public_key = fixture_carol_public_key(); - let secret = "connect-secret"; - - let first = handler - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("first connect"); - assert!(first.audit.is_none()); - - let connection = backend - .find_connections_by_client_public_key(&client_public_key) - .expect("connections") - .into_iter() - .next() - .expect("connection"); - backend - .mark_connect_secret_consumed(&connection.connection_id) - .expect("consume secret"); - - let reused = handler_with_backend(backend) - .handle_request(client_public_key, connect_request(Some(secret))) - .expect("reused outcome"); - assert_eq!( - reused.handled_request, - RadrootsNostrSignerHandledRequest::Ignore - ); - } - - #[test] - fn sign_event_response_rejects_wrong_user_pubkey() { - let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer()); - let response = codec - .sign_event_response( - serde_json::from_value(serde_json::json!({ - "pubkey": fixture_alice_identity().public_key().to_hex(), - "created_at": 1, - "kind": 1, - "tags": [], - "content": "hello", - })) - .expect("unsigned event"), - ) - .expect("response"); - - assert_eq!( - response, - Response::Error { - result: None, - error: "sign_event pubkey does not match the managed user identity".to_owned(), - } - ); - } - - #[test] - fn connect_decision_enum_covers_all_states() { - assert_eq!( - [ - RadrootsNostrSignerNip46ConnectDecision::Allow, - RadrootsNostrSignerNip46ConnectDecision::RequireApproval, - RadrootsNostrSignerNip46ConnectDecision::Deny, - ] - .len(), - 3 - ); - } - - #[test] - fn connect_request_keeps_requested_permissions() { - let request = connect_request(None); - assert_eq!( - request.request, - Request::Connect { - remote_signer_public_key: connect_public_key( - test_signer().signer_identity.public_key(), - ), - secret: None, - requested_permissions: vec![Permission::new(Method::Nip04Encrypt,)].into(), - client_metadata: None, - } - ); - } - - #[test] - fn handler_registration_initializes_non_terminal_connection_state() { - let backend = embedded_backend(); - let handler = handler_with_backend(backend.clone()); - let _ = handler - .handle_request(fixture_carol_public_key(), connect_request(None)) - .expect("connect"); - let connection = backend - .find_connections_by_client_public_key(&fixture_carol_public_key()) - .expect("connections") - .into_iter() - .next() - .expect("connection"); - assert!(matches!( - connection.auth_state, - RadrootsNostrSignerAuthState::NotRequired - | RadrootsNostrSignerAuthState::Pending - | RadrootsNostrSignerAuthState::Authorized - )); - assert_eq!( - connection.user_identity.id(), - test_signer().user_identity().id() - ); - } -} diff --git a/src/signer/test_fixtures.rs b/src/signer/test_fixtures.rs @@ -1,107 +0,0 @@ -#![forbid(unsafe_code)] -#![allow(dead_code)] - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ApprovedFixtureIdentity { - pub label: &'static str, - pub username: &'static str, - pub email: &'static str, - pub secret_key_hex: &'static str, - pub public_key_hex: &'static str, - pub nsec: &'static str, - pub npub: &'static str, -} - -pub const APPROVED_FIXTURE_NAMESPACE: &str = "radroots-approved-fixture-v1"; - -pub const FIXTURE_ALICE_LABEL: &str = "fixture_alice"; -pub const FIXTURE_ALICE_USERNAME: &str = "fixture_alice"; -pub const FIXTURE_ALICE_EMAIL: &str = "fixture_alice@fixtures.test"; -pub const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -pub const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -pub const FIXTURE_ALICE_NSEC: &str = - "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz"; -pub const FIXTURE_ALICE_NPUB: &str = - "npub1tp2ez55a5zatxxemrv0eses3ea05xhw2snuh3jy7azjqejn3q00s3vy5a9"; -pub const FIXTURE_ALICE: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_ALICE_LABEL, - username: FIXTURE_ALICE_USERNAME, - email: FIXTURE_ALICE_EMAIL, - secret_key_hex: FIXTURE_ALICE_SECRET_KEY_HEX, - public_key_hex: FIXTURE_ALICE_PUBLIC_KEY_HEX, - nsec: FIXTURE_ALICE_NSEC, - npub: FIXTURE_ALICE_NPUB, -}; - -pub const FIXTURE_BOB_LABEL: &str = "fixture_bob"; -pub const FIXTURE_BOB_USERNAME: &str = "fixture_bob"; -pub const FIXTURE_BOB_EMAIL: &str = "fixture_bob@fixtures.test"; -pub const FIXTURE_BOB_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -pub const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -pub const FIXTURE_BOB_NSEC: &str = - "nsec1tyujayrg7ejrrvf0wqscldsjs89kksea0unu2htp7xnrlcdfdluqrjya9h"; -pub const FIXTURE_BOB_NPUB: &str = - "npub1uqnxu08mp55gd7guw06ls68nhxp8xuf7tlxe0sypvcl42x9ykwhsd55k2g"; -pub const FIXTURE_BOB: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_BOB_LABEL, - username: FIXTURE_BOB_USERNAME, - email: FIXTURE_BOB_EMAIL, - secret_key_hex: FIXTURE_BOB_SECRET_KEY_HEX, - public_key_hex: FIXTURE_BOB_PUBLIC_KEY_HEX, - nsec: FIXTURE_BOB_NSEC, - npub: FIXTURE_BOB_NPUB, -}; - -pub const FIXTURE_CAROL_LABEL: &str = "fixture_carol"; -pub const FIXTURE_CAROL_USERNAME: &str = "fixture_carol"; -pub const FIXTURE_CAROL_EMAIL: &str = "fixture_carol@fixtures.test"; -pub const FIXTURE_CAROL_SECRET_KEY_HEX: &str = - "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509"; -pub const FIXTURE_CAROL_PUBLIC_KEY_HEX: &str = - "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"; -pub const FIXTURE_CAROL_NSEC: &str = - "nsec1f4kzplwcdptaualltna9c4zh2xazalgjdc9kvsk6a9my67pdv5ys2pqkaj"; -pub const FIXTURE_CAROL_NPUB: &str = - "npub1r9ft33558zvtemluludhdxwy5a66f5fmf2d6qztt5fh0q3yjhvwqgzmkl6"; -pub const FIXTURE_CAROL: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_CAROL_LABEL, - username: FIXTURE_CAROL_USERNAME, - email: FIXTURE_CAROL_EMAIL, - secret_key_hex: FIXTURE_CAROL_SECRET_KEY_HEX, - public_key_hex: FIXTURE_CAROL_PUBLIC_KEY_HEX, - nsec: FIXTURE_CAROL_NSEC, - npub: FIXTURE_CAROL_NPUB, -}; - -pub const FIXTURE_DIEGO_LABEL: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_USERNAME: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_EMAIL: &str = "fixture_diego@fixtures.test"; -pub const FIXTURE_DIEGO_SECRET_KEY_HEX: &str = - "9de56c1fdfce9ab00af85b3d7003c1d15cffb84cdf303c3a83c1a3fb1a2d0db0"; -pub const FIXTURE_DIEGO_PUBLIC_KEY_HEX: &str = - "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"; -pub const FIXTURE_DIEGO_NSEC: &str = - "nsec1nhjkc87le6dtqzhctv7hqq7p69w0lwzvmucrcw5rcx3lkx3dpkcqkrmgp5"; -pub const FIXTURE_DIEGO_NPUB: &str = - "npub1t5l2kmncadlyv757r94xx3tvn7hmj0ac3dc99wpj9xrs3zvj82jqwwcglm"; -pub const FIXTURE_DIEGO: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_DIEGO_LABEL, - username: FIXTURE_DIEGO_USERNAME, - email: FIXTURE_DIEGO_EMAIL, - secret_key_hex: FIXTURE_DIEGO_SECRET_KEY_HEX, - public_key_hex: FIXTURE_DIEGO_PUBLIC_KEY_HEX, - nsec: FIXTURE_DIEGO_NSEC, - npub: FIXTURE_DIEGO_NPUB, -}; - -pub const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com"; -pub const RELAY_SECONDARY_WSS: &str = "wss://relay-2.example.com"; -pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com"; - -pub const APP_PRIMARY_HTTPS: &str = "https://app.example.com"; -pub const API_PRIMARY_HTTPS: &str = "https://api.example.com"; -pub const CDN_PRIMARY_HTTPS: &str = "https://cdn.example.com"; diff --git a/src/signer/test_support.rs b/src/signer/test_support.rs @@ -1,85 +0,0 @@ -use crate::host_identity::RadrootsIdentityPublic as PublicIdentity; -use crate::signer::test_fixtures::{ - API_PRIMARY_HTTPS, ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, - FIXTURE_DIEGO, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS, -}; -use nostr::{Keys, PublicKey, RelayUrl, SecretKey}; - -fn approved_public_identity(identity: ApprovedFixtureIdentity) -> PublicIdentity { - let public_key = approved_public_key(identity); - PublicIdentity::new(public_key).expect("identity public key") -} - -fn approved_public_key(identity: ApprovedFixtureIdentity) -> PublicKey { - let secret = SecretKey::from_hex(identity.secret_key_hex).expect("secret"); - Keys::new(secret).public_key() -} - -fn relay(url: &str) -> RelayUrl { - RelayUrl::parse(url).expect("relay") -} - -pub(crate) fn fixture_alice_identity() -> PublicIdentity { - approved_public_identity(FIXTURE_ALICE) -} - -pub(crate) fn fixture_alice_public_key() -> PublicKey { - approved_public_key(FIXTURE_ALICE) -} - -pub(crate) fn fixture_bob_identity() -> PublicIdentity { - approved_public_identity(FIXTURE_BOB) -} - -pub(crate) fn fixture_carol_identity() -> PublicIdentity { - approved_public_identity(FIXTURE_CAROL) -} - -pub(crate) fn fixture_carol_public_key() -> PublicKey { - approved_public_key(FIXTURE_CAROL) -} - -pub(crate) fn fixture_diego_identity() -> PublicIdentity { - approved_public_identity(FIXTURE_DIEGO) -} - -pub(crate) fn fixture_diego_public_key() -> PublicKey { - approved_public_key(FIXTURE_DIEGO) -} - -pub(crate) fn primary_relay() -> RelayUrl { - relay(RELAY_PRIMARY_WSS) -} - -pub(crate) fn secondary_relay() -> RelayUrl { - relay(RELAY_SECONDARY_WSS) -} - -pub(crate) fn tertiary_relay() -> RelayUrl { - relay(RELAY_TERTIARY_WSS) -} - -pub(crate) fn api_primary_https() -> &'static str { - API_PRIMARY_HTTPS -} - -pub(crate) fn synthetic_secret_hex(index: u32) -> String { - format!("{index:064x}") -} - -pub(crate) fn synthetic_public_identity(index: u32) -> PublicIdentity { - let public_key = synthetic_public_key(index); - PublicIdentity::new(public_key).expect("identity public key") -} - -pub(crate) fn synthetic_public_key(index: u32) -> PublicKey { - let secret_hex = synthetic_secret_hex(index); - let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret"); - Keys::new(secret).public_key() -} - -pub(crate) fn synthetic_keys(index: u32) -> Keys { - let secret_hex = synthetic_secret_hex(index); - let secret = SecretKey::from_hex(secret_hex.as_str()).expect("secret"); - Keys::new(secret) -} diff --git a/src/signing_adapter.rs b/src/signing_adapter.rs @@ -1,88 +0,0 @@ -//! Adapter from Myc-owned identity operations to the final signing contract. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; -use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; -use radroots_signing::capability::{CancellationSupport, SignerCapability, SignerKind}; -use radroots_signing::error::Kind as SigningErrorKind; -use radroots_signing::recovery::ReplayCapability; -use radroots_signing::status::{SignProgress, SignProgressStage, SignerAvailability}; -use radroots_signing::{Error, SignReceipt, SignRequest, Signer, SignerStatus}; - -use crate::custody::MycActiveIdentity; - -impl Signer for MycActiveIdentity { - fn status(&self) -> radroots_signing::signer::BoxFuture<'_, Result<SignerStatus, Error>> { - Box::pin(async { - Ok(SignerStatus::new( - SignerAvailability::Ready, - vec![SignerCapability::new( - SignerKind::HostMediated, - ReplayCapability::LocalReplaySafe, - CancellationSupport::BeforePublication, - true, - true, - )], - None, - )) - }) - } - - fn sign( - &self, - request: SignRequest, - ) -> radroots_signing::signer::BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async move { - let now = now_unix_ms(); - request.ensure_active(now)?; - if request.expected_author() != &self.public_identity().public_key() { - return Err(Error::new(SigningErrorKind::AuthorizationDenied)); - } - report_progress(&request, SignProgressStage::Validating)?; - - let kind = u16::try_from(request.kind()) - .map_err(|_| Error::new(SigningErrorKind::InvalidArgument))?; - let tags = request - .tags() - .iter() - .cloned() - .map(Tag::parse) - .collect::<Result<Vec<_>, _>>() - .map_err(|source| Error::with_source(SigningErrorKind::InvalidArgument, source))?; - let unsigned = EventBuilder::new(Kind::Custom(kind), request.content()) - .tags(tags) - .custom_created_at(Timestamp::from(request.created_at())) - .build(self.public_key()); - let event = self - .sign_unsigned_event(unsigned, "final signing request") - .map_err(|source| Error::with_source(SigningErrorKind::InternalError, source))?; - - report_progress(&request, SignProgressStage::VerifyingOutput)?; - let raw_json = event.as_json(); - let wire = Nip01EventWire::parse_json(&raw_json).map_err(|source| { - Error::with_source(SigningErrorKind::SignerOutputInvalid, source) - })?; - let signed_event = - SignedEvent::from_wire_verified_id(wire, raw_json).map_err(|source| { - Error::with_source(SigningErrorKind::SignerOutputInvalid, source) - })?; - let receipt = SignReceipt::from_signed_event(&request, signed_event, now)?; - report_progress(&request, SignProgressStage::Complete)?; - Ok(receipt) - }) - } -} - -fn report_progress(request: &SignRequest, stage: SignProgressStage) -> Result<(), Error> { - request.report_progress(&SignProgress::stage(stage)?); - Ok(()) -} - -fn now_unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }) -} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -22,7 +22,7 @@ fn service_host_is_the_exact_default_feature_profile() { assert!(MANIFEST.contains("[features]\ndefault = [\"service-host\"]\nservice-host = []")); assert!(!MANIFEST.contains("getrandom = \"0.2\"")); assert!(MANIFEST.contains( - "tokio = { version = \"1.48\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"process\", \"rt-multi-thread\", \"sync\", \"time\"] }" + "tokio = { version = \"1.48\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"rt-multi-thread\", \"sync\", \"time\"] }" )); } diff --git a/tests/services_hardening_legacy_removal.rs b/tests/services_hardening_legacy_removal.rs @@ -5,6 +5,7 @@ use std::process::Command; const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MAIN_SOURCE: &str = include_str!("../src/main.rs"); +const MANIFEST: &str = include_str!("../Cargo.toml"); const ACTIVE_STATE_SOURCES: &[&str] = &[ include_str!("../src/state_catalog.rs"), include_str!("../src/state_connection.rs"), @@ -135,6 +136,85 @@ fn active_state_tree_has_one_shared_database_and_no_legacy_backend() { } #[test] +fn obsolete_provider_sources_and_dependencies_are_absent() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for relative in [ + "src/accounts.rs", + "src/custody.rs", + "src/error.rs", + "src/host_identity.rs", + "src/identity_files.rs", + "src/logging.rs", + "src/nostr_contract.rs", + "src/policy.rs", + "src/signing_adapter.rs", + "src/signer/backend.rs", + "src/signer/capability.rs", + "src/signer/error.rs", + "src/signer/evaluation.rs", + "src/signer/manager.rs", + "src/signer/mod.rs", + "src/signer/model.rs", + "src/signer/nip46.rs", + "src/signer/test_fixtures.rs", + "src/signer/test_support.rs", + ] { + assert!( + !root.join(relative).exists(), + "obsolete provider source remains: {relative}" + ); + } + + let manifest: toml::Value = toml::from_str(MANIFEST).expect("Cargo manifest"); + let dependencies = manifest["dependencies"] + .as_table() + .expect("dependencies table"); + for forbidden in [ + "axum", + "keyring", + "nostr-sdk", + "radroots_identity", + "radroots_event", + "radroots_signing", + "rand", + "thiserror", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + ] { + assert!( + !dependencies.contains_key(forbidden), + "obsolete provider dependency remains: {forbidden}" + ); + } + let secrets_features = dependencies["radroots_secrets"]["features"] + .as_array() + .expect("radroots_secrets features"); + assert_eq!(secrets_features, &[toml::Value::String("std".to_owned())]); + let tokio_features = dependencies["tokio"]["features"] + .as_array() + .expect("Tokio features"); + assert!( + !tokio_features + .iter() + .any(|feature| feature.as_str() == Some("process")) + ); + assert!(!dependencies.contains_key("tempfile")); + + let dev_dependencies = manifest["dev-dependencies"] + .as_table() + .expect("dev-dependencies table"); + assert!(dev_dependencies.contains_key("tempfile")); + for forbidden in ["futures-util", "serial_test", "tokio-tungstenite"] { + assert!( + !dev_dependencies.contains_key(forbidden), + "obsolete development dependency remains: {forbidden}" + ); + } +} + +#[test] fn binary_uses_only_the_hardened_parser_and_fails_closed_before_dispatch() { assert!(MAIN_SOURCE.contains("parse_myc_cli_v1_from(std::env::args_os())")); assert!(!MAIN_SOURCE.contains("MycConfig"));