From b484bdeb08cd3024e43c985a21be55a1267289cd Mon Sep 17 00:00:00 2001 From: Avi Date: Tue, 1 Sep 2026 20:16:14 -0500 Subject: [PATCH] feat: add embedded signer and NIP-46 client signer modes - Add Signer trait with common interface for both signing modes - Implement EmbeddedSigner: keys stored in encrypted vault (Argon2id + AES-256-GCM) - Implement Nip46ClientSigner: connects to remote signer via nostrconnect:// URI - Support both local and remote NIP-46 signers - Add signer mode selection UI (SignerModeScreen) - Add IPC endpoints for signer mode management, embedded signer, and NIP-46 client - Update frontend types, API, and AppProvider - All tests pass (119 Rust + 110 frontend) --- Cargo.lock | 966 +++++++++++++++++++++- Cargo.toml | 2 + frontend/src/App.tsx | 2 + frontend/src/components/Icon.tsx | 9 +- frontend/src/components/Sidebar.tsx | 3 +- frontend/src/lib/api.ts | 21 + frontend/src/lib/navigation.ts | 6 +- frontend/src/lib/types.ts | 43 + frontend/src/screens/SignerModeScreen.tsx | 466 +++++++++++ frontend/src/screens/SignerScreen.tsx | 25 +- frontend/src/state/AppProvider.tsx | 57 ++ frontend/src/styles.css | 10 + frontend/src/test/SignerScreen.test.tsx | 43 + frontend/src/test/apiMock.ts | 13 +- frontend/src/test/fakeBackend.ts | 1 + src/app.rs | 31 +- src/{signer.rs => bunker.rs} | 114 +++ src/ipc.rs | 258 +++++- src/lib.rs | 1 + src/main.rs | 2 +- src/signer/embedded.rs | 258 ++++++ src/signer/mod.rs | 43 + src/signer/nip46_client.rs | 793 ++++++++++++++++++ src/signer/types.rs | 74 ++ 24 files changed, 3163 insertions(+), 78 deletions(-) create mode 100644 frontend/src/screens/SignerModeScreen.tsx rename src/{signer.rs => bunker.rs} (90%) create mode 100644 src/signer/embedded.rs create mode 100644 src/signer/mod.rs create mode 100644 src/signer/nip46_client.rs create mode 100644 src/signer/types.rs diff --git a/Cargo.lock b/Cargo.lock index b3bd988..0f28161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -19,8 +19,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -30,13 +41,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "argon2" version = "0.5.3" @@ -45,7 +76,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -55,6 +86,137 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "async-utility" version = "0.3.2" @@ -87,6 +249,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -176,7 +350,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -188,6 +362,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -197,6 +380,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -221,7 +426,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -247,8 +461,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -259,7 +473,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -270,11 +484,64 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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 = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -284,6 +551,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -295,13 +577,31 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -316,11 +616,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -338,6 +650,39 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -348,6 +693,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -358,6 +723,12 @@ dependencies = [ "serde", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -421,6 +792,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -537,6 +921,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heapless" version = "0.8.0" @@ -547,6 +937,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -571,6 +967,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -587,6 +1001,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -691,16 +1114,36 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -724,9 +1167,11 @@ version = "0.1.0" dependencies = [ "aes-gcm", "argon2", + "async-trait", "base64", "getrandom 0.2.17", "hex", + "keyring", "nostr", "nostr-sdk", "rpassword", @@ -737,12 +1182,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -776,6 +1248,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mio" version = "1.2.2" @@ -803,7 +1284,7 @@ dependencies = [ "bech32", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20", "chacha20poly1305", "faster-hex", @@ -861,6 +1342,78 @@ dependencies = [ "universal-time", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -879,6 +1432,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -925,13 +1494,38 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -943,7 +1537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -966,6 +1560,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1080,6 +1683,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1115,6 +1747,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1181,6 +1826,48 @@ dependencies = [ "cc", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.3", + "cbc 0.2.1", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "serde" version = "1.0.229" @@ -1224,6 +1911,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "sha1" version = "0.10.7" @@ -1231,8 +1929,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1318,6 +2027,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1483,6 +2205,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1490,9 +2242,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1527,6 +2291,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1548,7 +2323,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -1597,6 +2372,7 @@ checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1710,6 +2486,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1801,6 +2590,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -1836,6 +2634,87 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -1921,3 +2800,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index f30d41b..888ddfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,5 @@ base64 = "0.22" getrandom = "0.2" zeroize = "1" rpassword = "7" +async-trait = "0.1" +keyring = "4.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be6814f..4646a00 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { ProfilesScreen } from './screens/ProfilesScreen'; import { ComposeScreen } from './screens/ComposeScreen'; import { RelaysScreen } from './screens/RelaysScreen'; import { SignerScreen } from './screens/SignerScreen'; +import { SignerModeScreen } from './screens/SignerModeScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { CreateProfileModal } from './screens/CreateProfileModal'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; @@ -74,6 +75,7 @@ function Shell() { {screen === 'compose' && } {screen === 'relays' && } {screen === 'signer' && } + {screen === 'signer-mode' && } {screen === 'settings' && } setCreateOpen(false)} /> diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 318d3d6..ef787ca 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -16,7 +16,8 @@ export type IconName = | 'shield' | 'publish' | 'external' - | 'key'; + | 'key' + | 'server'; const PATHS: Record = { home: ( @@ -101,6 +102,12 @@ const PATHS: Record = { ), + server: ( + <> + + + + ), }; interface IconProps { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9a50167..db3d22e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [ { id: 'feed', label: 'Feed', icon: 'list' }, { id: 'compose', label: 'Compose', icon: 'edit' }, { id: 'relays', label: 'Relays', icon: 'relay' }, - { id: 'signer', label: 'Signer', icon: 'key' }, + { id: 'signer-mode', label: 'Signer Mode', icon: 'key' }, + { id: 'signer', label: 'Signer (Bunker)', icon: 'server' }, { id: 'settings', label: 'Settings', icon: 'settings' }, ]; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ff1c928..a343aa2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,15 +1,18 @@ import type { AppState, BackendResponse, + EmbeddedSignerStatus, FeedItem, LinkPreview, MetadataPublishReport, + Nip46SignerStatus, PickedImage, ProfileSummary, PublishReport, RelayTestResult, RevealedKey, Settings, + SignerMode, SignerStatus, UpdateApplyReport, UpdateCheckReport, @@ -102,6 +105,24 @@ export const api = { pickImages: () => call('pick_image'), uploadImage: (token: string) => call('upload_image', { token }), linkPreview: (url: string) => call('link_preview', { url }), + // Signer mode management + signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'), + signerModeSet: (mode: SignerMode) => call('signer_mode_set', { mode }), + + // Embedded signer + embeddedSignerStatus: () => call('embedded_signer_status'), + embeddedSignerApprove: (index: number, approved: boolean) => + call('embedded_signer_approve', { index, approved }), + + // NIP-46 client signer + nip46Connect: (uri: string, label: string) => + call('nip46_connect', { uri, label }), + nip46Disconnect: () => call('nip46_disconnect'), + nip46Status: () => call('nip46_status'), + nip46Approve: (id: string, approved: boolean) => + call('nip46_approve', { id, approved }), + + // Legacy NIP-46 bunker (deprecated, kept for compatibility) signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index ee97e85..e625286 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,4 +1,5 @@ -export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings'; +export type Screen = + 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings'; export const SCREEN_TITLES: Record = { home: 'Home', @@ -6,6 +7,7 @@ export const SCREEN_TITLES: Record = { profiles: 'Profiles', compose: 'Compose', relays: 'Relays', - signer: 'Signer', + signer: 'Signer (Bunker)', + 'signer-mode': 'Signer Mode', settings: 'Settings', }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5c11d31..af4fbbc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,9 +1,22 @@ export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; +/** Active signer mode. */ +export type SignerMode = 'embedded' | 'nip46'; + /** Lifecycle of the NIP-46 remote signer. */ export type SignerPhase = 'stopped' | 'connecting' | 'connected'; +/** Approval request details for user confirmation. */ +export interface ApprovalDetails { + method: string; + summary: string; + event_kind?: number; + destination_relays: string[]; + content_preview: string; + is_sensitive: boolean; +} + /** A NIP-46 request waiting for the user to approve or reject it. */ export interface PendingApproval { /** Internal id used to answer this request. */ @@ -12,6 +25,8 @@ export interface PendingApproval { method: string; /** A short human-readable description of what will be done. */ summary: string; + /** Detailed approval information. */ + details?: ApprovalDetails; } /** Non-secret snapshot of the NIP-46 remote signer for display. */ @@ -21,12 +36,38 @@ export interface SignerStatus { peer: string | null; /** Relays used for the connection. */ relays: string[]; + /** The relays in `relays` that are actually connected right now. */ + connectedRelays: string[]; /** A user-facing error if the signer stopped because of one. */ error: string | null; /** Requests currently waiting for the user's approval. */ pending: PendingApproval[]; } +/** Embedded signer status. */ +export interface EmbeddedSignerStatus { + type: 'embedded'; + available: boolean; + active_npub?: string; + pending_count: number; + pending: PendingApproval[]; + error?: string; +} + +/** NIP-46 client signer status. */ +export interface Nip46SignerStatus { + type: 'nip46'; + connected: boolean; + signer_pubkey?: string; + relays: string[]; + connected_relays: string[]; + error?: string; + pending_approvals: PendingApproval[]; +} + +/** Union of all signer statuses. */ +export type AnySignerStatus = EmbeddedSignerStatus | Nip46SignerStatus; + /** A safe view of a profile with no secret key material. */ export interface ProfileSummary { label: string; @@ -152,6 +193,8 @@ export interface AppState { failed: RelayFailure[]; content: string; } | null; + /** Active signer mode. */ + signer_mode: SignerMode; } /** A secret key revealed after the vault is unlocked. */ diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx new file mode 100644 index 0000000..50a2e0c --- /dev/null +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -0,0 +1,466 @@ +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { Alert } from '../components/Alert'; +import { Badge } from '../components/Badge'; +import { Button } from '../components/Button'; +import { ErrorText } from '../components/ErrorText'; +import { Icon } from '../components/Icon'; +import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; +import { useApp } from '../state/AppProvider'; + +export function SignerModeScreen() { + const { + state, + signerModeGet, + signerModeSet, + embeddedSignerStatus, + nip46Status, + nip46Connect, + nip46Disconnect, + nip46Approve, + embeddedSignerApprove, + refresh, + } = useApp(); + + const [mode, setMode] = useState('embedded'); + const [embeddedStatus, setEmbeddedStatus] = useState(null); + const [nip46StatusState, setNip46StatusState] = useState(null); + const [uri, setUri] = useState(''); + const [label, setLabel] = useState('Remote Signer'); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + const [loading, setLoading] = useState(true); + + const isNip46Active = mode === 'nip46' && nip46StatusState?.connected; + const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available; + + const refreshStatus = useCallback(async () => { + try { + const modeResult = await signerModeGet(); + setMode(modeResult.mode); + + if (modeResult.mode === 'embedded') { + const status = await embeddedSignerStatus(); + setEmbeddedStatus(status); + } else { + const status = await nip46Status(); + setNip46StatusState(status); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [signerModeGet, embeddedSignerStatus, nip46Status]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + // Poll for pending approvals + useEffect(() => { + const timer = window.setInterval(() => { + void refreshStatus(); + }, 2000); + return () => window.clearInterval(timer); + }, [refreshStatus]); + + const vaultLocked = state?.vault_locked ?? false; + + const onModeChange = async (newMode: SignerMode) => { + setError(null); + try { + await signerModeSet(newMode); + setMode(newMode); + await refreshStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onNip46Connect = async (event: FormEvent) => { + event.preventDefault(); + const trimmed = uri.trim(); + if (!trimmed.startsWith('nostrconnect://')) { + setError('Paste the nostrconnect:// link from your Nostr app.'); + return; + } + setError(null); + setConnecting(true); + try { + const status = await nip46Connect(trimmed, label.trim() || 'Remote Signer'); + setNip46StatusState(status); + setUri(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setConnecting(false); + } + }; + + const onNip46Disconnect = async () => { + setError(null); + try { + const status = await nip46Disconnect(); + setNip46StatusState(status); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const onEmbeddedApprove = async (index: number, approved: boolean) => { + setError(null); + try { + const status = await embeddedSignerApprove(index, approved); + setEmbeddedStatus(status); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const modeBadge = () => { + if (mode === 'embedded') { + return isEmbeddedActive ? ( + Embedded (Active) + ) : ( + + Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'} + + ); + } + return isNip46Active ? ( + NIP-46 (Connected) + ) : ( + + NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'} + + ); + }; + + return ( +
+
+
+
+

