myc

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

commit c99655b06d5bab291ae24b2dc20956a9ed4d597c
parent eb2267204a8f167f13d7183100a8c1610e1fdb60
Author: triesap <tyson@radroots.org>
Date:   Wed, 25 Mar 2026 22:33:54 +0000

custody: add backend-aware identity providers

- add typed filesystem and os_keyring identity source config for signer, user, and discovery app identities
- route runtime bootstrap, discovery, and status reporting through provider-backed custody resolution
- expose custody backend and resolution state in operability output and document the config contract
- validate with cargo metadata --format-version 1 --no-deps, cargo check --locked, cargo test --locked, and cargo fmt --all --check

Diffstat:
M.env.example | 12++++++++++++
MCargo.lock | 326++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCargo.toml | 1+
Msrc/app/mod.rs | 5+++++
Msrc/app/runtime.rs | 43++++++++++++++++++++++++++++++++++++++++---
Msrc/config.rs | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/custody.rs | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/discovery.rs | 12+++++++-----
Msrc/error.rs | 33+++++++++++++++++++++++++++++++++
Msrc/lib.rs | 9++++++---
Msrc/operability/mod.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/operability_cli.rs | 2++
12 files changed, 1164 insertions(+), 38 deletions(-)

diff --git a/.env.example b/.env.example @@ -4,8 +4,16 @@ MYC_LOGGING_OUTPUT_DIR=/var/log/radroots/services/myc MYC_LOGGING_STDOUT=true MYC_PATHS_STATE_DIR=/var/lib/myc +MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem MYC_PATHS_SIGNER_IDENTITY_PATH=/etc/myc/identities/signer-identity.json +MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID= +MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.signer +MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH= +MYC_PATHS_USER_IDENTITY_BACKEND=filesystem MYC_PATHS_USER_IDENTITY_PATH=/etc/myc/identities/user-identity.json +MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID= +MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user +MYC_PATHS_USER_IDENTITY_PROFILE_PATH= MYC_AUDIT_DEFAULT_READ_LIMIT=200 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 @@ -17,7 +25,11 @@ MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9460 MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.radroots.org MYC_DISCOVERY_HANDLER_IDENTIFIER=myc +MYC_DISCOVERY_APP_IDENTITY_BACKEND= MYC_DISCOVERY_APP_IDENTITY_PATH=/etc/myc/identities/app-identity.json +MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID= +MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.discovery +MYC_DISCOVERY_APP_IDENTITY_PROFILE_PATH= MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.radroots.org MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.radroots.org MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE=https://myc.radroots.org/connect?uri=<nostrconnect> diff --git a/Cargo.lock b/Cargo.lock @@ -321,6 +321,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -494,6 +500,26 @@ dependencies = [ ] [[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -547,6 +573,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + +[[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -636,6 +684,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1125,6 +1188,23 @@ dependencies = [ ] [[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1143,6 +1223,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1221,6 +1321,7 @@ dependencies = [ "radroots-identity", "radroots-log", "radroots-nostr", + "radroots-nostr-accounts", "radroots-nostr-connect", "radroots-nostr-signer", "serde", @@ -1371,6 +1472,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] name = "ordered-multimap" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1469,6 +1618,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1607,6 +1762,20 @@ dependencies = [ ] [[package]] +name = "radroots-nostr-accounts" +version = "0.1.0-alpha.1" +dependencies = [ + "keyring", + "radroots-identity", + "radroots-nostr-signer", + "radroots-runtime", + "serde", + "serde_json", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] name = "radroots-nostr-connect" version = "0.1.0-alpha.1" dependencies = [ @@ -1877,6 +2046,42 @@ dependencies = [ ] [[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2516,6 +2721,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2731,7 +2942,25 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2749,14 +2978,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2766,48 +3012,96 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2990,6 +3284,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -19,6 +19,7 @@ clap = { version = "4.5", features = ["derive"] } nostr = { version = "0.44.2", features = ["nip04", "nip44"] } radroots-identity = { path = "../lib/crates/identity" } radroots-log = { path = "../lib/crates/log" } +radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "memory-vault", "os-keyring"] } radroots-nostr = { path = "../lib/crates/nostr", features = ["client", "events"] } radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -77,6 +77,11 @@ mod tests { assert!(snapshot.audit_dir.ends_with("audit")); assert!(snapshot.signer_identity_path.ends_with("identity.json")); assert!(snapshot.user_identity_path.ends_with("user.json")); + assert_eq!( + snapshot.signer_identity_source.backend.as_str(), + "filesystem" + ); + assert_eq!(snapshot.user_identity_source.backend.as_str(), "filesystem"); assert!(snapshot.signer_state_path.ends_with("signer-state.json")); assert!(!snapshot.signer_identity_id.is_empty()); assert!(!snapshot.signer_public_key_hex.is_empty()); diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -4,7 +4,8 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore}; -use crate::config::{MycAuditConfig, MycConfig}; +use crate::config::{MycAuditConfig, MycConfig, MycIdentitySourceSpec}; +use crate::custody::MycIdentityProvider; use crate::error::MycError; use crate::operability::server::run_observability_server; use crate::policy::MycPolicyContext; @@ -36,6 +37,8 @@ pub struct MycStartupSnapshot { pub audit_dir: PathBuf, pub signer_identity_path: PathBuf, pub user_identity_path: PathBuf, + pub signer_identity_source: MycIdentitySourceSpec, + pub user_identity_source: MycIdentitySourceSpec, pub signer_state_path: PathBuf, pub signer_identity_id: String, pub signer_public_key_hex: String, @@ -46,6 +49,8 @@ pub struct MycStartupSnapshot { #[derive(Clone)] pub struct MycSignerContext { + signer_identity_provider: MycIdentityProvider, + user_identity_provider: MycIdentityProvider, signer_identity: RadrootsIdentity, user_identity: RadrootsIdentity, signer_state_path: PathBuf, @@ -73,6 +78,8 @@ impl MycRuntime { &paths, config.audit.clone(), MycPolicyContext::from_config(&config.policy)?, + config.paths.signer_identity_source(), + config.paths.user_identity_source(), )?; let transport = MycNostrTransport::bootstrap(&config.transport, &signer.signer_identity)?; let runtime = Self { @@ -140,6 +147,8 @@ impl MycRuntime { audit_dir: self.paths.audit_dir.clone(), signer_identity_path: self.paths.signer_identity_path.clone(), user_identity_path: self.paths.user_identity_path.clone(), + signer_identity_source: self.signer.signer_identity_source().clone(), + user_identity_source: self.signer.user_identity_source().clone(), signer_state_path: self.paths.signer_state_path.clone(), signer_identity_id: signer_public.id.into_string(), signer_public_key_hex: signer_public.public_key_hex, @@ -168,6 +177,10 @@ impl MycRuntime { audit_dir = %snapshot.audit_dir.display(), signer_identity_path = %snapshot.signer_identity_path.display(), user_identity_path = %snapshot.user_identity_path.display(), + signer_identity_backend = %snapshot.signer_identity_source.backend.as_str(), + user_identity_backend = %snapshot.user_identity_source.backend.as_str(), + signer_keyring_account_id = snapshot.signer_identity_source.keyring_account_id.as_deref().unwrap_or(""), + user_keyring_account_id = snapshot.user_identity_source.keyring_account_id.as_deref().unwrap_or(""), signer_state_path = %snapshot.signer_state_path.display(), signer_identity_id = %snapshot.signer_identity_id, signer_public_key_hex = %snapshot.signer_public_key_hex, @@ -288,6 +301,14 @@ impl MycSignerContext { &self.signer_identity } + pub fn signer_identity_source(&self) -> &MycIdentitySourceSpec { + self.signer_identity_provider.source() + } + + pub fn signer_identity_provider(&self) -> &MycIdentityProvider { + &self.signer_identity_provider + } + pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { self.signer_identity.to_public() } @@ -296,6 +317,14 @@ impl MycSignerContext { &self.user_identity } + pub fn user_identity_source(&self) -> &MycIdentitySourceSpec { + self.user_identity_provider.source() + } + + pub fn user_identity_provider(&self) -> &MycIdentityProvider { + &self.user_identity_provider + } + pub fn user_public_identity(&self) -> RadrootsIdentityPublic { self.user_identity.to_public() } @@ -342,9 +371,15 @@ impl MycSignerContext { paths: &MycRuntimePaths, audit_config: MycAuditConfig, policy: MycPolicyContext, + signer_identity_source: MycIdentitySourceSpec, + user_identity_source: MycIdentitySourceSpec, ) -> Result<Self, MycError> { - let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; - let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?; + let signer_identity_provider = + MycIdentityProvider::from_source("signer", signer_identity_source)?; + let user_identity_provider = + MycIdentityProvider::from_source("user", user_identity_source)?; + let signer_identity = signer_identity_provider.load_identity()?; + let user_identity = user_identity_provider.load_identity()?; let manager = Self::load_signer_manager_from_path(&paths.signer_state_path)?; let configured_public = signer_identity.to_public(); @@ -362,6 +397,8 @@ impl MycSignerContext { } Ok(Self { + signer_identity_provider, + user_identity_provider, signer_identity, user_identity, signer_state_path: paths.signer_state_path.clone(), diff --git a/src/config.rs b/src/config.rs @@ -46,8 +46,16 @@ pub struct MycLoggingConfig { #[serde(default, deny_unknown_fields)] pub struct MycPathsConfig { pub state_dir: PathBuf, + pub signer_identity_backend: MycIdentityBackend, pub signer_identity_path: PathBuf, + pub signer_identity_keyring_account_id: Option<String>, + pub signer_identity_keyring_service_name: String, + pub signer_identity_profile_path: Option<PathBuf>, + pub user_identity_backend: MycIdentityBackend, pub user_identity_path: PathBuf, + pub user_identity_keyring_account_id: Option<String>, + pub user_identity_keyring_service_name: String, + pub user_identity_profile_path: Option<PathBuf>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -71,7 +79,11 @@ pub struct MycDiscoveryConfig { pub enabled: bool, pub domain: Option<String>, pub handler_identifier: String, + pub app_identity_backend: Option<MycIdentityBackend>, pub app_identity_path: Option<PathBuf>, + pub app_identity_keyring_account_id: Option<String>, + pub app_identity_keyring_service_name: Option<String>, + pub app_identity_profile_path: Option<PathBuf>, pub public_relays: Vec<String>, pub publish_relays: Vec<String>, pub nostrconnect_url_template: Option<String>, @@ -112,6 +124,26 @@ pub enum MycConnectionApproval { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +pub enum MycIdentityBackend { + Filesystem, + OsKeyring, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycIdentitySourceSpec { + pub backend: MycIdentityBackend, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<PathBuf>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring_account_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring_service_name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_path: Option<PathBuf>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum MycTransportDeliveryPolicy { Any, Quorum, @@ -169,8 +201,16 @@ impl Default for MycPathsConfig { fn default() -> Self { Self { state_dir: PathBuf::from("var"), + signer_identity_backend: MycIdentityBackend::Filesystem, signer_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), + signer_identity_keyring_account_id: None, + signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(), + signer_identity_profile_path: None, + user_identity_backend: MycIdentityBackend::Filesystem, user_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), + user_identity_keyring_account_id: None, + user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(), + user_identity_profile_path: None, } } } @@ -217,7 +257,11 @@ impl Default for MycDiscoveryConfig { enabled: false, domain: None, handler_identifier: "myc".to_owned(), + app_identity_backend: None, app_identity_path: None, + app_identity_keyring_account_id: None, + app_identity_keyring_service_name: None, + app_identity_profile_path: None, public_relays: Vec::new(), publish_relays: Vec::new(), nostrconnect_url_template: None, @@ -255,6 +299,12 @@ impl Default for MycPolicyConfig { } } +impl Default for MycIdentityBackend { + fn default() -> Self { + Self::Filesystem + } +} + impl MycConnectionApproval { pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement { match self { @@ -274,6 +324,65 @@ impl MycTransportDeliveryPolicy { } } +impl MycIdentityBackend { + pub fn as_str(self) -> &'static str { + match self { + Self::Filesystem => "filesystem", + Self::OsKeyring => "os_keyring", + } + } +} + +impl MycPathsConfig { + pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { + MycIdentitySourceSpec { + backend: self.signer_identity_backend, + path: match self.signer_identity_backend { + MycIdentityBackend::Filesystem => Some(self.signer_identity_path.clone()), + MycIdentityBackend::OsKeyring => None, + }, + keyring_account_id: match self.signer_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.signer_identity_keyring_account_id.clone(), + }, + keyring_service_name: match self.signer_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => { + Some(self.signer_identity_keyring_service_name.clone()) + } + }, + profile_path: match self.signer_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.signer_identity_profile_path.clone(), + }, + } + } + + pub fn user_identity_source(&self) -> MycIdentitySourceSpec { + MycIdentitySourceSpec { + backend: self.user_identity_backend, + path: match self.user_identity_backend { + MycIdentityBackend::Filesystem => Some(self.user_identity_path.clone()), + MycIdentityBackend::OsKeyring => None, + }, + keyring_account_id: match self.user_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.user_identity_keyring_account_id.clone(), + }, + keyring_service_name: match self.user_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => { + Some(self.user_identity_keyring_service_name.clone()) + } + }, + profile_path: match self.user_identity_backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.user_identity_profile_path.clone(), + }, + } + } +} + impl MycConfig { pub fn load_from_default_env_path() -> Result<Self, MycError> { Self::load_from_env_path(DEFAULT_ENV_PATH) @@ -326,17 +435,11 @@ impl MycConfig { )); } - if self.paths.signer_identity_path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig( - "paths.signer_identity_path must not be empty".to_owned(), - )); - } - - if self.paths.user_identity_path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig( - "paths.user_identity_path must not be empty".to_owned(), - )); - } + validate_identity_source_config( + "paths.signer_identity", + &self.paths.signer_identity_source(), + )?; + validate_identity_source_config("paths.user_identity", &self.paths.user_identity_source())?; if self.audit.default_read_limit == 0 { return Err(MycError::InvalidConfig( @@ -567,12 +670,38 @@ fn apply_env_entry( config.logging.stdout = parse_bool_env(key, value, path, line_number)?; } "MYC_PATHS_STATE_DIR" => config.paths.state_dir = PathBuf::from(value), + "MYC_PATHS_SIGNER_IDENTITY_BACKEND" => { + config.paths.signer_identity_backend = + parse_identity_backend_env(key, value, path, line_number)?; + } "MYC_PATHS_SIGNER_IDENTITY_PATH" => { config.paths.signer_identity_path = PathBuf::from(value); } + "MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID" => { + config.paths.signer_identity_keyring_account_id = parse_optional_string_env(value); + } + "MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME" => { + config.paths.signer_identity_keyring_service_name = value.to_owned(); + } + "MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH" => { + config.paths.signer_identity_profile_path = parse_optional_path_env(value); + } + "MYC_PATHS_USER_IDENTITY_BACKEND" => { + config.paths.user_identity_backend = + parse_identity_backend_env(key, value, path, line_number)?; + } "MYC_PATHS_USER_IDENTITY_PATH" => { config.paths.user_identity_path = PathBuf::from(value); } + "MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID" => { + config.paths.user_identity_keyring_account_id = parse_optional_string_env(value); + } + "MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME" => { + config.paths.user_identity_keyring_service_name = value.to_owned(); + } + "MYC_PATHS_USER_IDENTITY_PROFILE_PATH" => { + config.paths.user_identity_profile_path = parse_optional_path_env(value); + } "MYC_AUDIT_DEFAULT_READ_LIMIT" => { config.audit.default_read_limit = parse_usize_env(key, value, path, line_number)?; } @@ -597,9 +726,22 @@ fn apply_env_entry( "MYC_DISCOVERY_HANDLER_IDENTIFIER" => { config.discovery.handler_identifier = value.to_owned(); } + "MYC_DISCOVERY_APP_IDENTITY_BACKEND" => { + config.discovery.app_identity_backend = + parse_optional_identity_backend_env(key, value, path, line_number)?; + } "MYC_DISCOVERY_APP_IDENTITY_PATH" => { config.discovery.app_identity_path = parse_optional_path_env(value); } + "MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID" => { + config.discovery.app_identity_keyring_account_id = parse_optional_string_env(value); + } + "MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME" => { + config.discovery.app_identity_keyring_service_name = parse_optional_string_env(value); + } + "MYC_DISCOVERY_APP_IDENTITY_PROFILE_PATH" => { + config.discovery.app_identity_profile_path = parse_optional_path_env(value); + } "MYC_DISCOVERY_PUBLIC_RELAYS" => { config.discovery.public_relays = parse_string_list_env(value); } @@ -772,6 +914,35 @@ fn parse_connection_approval_env( } } +fn parse_identity_backend_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<MycIdentityBackend, MycError> { + match value { + "filesystem" => Ok(MycIdentityBackend::Filesystem), + "os_keyring" => Ok(MycIdentityBackend::OsKeyring), + _ => Err(config_parse_error( + path, + line_number, + format!("{key} must be `filesystem` or `os_keyring`"), + )), + } +} + +fn parse_optional_identity_backend_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<Option<MycIdentityBackend>, MycError> { + match parse_optional_string_env(value) { + Some(value) => parse_identity_backend_env(key, value.as_str(), path, line_number).map(Some), + None => Ok(None), + } +} + fn parse_delivery_policy_env( key: &str, value: &str, @@ -869,6 +1040,57 @@ fn config_parse_error(path: &Path, line_number: usize, message: impl Into<String } } +fn validate_identity_source_config( + label: &str, + source: &MycIdentitySourceSpec, +) -> Result<(), MycError> { + match source.backend { + MycIdentityBackend::Filesystem => { + let Some(path) = source.path.as_ref() else { + return Err(MycError::InvalidConfig(format!( + "{label}.path must be set when backend is `filesystem`" + ))); + }; + if path.as_os_str().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{label}.path must not be empty when backend is `filesystem`" + ))); + } + } + MycIdentityBackend::OsKeyring => { + let Some(account_id) = source.keyring_account_id.as_deref() else { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_account_id must be set when backend is `os_keyring`" + ))); + }; + let _ = radroots_identity::RadrootsIdentityId::parse(account_id).map_err(|_| { + MycError::InvalidConfig(format!( + "{label}.keyring_account_id must be a valid nostr public identity id" + )) + })?; + let Some(service_name) = source.keyring_service_name.as_deref() else { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_service_name must be set when backend is `os_keyring`" + ))); + }; + if service_name.trim().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_service_name must not be empty when backend is `os_keyring`" + ))); + } + if let Some(profile_path) = source.profile_path.as_ref() + && profile_path.as_os_str().is_empty() + { + return Err(MycError::InvalidConfig(format!( + "{label}.profile_path must not be empty when set" + ))); + } + } + } + + Ok(()) +} + impl MycTransportConfig { pub fn parse_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { self.relays @@ -885,6 +1107,34 @@ impl MycTransportConfig { } impl MycDiscoveryConfig { + pub fn app_identity_source(&self) -> Option<MycIdentitySourceSpec> { + let backend = match (self.app_identity_backend, self.app_identity_path.as_ref()) { + (Some(backend), _) => Some(backend), + (None, Some(_)) => Some(MycIdentityBackend::Filesystem), + (None, None) => None, + }?; + + Some(MycIdentitySourceSpec { + backend, + path: match backend { + MycIdentityBackend::Filesystem => self.app_identity_path.clone(), + MycIdentityBackend::OsKeyring => None, + }, + keyring_account_id: match backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.app_identity_keyring_account_id.clone(), + }, + keyring_service_name: match backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.app_identity_keyring_service_name.clone(), + }, + profile_path: match backend { + MycIdentityBackend::Filesystem => None, + MycIdentityBackend::OsKeyring => self.app_identity_profile_path.clone(), + }, + }) + } + pub fn parse_public_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { parse_discovery_relays(&self.public_relays, "discovery.public_relays") } @@ -936,12 +1186,8 @@ impl MycDiscoveryConfig { )); } - if let Some(path) = self.app_identity_path.as_ref() { - if path.as_os_str().is_empty() { - return Err(MycError::InvalidConfig( - "discovery.app_identity_path must not be empty".to_owned(), - )); - } + if let Some(source) = self.app_identity_source() { + validate_identity_source_config("discovery.app_identity", &source)?; } if let Some(template) = self.nostrconnect_url_template.as_deref() { @@ -1055,13 +1301,33 @@ mod tests { assert!(config.logging.stdout); assert_eq!(config.paths.state_dir, PathBuf::from("var")); assert_eq!( + config.paths.signer_identity_backend, + MycIdentityBackend::Filesystem + ); + assert_eq!( config.paths.signer_identity_path, PathBuf::from(DEFAULT_IDENTITY_PATH) ); + assert_eq!(config.paths.signer_identity_keyring_account_id, None); + assert_eq!( + config.paths.signer_identity_keyring_service_name, + "org.radroots.myc.signer" + ); + assert_eq!(config.paths.signer_identity_profile_path, None); + assert_eq!( + config.paths.user_identity_backend, + MycIdentityBackend::Filesystem + ); assert_eq!( config.paths.user_identity_path, PathBuf::from(DEFAULT_IDENTITY_PATH) ); + assert_eq!(config.paths.user_identity_keyring_account_id, None); + assert_eq!( + config.paths.user_identity_keyring_service_name, + "org.radroots.myc.user" + ); + assert_eq!(config.paths.user_identity_profile_path, None); assert_eq!( config.policy.connection_approval, MycConnectionApproval::ExplicitUser @@ -1087,6 +1353,7 @@ mod tests { assert!(!config.discovery.enabled); assert_eq!(config.discovery.handler_identifier, "myc"); assert!(config.discovery.domain.is_none()); + assert_eq!(config.discovery.app_identity_backend, None); assert!(config.discovery.public_relays.is_empty()); assert!(config.discovery.publish_relays.is_empty()); assert!(config.discovery.nostrconnect_url_template.is_none()); @@ -1113,7 +1380,9 @@ MYC_LOGGING_FILTER=debug,myc=trace MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs MYC_LOGGING_STDOUT=false MYC_PATHS_STATE_DIR=/tmp/myc +MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/myc-identity.json +MYC_PATHS_USER_IDENTITY_BACKEND=filesystem MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json MYC_AUDIT_DEFAULT_READ_LIMIT=50 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 @@ -1123,6 +1392,7 @@ MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.example.com MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main +MYC_DISCOVERY_APP_IDENTITY_BACKEND=filesystem MYC_DISCOVERY_APP_IDENTITY_PATH=/tmp/myc-app.json MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.discovery.example.com MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.publish.example.com @@ -1163,10 +1433,18 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 assert!(!config.logging.stdout); assert_eq!(config.paths.state_dir, PathBuf::from("/tmp/myc")); assert_eq!( + config.paths.signer_identity_backend, + MycIdentityBackend::Filesystem + ); + assert_eq!( config.paths.signer_identity_path, PathBuf::from("/tmp/myc-identity.json") ); assert_eq!( + config.paths.user_identity_backend, + MycIdentityBackend::Filesystem + ); + assert_eq!( config.paths.user_identity_path, PathBuf::from("/tmp/myc-user.json") ); @@ -1182,6 +1460,10 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 assert_eq!(config.discovery.domain.as_deref(), Some("myc.example.com")); assert_eq!(config.discovery.handler_identifier, "myc-main"); assert_eq!( + config.discovery.app_identity_backend, + Some(MycIdentityBackend::Filesystem) + ); + assert_eq!( config.discovery.app_identity_path, Some(PathBuf::from("/tmp/myc-app.json")) ); @@ -1423,6 +1705,51 @@ MYC_UNKNOWN=nope } #[test] + fn parse_and_validate_os_keyring_identity_backends() { + let config = MycConfig::from_env_str( + r#" +MYC_PATHS_SIGNER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111 +MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer +MYC_PATHS_USER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID=2222222222222222222222222222222222222222222222222222222222222222 +MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=myc.example.com +MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.example.com +MYC_DISCOVERY_APP_IDENTITY_BACKEND=os_keyring +MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID=3333333333333333333333333333333333333333333333333333333333333333 +MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery + "#, + ) + .expect("config"); + + assert_eq!( + config.paths.signer_identity_backend, + MycIdentityBackend::OsKeyring + ); + assert_eq!( + config.paths.signer_identity_keyring_account_id.as_deref(), + Some("1111111111111111111111111111111111111111111111111111111111111111") + ); + assert_eq!( + config.paths.user_identity_backend, + MycIdentityBackend::OsKeyring + ); + assert_eq!( + config.discovery.app_identity_backend, + Some(MycIdentityBackend::OsKeyring) + ); + assert_eq!( + config + .discovery + .app_identity_keyring_service_name + .as_deref(), + Some("org.radroots.myc.test.discovery") + ); + } + + #[test] fn example_env_parses_and_validates() { let example = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example")) diff --git a/src/custody.rs b/src/custody.rs @@ -0,0 +1,331 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrSecretVault, RadrootsNostrSecretVaultOsKeyring, +}; +use serde::Serialize; + +use crate::config::{MycIdentityBackend, MycIdentitySourceSpec}; +use crate::error::MycError; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycIdentityStatusOutput { + pub backend: MycIdentityBackend, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<PathBuf>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring_account_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring_service_name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_path: Option<PathBuf>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited_from: Option<String>, + pub resolved: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key_hex: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +#[derive(Clone)] +pub struct MycIdentityProvider { + role: String, + source: MycIdentitySourceSpec, + backend: MycIdentityProviderBackend, +} + +#[derive(Clone)] +enum MycIdentityProviderBackend { + Filesystem { + path: PathBuf, + }, + OsKeyring { + account_id: RadrootsIdentityId, + service_name: String, + profile_path: Option<PathBuf>, + vault: Arc<dyn RadrootsNostrSecretVault>, + }, +} + +impl MycIdentityProvider { + pub fn from_source( + role: impl Into<String>, + source: MycIdentitySourceSpec, + ) -> Result<Self, MycError> { + let role = role.into(); + let backend = match source.backend { + MycIdentityBackend::Filesystem => { + let path = source.path.clone().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity filesystem backend requires a path" + )) + })?; + MycIdentityProviderBackend::Filesystem { path } + } + MycIdentityBackend::OsKeyring => { + let account_id = RadrootsIdentityId::parse( + source.keyring_account_id.as_deref().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity os_keyring backend requires keyring_account_id" + )) + })?, + ) + .map_err(|_| { + MycError::InvalidConfig(format!( + "{role} identity os_keyring backend requires a valid keyring_account_id" + )) + })?; + let service_name = source.keyring_service_name.clone().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity os_keyring backend requires keyring_service_name" + )) + })?; + Self::vault_provider(role.as_str(), &source, account_id, service_name)? + } + }; + + Ok(Self { + role, + source, + backend, + }) + } + + pub fn load_identity(&self) -> Result<RadrootsIdentity, MycError> { + match &self.backend { + MycIdentityProviderBackend::Filesystem { path } => { + RadrootsIdentity::load_from_path_auto(path).map_err(Into::into) + } + MycIdentityProviderBackend::OsKeyring { + account_id, + service_name, + profile_path, + vault, + } => { + let secret_key_hex = vault + .load_secret_hex(account_id) + .map_err(|source| MycError::CustodyVault { + role: self.role.clone(), + source, + })? + .ok_or_else(|| MycError::CustodySecretNotFound { + role: self.role.clone(), + service_name: service_name.clone(), + account_id: account_id.to_string(), + })?; + let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; + if identity.id() != *account_id { + return Err(MycError::CustodySecretIdentityMismatch { + role: self.role.clone(), + service_name: service_name.clone(), + account_id: account_id.to_string(), + resolved_identity_id: identity.id().to_string(), + }); + } + if let Some(profile_path) = profile_path { + let profile_identity = RadrootsIdentity::load_from_path_auto(profile_path)?; + if profile_identity.id() != *account_id { + return Err(MycError::CustodyProfileIdentityMismatch { + role: self.role.clone(), + path: profile_path.clone(), + account_id: account_id.to_string(), + profile_identity_id: profile_identity.id().to_string(), + }); + } + if let Some(profile) = profile_identity.profile().cloned() { + identity.set_profile(profile); + } + } + Ok(identity) + } + } + } + + pub fn resolved_status(&self, identity: &RadrootsIdentity) -> MycIdentityStatusOutput { + self.status_with_result(Ok(identity)) + } + + pub fn probe_status(&self) -> MycIdentityStatusOutput { + self.status_with_result(self.load_identity().as_ref()) + } + + pub fn source(&self) -> &MycIdentitySourceSpec { + &self.source + } + + fn status_with_result( + &self, + result: Result<&RadrootsIdentity, &MycError>, + ) -> MycIdentityStatusOutput { + match result { + Ok(identity) => MycIdentityStatusOutput { + backend: self.source.backend, + path: self.source.path.clone(), + keyring_account_id: self.source.keyring_account_id.clone(), + keyring_service_name: self.source.keyring_service_name.clone(), + profile_path: self.source.profile_path.clone(), + inherited_from: None, + resolved: true, + identity_id: Some(identity.id().to_string()), + public_key_hex: Some(identity.public_key_hex()), + error: None, + }, + Err(error) => MycIdentityStatusOutput { + backend: self.source.backend, + path: self.source.path.clone(), + keyring_account_id: self.source.keyring_account_id.clone(), + keyring_service_name: self.source.keyring_service_name.clone(), + profile_path: self.source.profile_path.clone(), + inherited_from: None, + resolved: false, + identity_id: None, + public_key_hex: None, + error: Some(error.to_string()), + }, + } + } + + fn vault_provider( + role: &str, + source: &MycIdentitySourceSpec, + account_id: RadrootsIdentityId, + service_name: String, + ) -> Result<MycIdentityProviderBackend, MycError> { + if service_name.trim().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{role} identity os_keyring backend requires a non-empty keyring_service_name" + ))); + } + Ok(MycIdentityProviderBackend::OsKeyring { + account_id, + service_name: service_name.clone(), + profile_path: source.profile_path.clone(), + vault: Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name)), + }) + } +} + +impl MycIdentityStatusOutput { + pub fn with_inherited_from(mut self, inherited_from: impl Into<String>) -> Self { + self.inherited_from = Some(inherited_from.into()); + self + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use radroots_identity::RadrootsIdentity; + use radroots_nostr_accounts::prelude::RadrootsNostrSecretVaultMemory; + + use super::*; + + fn write_identity(path: &Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn fixture_source(path: &Path) -> MycIdentitySourceSpec { + MycIdentitySourceSpec { + backend: MycIdentityBackend::Filesystem, + path: Some(path.to_path_buf()), + keyring_account_id: None, + keyring_service_name: None, + profile_path: None, + } + } + + #[test] + fn filesystem_provider_loads_identity() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("signer.json"); + write_identity( + &path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + + let provider = + MycIdentityProvider::from_source("signer", fixture_source(&path)).expect("provider"); + let identity = provider.load_identity().expect("identity"); + + assert_eq!( + identity.public_key_hex(), + "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + ); + } + + #[test] + fn vault_provider_loads_identity_and_merges_profile() { + let temp = tempfile::tempdir().expect("tempdir"); + let profile_path = temp.path().join("profile.json"); + let identity = RadrootsIdentity::from_secret_key_str( + "1111111111111111111111111111111111111111111111111111111111111111", + ) + .expect("identity"); + identity.save_json(&profile_path).expect("save profile"); + + let account_id = identity.id(); + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + vault + .store_secret_hex(&account_id, identity.secret_key_hex().as_str()) + .expect("store"); + + let provider = MycIdentityProvider { + role: "signer".to_owned(), + source: MycIdentitySourceSpec { + backend: MycIdentityBackend::OsKeyring, + path: None, + keyring_account_id: Some(account_id.to_string()), + keyring_service_name: Some("org.radroots.test".to_owned()), + profile_path: Some(profile_path.clone()), + }, + backend: MycIdentityProviderBackend::OsKeyring { + account_id: account_id.clone(), + service_name: "org.radroots.test".to_owned(), + profile_path: Some(profile_path), + vault, + }, + }; + + let loaded = provider.load_identity().expect("loaded"); + assert_eq!(loaded.id(), account_id); + assert!(provider.probe_status().resolved); + } + + #[test] + fn vault_provider_reports_missing_secret() { + let account_id = RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + ) + .expect("identity") + .id(); + let provider = MycIdentityProvider { + role: "user".to_owned(), + source: MycIdentitySourceSpec { + backend: MycIdentityBackend::OsKeyring, + path: None, + keyring_account_id: Some(account_id.to_string()), + keyring_service_name: Some("org.radroots.test".to_owned()), + profile_path: None, + }, + backend: MycIdentityProviderBackend::OsKeyring { + account_id: account_id.clone(), + service_name: "org.radroots.test".to_owned(), + profile_path: None, + vault: Arc::new(RadrootsNostrSecretVaultMemory::new()), + }, + }; + + let err = provider.load_identity().expect_err("missing secret"); + assert!(matches!(err, MycError::CustodySecretNotFound { .. })); + assert!(!provider.probe_status().resolved); + } +} diff --git a/src/discovery.rs b/src/discovery.rs @@ -18,6 +18,7 @@ use tokio::task::JoinSet; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::MycDiscoveryMetadataConfig; +use crate::custody::MycIdentityProvider; use crate::error::MycError; use crate::transport::{MycNostrTransport, MycRelayPublishResult}; @@ -286,11 +287,12 @@ impl MycDiscoveryContext { )); } - let app_identity_path = discovery - .app_identity_path - .clone() - .unwrap_or_else(|| runtime.paths().signer_identity_path.clone()); - let app_identity = RadrootsIdentity::load_from_path_auto(&app_identity_path)?; + let app_identity = match discovery.app_identity_source() { + Some(source) => { + MycIdentityProvider::from_source("discovery app", source)?.load_identity()? + } + None => runtime.signer_identity().clone(), + }; let public_relays = discovery.resolved_public_relays(&runtime.config().transport)?; let publish_relays = discovery.resolved_publish_relays(&runtime.config().transport)?; let nostrconnect_url = discovery diff --git a/src/error.rs b/src/error.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use radroots_identity::IdentityError; use radroots_nostr::prelude::RadrootsNostrError; +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; use radroots_nostr_connect::prelude::RadrootsNostrConnectError; use radroots_nostr_signer::prelude::RadrootsNostrSignerError; use thiserror::Error; @@ -98,6 +99,38 @@ pub enum MycError { #[source] source: Box<MycError>, }, + #[error("custody vault error for {role} identity: {source}")] + CustodyVault { + role: String, + #[source] + source: RadrootsNostrAccountsError, + }, + #[error( + "no secret found in custody vault service `{service_name}` for {role} identity `{account_id}`" + )] + CustodySecretNotFound { + role: String, + service_name: String, + account_id: String, + }, + #[error( + "custody vault service `{service_name}` resolved {role} identity `{resolved_identity_id}` but expected `{account_id}`" + )] + CustodySecretIdentityMismatch { + role: String, + service_name: String, + account_id: String, + resolved_identity_id: String, + }, + #[error( + "public identity file {path} resolved {role} identity `{profile_identity_id}` but expected `{account_id}`" + )] + CustodyProfileIdentityMismatch { + role: String, + path: PathBuf, + account_id: String, + profile_identity_id: String, + }, #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] diff --git a/src/lib.rs b/src/lib.rs @@ -5,6 +5,7 @@ pub mod audit; pub mod cli; pub mod config; pub mod control; +pub mod custody; pub mod discovery; pub mod error; pub mod logging; @@ -19,10 +20,12 @@ pub use audit::{ }; pub use config::{ DEFAULT_ENV_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycDiscoveryConfig, - MycDiscoveryMetadataConfig, MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, - MycPolicyConfig, MycServiceConfig, MycTransportConfig, MycTransportDeliveryPolicy, + MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, + MycObservabilityConfig, MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig, + MycTransportDeliveryPolicy, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; +pub use custody::{MycIdentityProvider, MycIdentityStatusOutput}; pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, @@ -36,7 +39,7 @@ pub use discovery::{ }; pub use error::MycError; pub use operability::{ - MycAuditDecisionCounts, MycDiscoveryStatusOutput, MycMetricsSnapshot, + MycAuditDecisionCounts, MycCustodyStatusOutput, MycDiscoveryStatusOutput, MycMetricsSnapshot, MycOperationOutcomeCounts, MycRelayProbe, MycRelayProbeAvailability, MycRuntimeStatus, MycStatusFullOutput, MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, collect_status_summary, render_metrics_text, diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -14,6 +14,7 @@ use tokio::task::JoinSet; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome}; use crate::config::MycTransportDeliveryPolicy; +use crate::custody::MycIdentityStatusOutput; use crate::discovery::MycDiscoveryContext; use crate::error::MycError; use crate::transport::MycTransportSnapshot; @@ -84,11 +85,20 @@ pub struct MycDiscoveryStatusOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycCustodyStatusOutput { + pub signer: MycIdentityStatusOutput, + pub user: MycIdentityStatusOutput, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovery_app: Option<MycIdentityStatusOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycStatusFullOutput { pub status: MycRuntimeStatus, pub ready: bool, pub reasons: Vec<String>, pub startup: crate::app::MycStartupSnapshot, + pub custody: MycCustodyStatusOutput, pub transport: MycTransportStatusOutput, pub discovery: MycDiscoveryStatusOutput, } @@ -99,6 +109,7 @@ pub struct MycStatusSummaryOutput { pub ready: bool, pub reasons: Vec<String>, pub instance_name: String, + pub custody: MycCustodyStatusOutput, pub transport: MycTransportStatusOutput, pub discovery: MycDiscoveryStatusOutput, } @@ -143,13 +154,19 @@ struct MycTransportStatusEvaluation { reasons: Vec<String>, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MycCustodyStatusEvaluation { + output: MycCustodyStatusOutput, +} + pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOutput, MycError> { let snapshot = runtime.snapshot(); + let custody = collect_custody_status(runtime)?; let transport = collect_transport_status(runtime).await?; let discovery = collect_discovery_status(runtime).await?; let mut reasons = transport.reasons; reasons.extend(discovery.reasons); - let status = combine_runtime_status( + let mut status = combine_runtime_status( transport.output.status, if discovery.output.enabled { Some(discovery.output.status) @@ -157,12 +174,23 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu None }, ); + if custody + .output + .discovery_app + .as_ref() + .is_some_and(|status_output| !status_output.resolved) + && status != MycRuntimeStatus::Unready + { + status = MycRuntimeStatus::Degraded; + reasons.push("discovery app identity could not be resolved".to_owned()); + } let ready = transport.output.ready; Ok(MycStatusFullOutput { status, ready, reasons, startup: snapshot, + custody: custody.output, transport: transport.output, discovery: discovery.output, }) @@ -177,6 +205,7 @@ pub async fn collect_status_summary( ready: full.ready, reasons: full.reasons, instance_name: full.startup.instance_name, + custody: full.custody, transport: MycTransportStatusOutput { relay_probes: Vec::new(), ..full.transport @@ -196,6 +225,42 @@ pub async fn collect_status_summary( }) } +fn collect_custody_status(runtime: &MycRuntime) -> Result<MycCustodyStatusEvaluation, MycError> { + let signer = runtime + .signer_context() + .signer_identity_provider() + .resolved_status(runtime.signer_identity()); + let user = runtime + .signer_context() + .user_identity_provider() + .resolved_status(runtime.user_identity()); + let discovery_app = if runtime.config().discovery.enabled { + match runtime.config().discovery.app_identity_source() { + Some(source) => Some( + crate::custody::MycIdentityProvider::from_source("discovery app", source)? + .probe_status(), + ), + None => Some( + runtime + .signer_context() + .signer_identity_provider() + .resolved_status(runtime.signer_identity()) + .with_inherited_from("signer"), + ), + } + } else { + None + }; + + Ok(MycCustodyStatusEvaluation { + output: MycCustodyStatusOutput { + signer, + user, + discovery_app, + }, + }) +} + pub fn collect_metrics(runtime: &MycRuntime) -> Result<MycMetricsSnapshot, MycError> { let manager = runtime.signer_manager()?; let signer_request_audit = manager.list_audit_records()?; diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -67,6 +67,8 @@ fn status_summary_command_emits_machine_readable_json() { let value: Value = serde_json::from_slice(&output.stdout).expect("status json"); assert_eq!(value["status"], "unready"); assert_eq!(value["ready"], false); + assert_eq!(value["custody"]["signer"]["backend"], "filesystem"); + assert_eq!(value["custody"]["signer"]["resolved"], true); assert_eq!(value["transport"]["enabled"], false); }