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:
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);
}