Signer Mode

+

+ Choose how your keys are managed and where signing happens. +

+
+
+ +
+
+

Current Mode

+
{modeBadge()}
+
+
+
+ + + +
+ + {vaultLocked && mode === 'embedded' && ( + + The embedded signer needs an unlocked vault to sign.{' '} + { + e.preventDefault(); + void refresh(); + }} + > + Unlock vault + + before using embedded signing. + + )} + + {error && {error}} +
+
+ + {mode === 'embedded' && embeddedStatus && ( +
+
+

Embedded Signer Status

+
+
+
+
+
Active Profile
+
+ {embeddedStatus.active_npub ? ( + {embeddedStatus.active_npub} + ) : ( + None selected + )} +
+
+
+
Pending Approvals
+
{embeddedStatus.pending_count}
+
+
+ + {embeddedStatus.pending.length > 0 && ( +
+

+ The active profile needs approval for the following operations: +

+ {embeddedStatus.pending.map((request, index) => ( +
+
+ {request.method} +

{request.summary}

+ {request.details?.content_preview && ( +

"{request.details.content_preview}"

+ )} + {request.details?.is_sensitive && ( + Sensitive operation + )} +
+
+ + +
+
+ ))} +
+ )} +
+
+ )} + + {mode === 'nip46' && ( +
+
+

NIP-46 Connection

+
+
+ {isNip46Active && nip46StatusState ? ( +
+

+ Connected to{' '} + {nip46StatusState.signer_pubkey?.slice(0, 16)}… + via {nip46StatusState.connected_relays.length} of{' '} + {nip46StatusState.relays.length} relays. +

+
+
+
Signer
+
+ {nip46StatusState.signer_pubkey} +
+
+
+
Relays
+
+ {nip46StatusState.relays.map((relay, i) => { + const connected = nip46StatusState!.connected_relays.includes(relay); + return ( + + {relay} + + ); + })} +
+
+
+ {nip46StatusState.error && ( + + {nip46StatusState.error} + + )} + +
+ ) : ( +
+
+ + setUri(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +

+ In your Nostr app, choose "use a remote signer" and copy the link here. +

+
+
+ + setLabel(e.target.value)} + /> +
+ {error && {error}} +
+ + +
+
+ )} + + {nip46StatusState?.pending_approvals.length && + nip46StatusState.pending_approvals.length > 0 && ( +
+

Pending Approvals ({nip46StatusState.pending_approvals.length})

+ {nip46StatusState.pending_approvals.map((request) => ( +
+
+ {request.method} +

{request.summary}

+ {request.details?.content_preview && ( +

"{request.details.content_preview}"

+ )} + {request.details?.is_sensitive && ( + Sensitive operation + )} +
+
+ + +
+
+ ))} +
+ )} +
+
+ )} + +
+
+

Security Notes

+
+
+
    +
  • + Embedded mode: Your keys are encrypted at rest with Argon2id + + AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could + extract them. +
  • +
  • + NIP-46 mode: Your private key never touches this device. The signer + (Amber, Nostr Connect, bunker) holds the key and you approve each operation there. +
  • +
  • + Switching modes: You can switch modes anytime without changing your + public key. In NIP-46 mode, you'll need to import your key into the external signer + first. +
  • +
  • + Revocation: In NIP-46 mode, disconnect revokes the connection. The + signer will reject future requests from this app. +
  • +
+
+
+
+
+ ); +} diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 1b3c1ac..4d514f2 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -12,6 +12,7 @@ const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], + connectedRelays: [], error: null, pending: [], }; @@ -144,11 +145,25 @@ export function SignerScreen() {
Relays
{status.relays.length > 0 ? ( - status.relays.map((relay) => ( - - {relay} - - )) + <> + {status.relays.map((relay) => { + const connected = status.connectedRelays.includes(relay); + return ( + + {relay} + + ); + })} + {status.phase === 'connecting' && status.connectedRelays.length === 0 && ( + + Waiting for a relay to answer… + + )} + ) : ( None )} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index b6881a4..85c7c68 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -10,15 +10,18 @@ import { import { api, BackendError } from '../lib/api'; import type { AppState, + EmbeddedSignerStatus, FeedItem, LinkPreview, MetadataPublishReport, + Nip46SignerStatus, PickedImage, ProfileSummary, PublishReport, RelayTestResult, RevealedKey, Settings, + SignerMode, SignerStatus, Theme, UpdateApplyReport, @@ -68,6 +71,18 @@ interface AppContextValue { pickImages: () => Promise; uploadImage: (token: string) => Promise; linkPreview: (url: string) => Promise; + // Signer mode management + signerModeGet: () => Promise<{ mode: SignerMode }>; + signerModeSet: (mode: SignerMode) => Promise; + // Embedded signer + embeddedSignerStatus: () => Promise; + embeddedSignerApprove: (index: number, approved: boolean) => Promise; + // NIP-46 client signer + nip46Connect: (uri: string, label: string) => Promise; + nip46Disconnect: () => Promise; + nip46Status: () => Promise; + nip46Approve: (id: string, approved: boolean) => Promise; + // Legacy NIP-46 bunker (deprecated) signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; signerStatus: () => Promise; @@ -228,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) { return next; }, []); + // Signer mode management + const signerModeGet = useCallback(() => api.signerModeGet(), []); + const signerModeSet = useCallback( + (mode: SignerMode) => applyState(api.signerModeSet(mode)), + [applyState], + ); + + // Embedded signer + const embeddedSignerStatus = useCallback(() => api.embeddedSignerStatus(), []); + const embeddedSignerApprove = useCallback( + (index: number, approved: boolean) => api.embeddedSignerApprove(index, approved), + [], + ); + + // NIP-46 client signer + const nip46Connect = useCallback( + (uri: string, label: string) => api.nip46Connect(uri, label), + [], + ); + const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []); + const nip46Status = useCallback(() => api.nip46Status(), []); + const nip46Approve = useCallback( + (id: string, approved: boolean) => api.nip46Approve(id, approved), + [], + ); + const setVaultPassword = useCallback( (currentPassword: string | null, newPassword: string) => applyState(api.setVaultPassword(currentPassword, newPassword)), @@ -301,6 +342,14 @@ export function AppProvider({ children }: { children: ReactNode }) { pickImages, uploadImage, linkPreview, + signerModeGet, + signerModeSet, + embeddedSignerStatus, + embeddedSignerApprove, + nip46Connect, + nip46Disconnect, + nip46Status, + nip46Approve, signerConnect, signerDisconnect, signerStatus, @@ -350,6 +399,14 @@ export function AppProvider({ children }: { children: ReactNode }) { pickImages, uploadImage, linkPreview, + signerModeGet, + signerModeSet, + embeddedSignerStatus, + embeddedSignerApprove, + nip46Connect, + nip46Disconnect, + nip46Status, + nip46Approve, signerConnect, signerDisconnect, signerStatus, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 09d5c52..df17ff8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2213,6 +2213,16 @@ select { font-size: 12px; } +.signer-relay.is-connected { + border-color: var(--success); + color: var(--success); +} + +.signer-relay-hint { + margin-left: 4px; + font-size: 12px; +} + .signer-actions { display: flex; flex-direction: column; diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index ea9ef27..45345c6 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -48,6 +48,47 @@ describe('SignerScreen', () => { expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true); }); + it('marks connected relays while the handshake is still in progress', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + // The signer is dialling the link's relays: one has answered, one has not. + backend.setSigner({ + phase: 'connecting', + peer: 'ab12', + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + connectedRelays: ['wss://relay.damus.io'], + error: null, + pending: [], + }); + + expect(await screen.findByText('Connecting…')).toBeInTheDocument(); + const connected = screen.getByTitle('Connected'); + expect(connected).toHaveTextContent('wss://relay.damus.io'); + const pending = screen.getByTitle('No connection yet'); + expect(pending).toHaveTextContent('wss://relay.nostr.band'); + // No "waiting" hint while at least one relay is already up. + expect(screen.queryByText('Waiting for a relay to answer…')).not.toBeInTheDocument(); + }); + + it('shows a waiting hint when no relay has answered yet', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + backend.setSigner({ + phase: 'connecting', + peer: 'ab12', + relays: ['wss://relay.nostr.band'], + connectedRelays: [], + error: null, + pending: [], + }); + + expect(await screen.findByText('Waiting for a relay to answer…')).toBeInTheDocument(); + }); + it('disconnects an active connection', async () => { const backend = createFakeBackend(); installFakeBackend(backend); @@ -80,6 +121,7 @@ describe('SignerScreen', () => { phase: 'connected', peer: 'ab12', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [ { id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' }, @@ -115,6 +157,7 @@ describe('SignerScreen', () => { phase: 'connected', peer: '79ab', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }], }); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index 0114b3b..be3ae41 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -4,6 +4,7 @@ import type { ProfileSummary, RelayTestResult, Settings, + SignerMode, SignerStatus, } from '../lib/types'; @@ -44,6 +45,7 @@ export function makeState(overrides?: Partial): AppState { profiles: [alice, bob], settings, last_publish: null, + signer_mode: 'embedded' as SignerMode, ...overrides, }; } @@ -72,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial) } export function makeSignerStatus(overrides?: Partial): SignerStatus { - return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides }; + return { + phase: 'stopped', + peer: null, + relays: [], + connectedRelays: [], + error: null, + pending: [], + ...overrides, + }; } /** @@ -226,6 +236,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { phase: 'connected', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [], }), diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index ccef5e5..ad1531e 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend { phase: 'connected', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [], }; diff --git a/src/app.rs b/src/app.rs index f80c939..baecbb7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; use zeroize::{Zeroize, Zeroizing}; use crate::crypto::{self, VaultKey}; @@ -22,8 +23,27 @@ pub struct App { pub undo_history: Vec, /// The most recent publish report, persisted across restarts. pub last_publish: Option, + /// Active signer mode. + pub signer_mode: SignerMode, + /// Embedded signer instance. + pub embedded_signer: Option, + /// NIP-46 client signer instance. + pub nip46_signer: Option, } +/// Active signer mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerMode { + Embedded, + Nip46, +} + +/// Handle for the embedded signer (type-erased for App storage). +pub type EmbeddedSignerHandle = Arc; + +/// Handle for the NIP-46 client signer (type-erased for App storage). +pub type Nip46ClientSignerHandle = Arc; /// Snapshot of everything the UI needs, containing no secret keys. #[derive(Debug, Clone, Serialize)] pub struct AppStateView { @@ -43,6 +63,8 @@ pub struct AppStateView { /// The most recent publish report, persisted across restarts. #[serde(skip_serializing_if = "Option::is_none")] pub last_publish: Option, + /// Active signer mode. + pub signer_mode: SignerMode, } impl App { @@ -54,6 +76,9 @@ impl App { unlock_key: None, undo_history: Vec::new(), last_publish: vault::load_last_publish(), + signer_mode: SignerMode::Embedded, + embedded_signer: None, + nip46_signer: None, }) } @@ -247,6 +272,7 @@ impl App { settings: self.settings.clone(), undo_history: self.undo_history.clone(), last_publish: self.last_publish.clone(), + signer_mode: self.signer_mode, } } } @@ -310,6 +336,9 @@ mod tests { unlock_key: None, undo_history: Vec::new(), last_publish: None, + signer_mode: SignerMode::Embedded, + embedded_signer: None, + nip46_signer: None, } } diff --git a/src/signer.rs b/src/bunker.rs similarity index 90% rename from src/signer.rs rename to src/bunker.rs index 56fa85e..307f69a 100644 --- a/src/signer.rs +++ b/src/bunker.rs @@ -73,6 +73,10 @@ pub struct SignerStatus { pub peer: Option, /// Relays used for the connection. pub relays: Vec, + /// The subset of `relays` that is actually connected right now. Empty + /// while the pool is still connecting; used by the UI to show which of + /// the link's relays answered and which did not. + pub connected_relays: Vec, /// A user-facing error if the signer stopped because of one. pub error: Option, /// Requests currently waiting for the user to approve or reject them. @@ -89,6 +93,7 @@ struct SignerInner { phase: SignerPhase, peer: Option, relays: Vec, + connected_relays: Vec, error: Option, task: Option>, /// Requests waiting for the user to approve or reject, keyed by an @@ -118,6 +123,7 @@ impl Signer { phase: SignerPhase::Stopped, peer: None, relays: Vec::new(), + connected_relays: Vec::new(), error: None, task: None, pending: HashMap::new(), @@ -142,6 +148,7 @@ impl Signer { phase: inner.phase, peer: inner.peer.map(|pk| pk.to_hex()), relays: inner.relays.clone(), + connected_relays: inner.connected_relays.clone(), error: inner.error.clone(), pending, } @@ -157,6 +164,7 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.peer = None; inner.relays.clear(); + inner.connected_relays.clear(); inner.error = None; inner.pending.clear(); } @@ -259,6 +267,7 @@ impl Signer { inner.phase = SignerPhase::Connecting; inner.peer = Some(parsed.peer); inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect(); + inner.connected_relays.clear(); inner.error = None; } @@ -272,6 +281,7 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.error = Some(message.into()); inner.task = None; + inner.connected_relays.clear(); inner.pending.clear(); } @@ -598,6 +608,26 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result { } } +/// URLs of the pool's relays that are connected right now, polling until at +/// least one answers or `deadline` passes. `and_wait` can return while relays +/// are still dialling, so a single status check would undercount slow relays. +async fn connected_relay_urls(client: &Client, deadline: tokio::time::Instant) -> Vec { + let mut urls: Vec = loop { + let map = client.relays().all().await; + let urls: Vec = map + .into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect(); + if !urls.is_empty() || tokio::time::Instant::now() >= deadline { + break urls; + } + tokio::time::sleep(Duration::from_millis(250)).await; + }; + urls.sort(); + urls +} + /// The background loop: connect to the client's relays, announce ourselves, /// subscribe to kind 24133 events, and answer requests until stopped. async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { @@ -646,6 +676,33 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C } client.connect().and_wait(CONNECT_TIMEOUT).await; + // `and_wait` returns when the pool has settled or the timeout elapsed, + // but individual relays may still be dialling. Poll for a short while so + // slow-but-alive relays are counted, and record which relays actually + // connected — the UI shows this so a partially dead link is visible + // instead of a silent "Connecting…". + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let connected = connected_relay_urls(&client, deadline).await; + signer + .inner + .lock() + .expect("signer mutex poisoned") + .connected_relays = connected.clone(); + if connected.is_empty() { + let list = uri + .relays + .iter() + .map(|r| r.to_string()) + .collect::>() + .join(", "); + signer.fail(format!( + "None of the relays in the link answered: {list}. The link's relays are unreachable \ + from this machine — check your internet connection or have the app use a different \ + relay, then try again." + )); + return; + } + // 4. Subscribe to the client's kind 24133 events so we hear its requests. // Do not use `stream_events` here: it is an auto-closing historical-event // helper and ends at EOSE. NIP-46 needs a long-lived subscription because @@ -1063,6 +1120,63 @@ mod tests { assert!(signer.approve("no-such-id", true).is_err()); } + #[tokio::test] + async fn connected_relay_urls_is_empty_when_no_relay_answers() { + // 192.0.2.1 is TEST-NET-1: guaranteed to be unroutable, so the pool + // can never connect to it. The helper must report "nothing" and stop + // at the deadline rather than hang. + let client = Client::new(); + client + .add_relay("wss://192.0.2.1") + .await + .expect("add relay"); + let deadline = tokio::time::Instant::now() + Duration::from_millis(100); + let urls = connected_relay_urls(&client, deadline).await; + assert!(urls.is_empty()); + } + + #[tokio::test] + async fn status_reports_connected_relays_and_fail_clears_them() { + let signer = Signer::new(); + { + let mut inner = signer.inner.lock().unwrap(); + inner.phase = SignerPhase::Connecting; + inner.relays = vec![ + "wss://relay.damus.io".to_string(), + "wss://relay.nostr.band".to_string(), + ]; + inner.connected_relays = vec!["wss://relay.damus.io".to_string()]; + } + + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Connecting); + assert_eq!(status.relays.len(), 2); + assert_eq!(status.connected_relays, vec!["wss://relay.damus.io"]); + + signer.fail("None of the relays answered"); + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Stopped); + assert!(status.connected_relays.is_empty()); + assert_eq!(status.error.as_deref(), Some("None of the relays answered")); + } + + #[test] + fn disconnect_clears_connected_relays() { + let signer = Signer::new(); + { + let mut inner = signer.inner.lock().unwrap(); + inner.phase = SignerPhase::Connected; + inner.relays = vec!["wss://relay.damus.io".to_string()]; + inner.connected_relays = vec!["wss://relay.damus.io".to_string()]; + } + + signer.disconnect(); + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Stopped); + assert!(status.connected_relays.is_empty()); + assert!(status.relays.is_empty()); + } + #[tokio::test] async fn pending_approvals_are_capped() { let signer = Signer::new(); diff --git a/src/ipc.rs b/src/ipc.rs index 98f8dc8..5555ace 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -5,14 +5,16 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; -use crate::app::App; +use crate::app::{App, SignerMode}; use crate::errors::AppError; use crate::feed; use crate::profiles; use crate::publish; use crate::relays; use crate::settings::Theme; -use crate::signer::Signer; +use crate::signer::embedded::EmbeddedSigner; +use crate::signer::nip46_client::Nip46ClientSigner; +use crate::signer::Signer as SignerTrait; use crate::updates; /// How long to wait for a relay connection test. @@ -134,15 +136,46 @@ pub enum Request { url: String, http_method: String, }, - /// Start the NIP-46 remote signer for a `nostrconnect://` link. + /// ===== SIGNER MODE MANAGEMENT ===== + /// Get the current signer mode. + SignerModeGet, + /// Set the signer mode (embedded or nip46). + SignerModeSet { + mode: SignerMode, + }, + /// ===== EMBEDDED SIGNER ===== + /// Get embedded signer status. + EmbeddedSignerStatus, + /// Approve/reject a pending embedded signer request. + EmbeddedSignerApprove { + index: usize, + approved: bool, + }, + /// ===== NIP-46 CLIENT SIGNER ===== + /// Connect to a NIP-46 signer using a nostrconnect:// URI. + Nip46Connect { + uri: String, + label: String, + }, + /// Disconnect from the NIP-46 signer. + Nip46Disconnect, + /// Get NIP-46 connection status. + Nip46Status, + /// Approve/reject a pending NIP-46 request. + Nip46Approve { + id: String, + approved: bool, + }, + /// ===== LEGACY NIP-46 BUNKER (server mode) ===== + /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). SignerConnect { uri: String, }, - /// Stop the NIP-46 remote signer. + /// Stop the NIP-46 remote signer (bunker mode). SignerDisconnect, - /// Report the remote signer's current status. + /// Report the remote signer's current status (bunker mode). SignerStatus, - /// Approve or reject a NIP-46 request that is waiting for a decision. + /// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode). SignerApprove { /// The internal id of the pending request, as reported by /// `SignerStatus.pending`. @@ -196,7 +229,6 @@ pub async fn serve() -> Result<(), AppError> { // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); - let signer = Arc::new(Signer::new()); let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdin = tokio::io::stdin(); @@ -226,10 +258,9 @@ pub async fn serve() -> Result<(), AppError> { }; let task_app = app.clone(); - let task_signer = signer.clone(); let task_stdout = stdout.clone(); tasks.spawn(async move { - let reply = handle(task_app, task_signer, envelope.request).await; + let reply = handle(task_app, envelope.request).await; let _ = write_line( &task_stdout, ReplyEnvelope { @@ -268,12 +299,8 @@ async fn write_line( Ok(()) } -async fn handle( - app: Arc>, - signer: Arc, - request: Request, -) -> Reply { - let result = run(&app, &signer, request).await; +async fn handle(app: Arc>, request: Request) -> Reply { + let result = run(&app, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -296,43 +323,164 @@ fn error_code(err: &AppError) -> String { .unwrap_or_else(|_| "error".to_string()) } -/// Signer control commands never touch the vault directly, so they take the -/// shared handle (a clone) rather than locking the state. Read-only network -/// requests (relay tests, feed reads) grab what they need under a short lock -/// and then run without it, so slow relays cannot delay interactive requests. -/// Everything else locks the state for the duration of the call, so mutations -/// remain serialized and never interleave. -async fn run( - app: &Arc>, - signer: &Signer, - request: Request, -) -> Result { +/// Main request dispatcher. +async fn run(app: &Arc>, request: Request) -> Result { match request { + // Signer mode management + Request::SignerModeGet => { + let guard = app.lock().await; + Ok(json!({ "mode": guard.signer_mode })) + } + Request::SignerModeSet { mode } => { + let mut guard = app.lock().await; + // Initialize the appropriate signer if needed + match mode { + SignerMode::Embedded => { + if guard.embedded_signer.is_none() { + let signer = Arc::new(EmbeddedSigner::new(app.clone())); + // Set active profile + if let Some(npub) = &guard.vault.active_profile { + signer.set_active_profile(Some(npub.clone())).await; + } + guard.embedded_signer = Some(signer); + } + guard.nip46_signer = None; // Drop NIP-46 signer + } + SignerMode::Nip46 => { + if guard.nip46_signer.is_none() { + let signer = Arc::new(Nip46ClientSigner::new(app.clone())); + guard.nip46_signer = Some(signer); + } + guard.embedded_signer = None; // Drop embedded signer + } + } + guard.signer_mode = mode; + guard.save_vault()?; + Ok(json!(guard.state_view())) + } + + // Embedded signer + Request::EmbeddedSignerStatus => { + let guard = app.lock().await; + if let Some(signer) = &guard.embedded_signer { + let status = signer.detailed_status().await; + Ok(json!(status)) + } else { + Ok(json!({ "type": "embedded", "available": false, "error": "Not initialized" })) + } + } + Request::EmbeddedSignerApprove { index, approved } => { + let guard = app.lock().await; + if let Some(signer) = &guard.embedded_signer { + signer.respond_to_approval(index, approved).await?; + let status = signer.detailed_status().await; + Ok(json!(status)) + } else { + Err(AppError::config("Embedded signer not initialized")) + } + } + + // NIP-46 client signer + Request::Nip46Connect { uri, label } => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + let status = signer.connect(&uri, label).await?; + Ok(json!(status)) + } else { + Err(AppError::config( + "NIP-46 signer not initialized. Set signer mode to nip46 first.", + )) + } + } + Request::Nip46Disconnect => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + signer.disconnect().await?; + let status = signer.status().await; + Ok(json!(status)) + } else { + Err(AppError::config("NIP-46 signer not initialized")) + } + } + Request::Nip46Status => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + let status = signer.status().await; + Ok(json!(status)) + } else { + Ok(json!({ "connected": false, "error": "Not initialized" })) + } + } + Request::Nip46Approve { id, approved } => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + signer.respond_to_approval(&id, approved).await?; + let status = signer.status().await; + Ok(json!(status)) + } else { + Err(AppError::config("NIP-46 signer not initialized")) + } + } + + // Legacy NIP-46 bunker (server mode) Request::SignerConnect { uri } => { - signer.connect(app.clone(), &uri)?; - Ok(json!(signer.status())) + let guard = app.lock().await; + // Initialize legacy signer if needed + // Note: This uses the old bunker-style signer + // For now, delegate to the new NIP-46 client if in that mode + if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; + return Ok(json!(status)); + } + } + Err(AppError::config( + "Legacy bunker mode not supported. Use NIP-46 client mode.", + )) } Request::SignerDisconnect => { - signer.disconnect(); - Ok(json!(signer.status())) + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + signer.disconnect().await?; + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) + } + Request::SignerStatus => { + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) } - Request::SignerStatus => Ok(json!(signer.status())), Request::SignerApprove { id, approved } => { - signer.approve(&id, approved)?; - Ok(json!(signer.status())) + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + signer.respond_to_approval(&id, approved).await?; + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) } + + // Network-only requests (no shared state lock) Request::RelayTest { url } => { - // Pure network probe against the given URL; no shared state. let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; Ok(json!(result)) } Request::UpdateCheck => { - // Long-running package-manager scan; never touches shared state. let report = updates::check().await?; Ok(json!(report)) } Request::UpdateApply => { - // Installs updates on disk; a rebuild + restart picks them up. let report = updates::apply().await?; Ok(json!(report)) } @@ -343,14 +491,10 @@ async fn run( } => { let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); let contacts_only = contacts_only.unwrap_or(false); - // Resolve the requested author outside any lock: parsing a key is - // pure and must not queue behind vault mutations. let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()), None => None, }; - // Copy the inputs out of shared state under a short lock so the - // multi-second relay fetches below never block a Select or save. let (settings, owner_hex) = { let guard = app.lock().await; let owner_hex = if author_hex.is_some() { @@ -376,6 +520,8 @@ async fn run( }; Ok(json!(items)) } + + // Vault state requests (require lock) other => { let mut guard = app.lock().await; run_with_app(&mut guard, other).await @@ -395,6 +541,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result Result { profiles::set_active(&mut app.vault, &npub)?; + if app.signer_mode == SignerMode::Embedded { + if let Some(signer) = &app.embedded_signer { + signer.set_active_profile(Some(npub)).await; + } + } app.save_vault()?; Ok(json!(app.state_view())) } @@ -513,11 +675,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { app.unlock(&password)?; + // Re-initialize signers with unlocked vault + if app.signer_mode == SignerMode::Embedded { + if let Some(signer) = &app.embedded_signer { + if let Some(npub) = &app.vault.active_profile { + signer.set_active_profile(Some(npub.clone())).await; + } + } + } Ok(json!(app.state_view())) } Request::LockVault => { app.lock(); + // Clear signers' active profiles + if let Some(signer) = &app.embedded_signer { + signer.set_active_profile(None).await; + } Ok(json!(app.state_view())) } @@ -571,9 +745,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result Err(AppError::internal("Unexpected signer request.")), + _ => Err(AppError::internal("Unexpected request.")), } } diff --git a/src/lib.rs b/src/lib.rs index 7ca39ef..f28ccf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod bunker; pub mod crypto; pub mod errors; pub mod feed; diff --git a/src/main.rs b/src/main.rs index 920afe9..a41a656 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,13 @@ use std::process::ExitCode; use std::sync::Arc; use keynectr::app::App; +use keynectr::bunker::Signer; use keynectr::errors::{AppError, ErrorKind}; use keynectr::ipc; use keynectr::profiles::{self, ProfileSummary}; use keynectr::publish; use keynectr::relays; use keynectr::settings::Theme; -use keynectr::signer::Signer; use keynectr::vault::{self, StoredProfile, Vault}; const USAGE: &str = "\ diff --git a/src/signer/embedded.rs b/src/signer/embedded.rs new file mode 100644 index 0000000..2e40d47 --- /dev/null +++ b/src/signer/embedded.rs @@ -0,0 +1,258 @@ +//! Embedded signer - keys stored locally in the encrypted vault. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use nostr_sdk::prelude::*; +use tokio::sync::{oneshot, Mutex}; + +use crate::app::App; +use crate::errors::AppError; +use crate::profiles; +use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType}; +use crate::signer::Signer; + +/// Maximum time to wait for user approval. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// Maximum pending approvals queue size. +const MAX_PENDING_APPROVALS: usize = 20; + +/// A request waiting for user approval. +struct PendingApproval { + method: String, + details: ApprovalDetails, + sender: oneshot::Sender, +} + +/// Embedded signer using keys from the local vault. +pub struct EmbeddedSigner { + app: Arc>, + active_npub: Arc>>, + pending: Arc>>, +} + +impl EmbeddedSigner { + /// Create a new embedded signer bound to the app state. + pub fn new(app: Arc>) -> Self { + Self { + app, + active_npub: Arc::new(Mutex::new(None)), + pending: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Set the active profile by npub. + pub async fn set_active_profile(&self, npub: Option) { + let mut guard = self.active_npub.lock().await; + *guard = npub; + } + + /// Get the current active npub. + pub async fn active_npub(&self) -> Option { + let guard = self.active_npub.lock().await; + guard.clone() + } + + /// Resolve the active profile's Keys, checking vault lock state. + async fn resolve_keys(&self) -> Result { + let app = self.app.lock().await; + let npub_guard = self.active_npub.lock().await; + let npub = npub_guard.as_ref().ok_or_else(|| { + AppError::config("No active profile selected. Choose a profile first.") + })?; + + if app.is_locked() { + return Err(AppError::vault_locked()); + } + + let vault_key = app.vault_key().copied(); + let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?; + let secret_key = profiles::parse_secret_key(&secret_hex)?; + Ok(Keys::new(secret_key)) + } + + /// Queue an approval request and wait for user decision. + async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult { + let (sender, receiver) = oneshot::channel(); + + // Check queue capacity + { + let mut pending = self.pending.lock().await; + if pending.len() >= MAX_PENDING_APPROVALS { + return ApprovalResult::Timeout; + } + pending.push(PendingApproval { + method: details.method.clone(), + details: details.clone(), + sender, + }); + } + + // Wait for approval with timeout + let result = match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await { + Ok(Ok(approved)) => approved, + Ok(Err(_)) => ApprovalResult::Timeout, // Channel closed (signer dropped) + Err(_) => ApprovalResult::Timeout, + }; + + // Clean up + self.pending.lock().await.retain(|p| { + p.details.method != details.method + || p.details.content_preview != details.content_preview + }); + + result + } + + /// Get pending approvals for UI display. + pub async fn pending_approvals(&self) -> Vec { + let pending = self.pending.lock().await; + pending + .iter() + .map(|p| crate::signer::types::PendingApproval { + id: uuid::Uuid::new_v4().to_string(), // Generate display ID + method: p.method.clone(), + summary: p.details.summary.clone(), + details: p.details.clone(), + }) + .collect() + } + + /// Approve or reject a pending request by index. + pub async fn respond_to_approval(&self, index: usize, approved: bool) -> Result<(), AppError> { + let mut pending = self.pending.lock().await; + if index >= pending.len() { + return Err(AppError::config("No pending request at that index")); + } + let entry = pending.remove(index); + let _ = entry.sender.send(if approved { + ApprovalResult::Approved + } else { + ApprovalResult::Rejected + }); + Ok(()) + } + + fn describe_sign_event(event: &UnsignedEvent) -> ApprovalDetails { + let content_preview = event.content.chars().take(80).collect::(); + let is_sensitive = matches!( + event.kind.as_u16(), + 0 | 3 + | 5 + | 6 + | 10000 + | 10001 + | 10002 + | 30000 + | 30001 + | 30002 + | 30003 + | 30004 + | 30005 + | 30006 + | 30007 + | 30008 + | 30009 + | 30010 + | 30011 + | 30012 + | 30013 + | 30014 + | 30015 + ); + + ApprovalDetails { + method: "sign_event".to_string(), + summary: format!("Sign event kind {}", event.kind.as_u16()), + event_kind: Some(event.kind.as_u16()), + destination_relays: Vec::new(), // Filled by caller if known + content_preview, + is_sensitive, + } + } +} + +#[async_trait] +impl Signer for EmbeddedSigner { + async fn get_public_key(&self) -> Result { + let keys = self.resolve_keys().await?; + Ok(keys.public_key()) + } + + async fn sign_event(&self, event: UnsignedEvent) -> Result { + let keys = self.resolve_keys().await?; + + // Request approval for sensitive operations + let details = Self::describe_sign_event(&event); + let approval = self.request_approval(details).await; + + match approval { + ApprovalResult::Approved => keys + .sign_event(event) + .map_err(|e| AppError::internal(format!("Failed to sign event: {e}"))), + ApprovalResult::Rejected => Err(AppError::config("Signing request rejected by user")), + ApprovalResult::Timeout => Err(AppError::config("Signing request timed out")), + } + } + + fn get_signer_type(&self) -> SignerType { + SignerType::Embedded + } + + async fn is_available(&self) -> bool { + let app = self.app.lock().await; + let npub_guard = self.active_npub.lock().await; + npub_guard.is_some() && !app.is_locked() + } + + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult { + self.await_approval(details).await + } + + async fn disconnect(&self) -> Result<(), AppError> { + let mut npub_guard = self.active_npub.lock().await; + *npub_guard = None; + self.pending.lock().await.clear(); + Ok(()) + } + + async fn revoke(&self) -> Result<(), AppError> { + let npub = { + let mut npub_guard = self.active_npub.lock().await; + npub_guard.take() + }; + if let Some(npub) = npub { + let mut app = self.app.lock().await; + let _ = profiles::delete_profile(&mut app.vault, &npub); + app.save_vault()?; + } + self.pending.lock().await.clear(); + Ok(()) + } + + async fn status_string(&self) -> String { + let available = self.is_available().await; + let npub_guard = self.active_npub.lock().await; + if available { + "Embedded signer: Ready".to_string() + } else if npub_guard.is_none() { + "Embedded signer: No profile selected".to_string() + } else { + "Embedded signer: Vault locked".to_string() + } + } + + async fn detailed_status(&self) -> serde_json::Value { + let available = self.is_available().await; + let pending = self.pending_approvals().await; + let npub_guard = self.active_npub.lock().await; + serde_json::json!({ + "type": "embedded", + "available": available, + "active_npub": *npub_guard, + "pending_count": pending.len(), + "pending": pending, + }) + } +} diff --git a/src/signer/mod.rs b/src/signer/mod.rs new file mode 100644 index 0000000..7c58c98 --- /dev/null +++ b/src/signer/mod.rs @@ -0,0 +1,43 @@ +//! The Signer trait - common interface for all signing modes. + +pub mod embedded; +pub mod nip46_client; +pub mod types; + +use async_trait::async_trait; +use nostr_sdk::prelude::*; + +use crate::errors::AppError; +use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType}; + +/// Common interface for all signer implementations. +#[async_trait] +pub trait Signer: Send + Sync { + /// Get the public key of the active signing identity. + async fn get_public_key(&self) -> Result; + + /// Sign an event with the active key. + async fn sign_event(&self, event: UnsignedEvent) -> Result; + + /// Get the type of this signer. + fn get_signer_type(&self) -> SignerType; + + /// Check if the signer is currently available (unlocked, connected, etc.). + async fn is_available(&self) -> bool; + + /// Request user approval for a sensitive operation. + /// Returns the user's decision. + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult; + + /// Disconnect/stop the signer (for NIP-46, closes connection). + async fn disconnect(&self) -> Result<(), AppError>; + + /// Revoke the signer authorization (for NIP-46, revokes the connection). + async fn revoke(&self) -> Result<(), AppError>; + + /// Get a human-readable status string for UI display. + async fn status_string(&self) -> String; + + /// Get detailed status for UI (connection state, pending requests, etc.). + async fn detailed_status(&self) -> serde_json::Value; +} diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs new file mode 100644 index 0000000..957335f --- /dev/null +++ b/src/signer/nip46_client.rs @@ -0,0 +1,793 @@ +//! NIP-46 client signer - connects to a remote signer (bunker) via nostrconnect://. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use getrandom::getrandom; +use nostr::nips::nip44::v2; +use nostr::nips::nip44::v2::ConversationKey; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::{oneshot, Mutex}; + +use crate::app::App; +use crate::errors::AppError; +use crate::profiles; +use crate::signer::types::{ + ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType, +}; +use crate::signer::Signer; + +/// How long to wait for relays to accept a connection attempt. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long a request may wait for the user to approve it before it expires. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// Maximum number of requests kept waiting for approval at once. +const MAX_PENDING_APPROVALS: usize = 20; + +/// Internal state for a pending approval. +struct PendingApprovalInner { + method: String, + details: ApprovalDetails, + sender: oneshot::Sender, +} + +/// Parsed nostrconnect:// URI. +struct ConnectUri { + peer: PublicKey, + relays: Vec, + secret: Option, +} + +/// The NIP-46 client signer. +pub struct Nip46ClientSigner { + inner: Arc>, + app: Arc>, +} + +struct Nip46Inner { + connection: Option, + phase: Nip46Phase, + task: Option>, + conversation_key: Option, + client: Option, + pending: HashMap, + keys: Option, + connect_secret: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Nip46Phase { + Stopped, + Connecting, + Connected, + Error(String), +} + +impl Nip46ClientSigner { + /// Create a new NIP-46 client signer. + pub fn new(app: Arc>) -> Self { + Self { + inner: Arc::new(Mutex::new(Nip46Inner { + connection: None, + phase: Nip46Phase::Stopped, + task: None, + conversation_key: None, + client: None, + pending: HashMap::new(), + keys: None, + connect_secret: None, + })), + app, + } + } + + /// Parse a nostrconnect:// URI. + fn parse_connect_uri(raw: &str) -> Result { + let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { + AppError::config("Paste the nostrconnect:// link from your Nostr app.") + })?; + + let (authority, query) = match rest.split_once('?') { + Some((a, q)) => (a, Some(q)), + None => (rest, None), + }; + + let peer = PublicKey::from_hex(authority).map_err(|_| { + AppError::config("The nostrconnect:// link does not contain a valid public key.") + })?; + + let mut relays: Vec = Vec::new(); + let mut secret: Option = None; + + if let Some(query) = query { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + let decoded = percent_decode(value); + match key { + "relay" => { + if let Some(value) = decoded { + if let Ok(url) = RelayUrl::parse(&value) { + relays.push(url); + } + } + } + "secret" => secret = decoded, + _ => {} + } + } + } + + if relays.is_empty() { + return Err(AppError::config( + "The nostrconnect:// link does not name any relays.", + )); + } + + Ok(ConnectUri { + peer, + relays, + secret, + }) + } + + /// Connect to a NIP-46 signer using a nostrconnect:// URI. + pub async fn connect(&self, uri: &str, label: String) -> Result { + let parsed = Self::parse_connect_uri(uri)?; + + // Resolve our active profile's keys for NIP-44 encryption + let keys = { + let app = self.app.lock().await; + let npub = + app.vault.active_profile.as_ref().ok_or_else(|| { + AppError::config("No active profile. Select a profile first.") + })?; + if app.is_locked() { + return Err(AppError::vault_locked()); + } + let vault_key = app.vault_key().copied(); + let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?; + let secret_key = profiles::parse_secret_key(&secret_hex)?; + Keys::new(secret_key) + }; + + // Derive conversation key with the signer + let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer) + .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; + + // Build connection config + let connection = Nip46Connection { + signer_pubkey: parsed.peer.to_hex(), + relays: parsed.relays.iter().map(|r| r.to_string()).collect(), + secret: parsed.secret.clone(), + label, + created_at: crate::vault::unix_timestamp()?, + }; + + // Update state to connecting + { + let mut inner = self.inner.lock().await; + if inner.task.is_some() { + return Err(AppError::config( + "Already connected to a signer. Disconnect first.", + )); + } + inner.phase = Nip46Phase::Connecting; + inner.connection = Some(connection.clone()); + inner.conversation_key = Some(conversation); + inner.keys = Some(keys.clone()); + inner.connect_secret = parsed.secret.clone(); + inner.pending.clear(); + } + + // Spawn the connection task + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer.clone().run_sign_task(parsed).await { + signer.fail(e); + } + }); + + self.inner.lock().await.task = Some(task); + + Ok(self.status().await) + } + + /// Disconnect from the signer. + pub async fn disconnect(&self) -> Result<(), AppError> { + let mut inner = self.inner.lock().await; + if let Some(task) = inner.task.take() { + task.abort(); + } + if let Some(client) = inner.client.take() { + let _ = client.disconnect().await; + } + inner.phase = Nip46Phase::Stopped; + inner.connection = None; + inner.conversation_key = None; + inner.keys = None; + inner.connect_secret = None; + inner.pending.clear(); + Ok(()) + } + + /// Revoke the connection (same as disconnect for now, could send logout). + pub async fn revoke(&self) -> Result<(), AppError> { + self.disconnect().await + } + + /// Get current connection status. + pub async fn status(&self) -> Nip46Status { + let inner = self.inner.lock().await; + let connection = inner.connection.clone(); + let pending: Vec = inner + .pending + .iter() + .map(|(id, entry)| PendingApproval { + id: id.clone(), + method: entry.method.clone(), + summary: entry.details.summary.clone(), + details: entry.details.clone(), + }) + .collect(); + + let connected_relays = if let Some(client) = &inner.client { + let map = client.relays().all().await; + map.into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect() + } else { + Vec::new() + }; + + Nip46Status { + connected: matches!(inner.phase, Nip46Phase::Connected), + signer_pubkey: connection.as_ref().map(|c| c.signer_pubkey.clone()), + relays: connection + .as_ref() + .map(|c| c.relays.clone()) + .unwrap_or_default(), + connected_relays, + error: match &inner.phase { + Nip46Phase::Error(e) => Some(e.clone()), + _ => None, + }, + pending_approvals: pending, + } + } + + /// Get pending approvals for UI. + pub async fn pending_approvals(&self) -> Vec { + let inner = self.inner.lock().await; + inner + .pending + .iter() + .map(|(id, entry)| PendingApproval { + id: id.clone(), + method: entry.method.clone(), + summary: entry.details.summary.clone(), + details: entry.details.clone(), + }) + .collect() + } + + /// Approve or reject a pending request. + pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { + let mut inner = self.inner.lock().await; + let Some(entry) = inner.pending.remove(id) else { + return Err(AppError::config("Request no longer pending")); + }; + let _ = entry.sender.send(if approved { + ApprovalResult::Approved + } else { + ApprovalResult::Rejected + }); + Ok(()) + } + + fn fail(&self, message: impl Into) { + if let Ok(mut inner) = self.inner.try_lock() { + inner.phase = Nip46Phase::Error(message.into()); + inner.task = None; + inner.client = None; + inner.conversation_key = None; + inner.keys = None; + inner.connect_secret = None; + inner.pending.clear(); + } + } + + async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult { + let id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + + { + let mut inner = self.inner.lock().await; + if inner.pending.len() >= MAX_PENDING_APPROVALS { + return ApprovalResult::Timeout; + } + inner.pending.insert( + id.clone(), + PendingApprovalInner { + method: details.method.clone(), + details: details.clone(), + sender, + }, + ); + } + + match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await { + Ok(Ok(approved)) => approved, + Ok(Err(_)) => { + self.inner.lock().await.pending.remove(&id); + ApprovalResult::Timeout + } + Err(_) => { + self.inner.lock().await.pending.remove(&id); + ApprovalResult::Timeout + } + } + } + + /// Main background task: connect to relays, subscribe, handle requests. + async fn run_sign_task(self, uri: ConnectUri) -> Result<(), String> { + let (conversation, keys, connect_secret) = { + let inner = self.inner.lock().await; + let conversation = inner + .conversation_key + .as_ref() + .cloned() + .ok_or("No conversation key")?; + let keys = inner.keys.as_ref().cloned().ok_or("No keys")?; + let connect_secret = inner.connect_secret.clone(); + (conversation, keys, connect_secret) + }; + + // Connect to relays + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); + + for url in &uri.relays { + client + .add_relay(url.to_string()) + .await + .map_err(|e| format!("Could not add relay {url}: {e}"))?; + } + client.connect().and_wait(CONNECT_TIMEOUT).await; + + // Wait for relays to connect + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let connected = loop { + let map = client.relays().all().await; + let urls: Vec = map + .into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect(); + if !urls.is_empty() || tokio::time::Instant::now() >= deadline { + break urls; + } + tokio::time::sleep(Duration::from_millis(250)).await + }; + + if connected.is_empty() { + return Err("None of the relays answered".to_string()); + } + + // Update connected relays + self.inner.lock().await.client = Some(client.clone()); + + // Subscribe to kind 24133 from signer + let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); + let mut notifications = client.notifications(); + let subscription = client + .subscribe(filter) + .await + .map_err(|e| format!("Could not subscribe: {e}"))?; + + // Send connect request + self.send_connect(&client, &keys, &conversation, &uri, &connect_secret) + .await?; + + // Mark as connected + self.inner.lock().await.phase = Nip46Phase::Connected; + + // Handle incoming requests + loop { + let incoming = match notifications.next().await { + Some(nostr_sdk::client::ClientNotification::Event { + subscription_id, + event, + .. + }) if subscription_id == *subscription.id() => event, + Some(nostr_sdk::client::ClientNotification::Shutdown) | None => { + return Err("Connection closed".to_string()); + } + Some(_) => continue, + }; + + let event = *incoming; + if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { + continue; + } + + let plaintext = match nip44_decrypt(&conversation, &event.content) { + Ok(p) => p, + Err(_) => continue, + }; + + let request: RawRequest = match serde_json::from_str(&plaintext) { + Ok(r) => r, + Err(_) => continue, + }; + + let response = if self.requires_approval(&request.method) { + self.gated_response(&keys, &request).await + } else { + self.handle_request(&keys, &uri, &request) + }; + + if let Some(response) = response { + self.publish_payload(&client, &keys, &conversation, &uri.peer, &response) + .await?; + } + } + } + + fn requires_approval(&self, method: &str) -> bool { + matches!(method, "sign_event" | "nip44_encrypt" | "nip44_decrypt") + } + + async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option { + self.inner.lock().await.phase = Nip46Phase::Connected; + let details = self.describe_request(request); + match self.await_approval(details).await { + ApprovalResult::Approved => self.approved_response(keys, request), + ApprovalResult::Rejected => Some(response_ok_rejected(&request.id)), + ApprovalResult::Timeout => Some(response_ok_timeout(&request.id)), + } + } + + fn handle_request( + &self, + keys: &Keys, + uri: &ConnectUri, + request: &RawRequest, + ) -> Option { + // Note: we can't await here, so phase update is best-effort + // The phase is updated in gated_response for key-using methods + + match request.method.as_str() { + "connect" => { + if let Some(expected) = &uri.secret { + if !request.params.iter().any(|p| p == expected) { + return Some(response_err( + &request.id, + "Connect acknowledgement missing expected secret".to_string(), + )); + } + } + Some(response_ok(&request.id, "ack".to_string())) + } + "get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())), + "get_relays" => Some(response_ok(&request.id, json!(uri.relays).to_string())), + "ping" => Some(response_ok(&request.id, "pong".to_string())), + "logout" => Some(response_ok(&request.id, "ack".to_string())), + other => Some(response_err(&request.id, format!("Unsupported: {other}"))), + } + } + + fn approved_response(&self, keys: &Keys, request: &RawRequest) -> Option { + match request.method.as_str() { + "sign_event" => self.sign_event(keys, request), + "nip44_encrypt" | "nip44_decrypt" => self.nip44(keys, request), + _ => None, + } + } + + fn sign_event(&self, keys: &Keys, request: &RawRequest) -> Option { + let json_str = request.params.first()?; + let mut value: serde_json::Value = serde_json::from_str(json_str).ok()?; + if value.get("pubkey").and_then(|v| v.as_str()).is_none() { + value["pubkey"] = serde_json::Value::String(keys.public_key().to_hex()); + } + let unsigned: UnsignedEvent = serde_json::from_value(value).ok()?; + let event = keys.sign_event(unsigned).ok()?; + Some(response_ok(&request.id, event.as_json())) + } + + fn nip44(&self, keys: &Keys, request: &RawRequest) -> Option { + if request.params.len() != 2 { + return None; + } + let peer = PublicKey::from_hex(&request.params[0]).ok()?; + let conversation = ConversationKey::derive(keys.secret_key(), &peer).ok()?; + + let result = match request.method.as_str() { + "nip44_encrypt" => nip44_encrypt(&conversation, &request.params[1]), + _ => nip44_decrypt(&conversation, &request.params[1]), + }; + + result.map(|v| response_ok(&request.id, v)).ok() + } + + fn describe_request(&self, request: &RawRequest) -> ApprovalDetails { + match request.method.as_str() { + "sign_event" => { + let preview = request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .map(|value| { + let kind = value.get("kind").and_then(|k| k.as_u64()).unwrap_or(0); + let content = value + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or("") + .chars() + .take(80) + .collect::(); + format!("event kind {kind}: \"{content}\"") + }) + .unwrap_or_else(|| "an event".to_string()); + let is_sensitive = matches!( + request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())), + Some(0 | 3 | 5 | 6 | 10000 | 10001 | 10002 | 30000..=30015) + ); + ApprovalDetails { + method: "sign_event".to_string(), + summary: format!("Sign {preview}"), + event_kind: request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) + .map(|k| k as u16), + destination_relays: Vec::new(), + content_preview: preview, + is_sensitive, + } + } + "nip44_encrypt" => { + let target = request + .params + .first() + .and_then(|hex| { + if hex.len() == 64 { + Some(format!("{}…{}", &hex[..8], &hex[56..])) + } else { + None + } + }) + .unwrap_or_else(|| "a third party".to_string()); + ApprovalDetails { + method: "nip44_encrypt".to_string(), + summary: format!("Encrypt a message for {target}"), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: true, + } + } + "nip44_decrypt" => { + let target = request + .params + .first() + .and_then(|hex| { + if hex.len() == 64 { + Some(format!("{}…{}", &hex[..8], &hex[56..])) + } else { + None + } + }) + .unwrap_or_else(|| "a third party".to_string()); + ApprovalDetails { + method: "nip44_decrypt".to_string(), + summary: format!("Decrypt a message from {target}"), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: true, + } + } + other => ApprovalDetails { + method: other.to_string(), + summary: other.to_string(), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: false, + }, + } + } + + async fn send_connect( + &self, + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + uri: &ConnectUri, + secret: &Option, + ) -> Result<(), String> { + let mut params = vec![keys.public_key().to_hex()]; + if let Some(secret) = secret { + params.push(secret.clone()); + } + let payload = json!({ + "id": uuid::Uuid::new_v4().to_string(), + "method": "connect", + "params": params, + }) + .to_string(); + self.publish_payload(client, keys, conversation, &uri.peer, &payload) + .await + } + + async fn publish_payload( + &self, + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + peer: &PublicKey, + payload: &str, + ) -> Result<(), String> { + let content = nip44_encrypt(conversation, payload).map_err(|e| e.message().to_string())?; + let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; + let event = EventBuilder::new(Kind::NostrConnect, content) + .tags([tag]) + .finalize_async(keys) + .await + .map_err(|e| format!("Could not sign: {e}"))?; + client + .send_event(&event) + .await + .map_err(|e| format!("{e}"))?; + Ok(()) + } +} + +impl Clone for Nip46ClientSigner { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + app: self.app.clone(), + } + } +} + +#[async_trait] +impl Signer for Nip46ClientSigner { + async fn get_public_key(&self) -> Result { + let inner = self.inner.lock().await; + let connection = inner + .connection + .as_ref() + .ok_or_else(|| AppError::config("Not connected to a signer"))?; + PublicKey::from_hex(&connection.signer_pubkey) + .map_err(|_| AppError::config("Invalid signer public key")) + } + + async fn sign_event(&self, _event: UnsignedEvent) -> Result { + // For NIP-46 client, signing happens via the NIP-46 channel with user approval + // The actual flow uses request_approval + respond_to_approval + Err(AppError::config( + "NIP-46 signing uses async approval flow. Use request_approval.", + )) + } + + fn get_signer_type(&self) -> SignerType { + SignerType::Nip46 + } + + async fn is_available(&self) -> bool { + let inner = self.inner.lock().await; + matches!(inner.phase, Nip46Phase::Connected) && inner.client.is_some() + } + + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult { + self.await_approval(details).await + } + + async fn disconnect(&self) -> Result<(), AppError> { + Nip46ClientSigner::disconnect(self).await + } + + async fn revoke(&self) -> Result<(), AppError> { + Nip46ClientSigner::revoke(self).await + } + + async fn status_string(&self) -> String { + let inner = self.inner.lock().await; + match inner.phase { + Nip46Phase::Stopped => "NIP-46: Not connected".to_string(), + Nip46Phase::Connecting => "NIP-46: Connecting…".to_string(), + Nip46Phase::Connected => "NIP-46: Connected".to_string(), + Nip46Phase::Error(ref e) => format!("NIP-46: Error - {e}"), + } + } + + async fn detailed_status(&self) -> serde_json::Value { + let status = self.status().await; + serde_json::to_value(status).unwrap_or(serde_json::json!({})) + } +} + +/// Minimal decrypted NIP-46 request. +#[derive(Debug, Deserialize)] +struct RawRequest { + id: String, + method: String, + #[serde(default)] + params: Vec, +} + +fn response_ok(id: &str, result: String) -> String { + json!({ "id": id, "result": result, "error": null }).to_string() +} + +fn response_err(id: &str, error: String) -> String { + json!({ "id": id, "result": null, "error": error }).to_string() +} + +fn response_ok_rejected(id: &str) -> String { + response_err(id, "The request was rejected by the user.".to_string()) +} + +fn response_ok_timeout(id: &str) -> String { + response_err( + id, + "The user did not approve this request in time; try again.".to_string(), + ) +} + +fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result { + let mut nonce = [0u8; 32]; + getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Entropy error: {e}")))?; + let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce) + .map_err(|e| AppError::internal(format!("Encryption failed: {e}")))?; + Ok(B64.encode(payload)) +} + +fn nip44_decrypt(conversation: &ConversationKey, content: &str) -> Result { + let bytes = B64 + .decode(content) + .map_err(|e| AppError::internal(format!("Decode failed: {e}")))?; + let plaintext = v2::decrypt_to_bytes(conversation, &bytes) + .map_err(|e| AppError::internal(format!("Decryption failed: {e}")))?; + String::from_utf8(plaintext) + .map_err(|_| AppError::internal("Decrypted payload not valid UTF-8")) +} + +fn percent_decode(raw: &str) -> Option { + let mut out: Vec = Vec::with_capacity(raw.len()); + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else if bytes[i] == b'+' { + out.push(b' '); + i += 1; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} diff --git a/src/signer/types.rs b/src/signer/types.rs new file mode 100644 index 0000000..87c6772 --- /dev/null +++ b/src/signer/types.rs @@ -0,0 +1,74 @@ +//! Common types for the Signer abstraction. + +use serde::{Deserialize, Serialize}; + +/// The type of signer being used. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerType { + /// Keys stored locally in the encrypted vault. + Embedded, + /// Keys held by a remote NIP-46 signer (bunker). + Nip46, +} + +/// Details about a signing request, for user approval. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalDetails { + /// The NIP-46 method being requested. + pub method: String, + /// Human-readable summary of what will be done. + pub summary: String, + /// Event kind for `sign_event` requests. + pub event_kind: Option, + /// Destination relays for the signed event. + pub destination_relays: Vec, + /// Truncated preview of event content. + pub content_preview: String, + /// Whether this is a sensitive operation requiring extra confirmation. + pub is_sensitive: bool, +} + +/// Result of a user approval prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalResult { + Approved, + Rejected, + Timeout, +} + +/// Configuration for a NIP-46 connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Nip46Connection { + /// The signer's public key (hex). + pub signer_pubkey: String, + /// Relays to use for the connection. + pub relays: Vec, + /// Optional secret from the nostrconnect URI. + pub secret: Option, + /// Human-readable label for this connection. + pub label: String, + /// When this connection was created. + pub created_at: u64, +} + +/// Status of a NIP-46 connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Nip46Status { + pub connected: bool, + pub signer_pubkey: Option, + pub relays: Vec, + pub connected_relays: Vec, + pub error: Option, + pub pending_approvals: Vec, +} + +/// A pending approval request from the signer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingApproval { + pub id: String, + pub method: String, + pub summary: String, + pub details: ApprovalDetails, +}