commit 4f22475cc3dd8480fa6e051505e1a881534d8b6a
parent cbaae379a05ff9c9713e21e113412bba85807de7
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 22:23:13 +0000
custody: add explicit nip49 operator flows
Diffstat:
| M | Cargo.lock | | | 185 | +++++++++++++++++++++++++++++++++++++++---------------------------------------- |
| M | src/cli.rs | | | 138 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/custody.rs | | | 265 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/identity_storage.rs | | | 142 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/lib.rs | | | 5 | +++-- |
| M | tests/operability_cli.rs | | | 106 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
6 files changed, 738 insertions(+), 103 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -343,9 +343,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.57"
+version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
+checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -679,9 +679,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
@@ -975,9 +975,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
-version = "1.8.1"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -989,7 +989,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
- "pin-utils",
"smallvec",
"tokio",
]
@@ -1035,12 +1034,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -1048,9 +1048,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -1061,9 +1061,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1075,15 +1075,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -1095,15 +1095,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -1143,9 +1143,9 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.13.0"
+version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
@@ -1189,10 +1189,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
-version = "0.3.91"
+version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1239,9 +1241,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.183"
+version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libdbus-sys"
@@ -1282,9 +1284,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
@@ -1333,9 +1335,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@@ -1479,9 +1481,9 @@ dependencies = [
[[package]]
name = "num-conv"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
@@ -1538,9 +1540,9 @@ dependencies = [
[[package]]
name = "openssl-src"
-version = "300.5.5+3.5.5"
+version = "300.6.0+3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
+checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
dependencies = [
"cc",
]
@@ -1651,12 +1653,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
-name = "pin-utils"
-version = "0.1.0"
-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"
@@ -1675,9 +1671,9 @@ dependencies = [
[[package]]
name = "potential_utf"
-version = "0.1.4"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -1860,8 +1856,12 @@ name = "radroots-runtime"
version = "0.1.0-alpha.1"
dependencies = [
"anyhow",
+ "chacha20poly1305",
"config",
+ "getrandom 0.2.17",
"radroots-log",
+ "radroots-protected-store",
+ "radroots-secret-vault",
"serde",
"serde_json",
"tempfile",
@@ -1869,6 +1869,7 @@ dependencies = [
"tokio",
"toml",
"tracing",
+ "zeroize",
]
[[package]]
@@ -2028,13 +2029,14 @@ dependencies = [
[[package]]
name = "rust_decimal"
-version = "1.40.0"
+version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
+checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"num-traits",
"serde",
+ "wasm-bindgen",
]
[[package]]
@@ -2179,9 +2181,9 @@ dependencies = [
[[package]]
name = "semver"
-version = "1.0.27"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
@@ -2477,9 +2479,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -2502,9 +2504,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.50.0"
+version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
"bytes",
"libc",
@@ -2518,9 +2520,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.1"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -2754,9 +2756,9 @@ dependencies = [
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-xid"
@@ -2813,9 +2815,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.22.0"
+version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -2867,36 +2869,33 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.114"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
+ "serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.64"
+version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
+checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [
- "cfg-if",
- "futures-util",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.114"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2904,9 +2903,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.114"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2917,9 +2916,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.114"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
@@ -2960,9 +2959,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.91"
+version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
+checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3309,9 +3308,9 @@ dependencies = [
[[package]]
name = "writeable"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yaml-rust2"
@@ -3326,9 +3325,9 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -3337,9 +3336,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -3349,18 +3348,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.47"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.47"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
@@ -3369,18 +3368,18 @@ dependencies = [
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -3410,9 +3409,9 @@ dependencies = [
[[package]]
name = "zerotrie"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -3421,9 +3420,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.5"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -3432,9 +3431,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
diff --git a/src/cli.rs b/src/cli.rs
@@ -9,6 +9,7 @@ use radroots_nostr_signer::prelude::{
RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord,
};
use serde::Serialize;
+use zeroize::Zeroizing;
use crate::app::MycRuntime;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
@@ -110,6 +111,10 @@ pub enum MycPersistenceCommand {
#[derive(Debug, Subcommand)]
pub enum MycCustodyCommand {
+ Status {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ },
List {
#[arg(long, value_enum)]
role: MycCustodyRole,
@@ -132,6 +137,28 @@ pub enum MycCustodyCommand {
#[arg(long)]
select: bool,
},
+ ExportNip49 {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ out: PathBuf,
+ #[arg(long)]
+ password_env: String,
+ },
+ ImportNip49 {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ path: PathBuf,
+ #[arg(long)]
+ password_env: String,
+ #[arg(long)]
+ label: Option<String>,
+ },
+ Rotate {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ },
Select {
#[arg(long, value_enum)]
role: MycCustodyRole,
@@ -407,6 +434,7 @@ pub async fn run_from_env() -> Result<(), MycError> {
MycCommand::Custody { command } => {
let provider = custody_provider_for_command(&config, &command)?;
match command {
+ MycCustodyCommand::Status { .. } => print_json(&provider.status_output()),
MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?),
MycCustodyCommand::Generate { label, select, .. } => {
let output = provider.generate_managed_account(label, select)?;
@@ -421,6 +449,27 @@ pub async fn run_from_env() -> Result<(), MycError> {
let output = provider.import_managed_account_file(path, label, select)?;
print_json(&output)
}
+ MycCustodyCommand::ExportNip49 {
+ out, password_env, ..
+ } => {
+ let password = read_secret_env(password_env.as_str(), "custody export-nip49")?;
+ let output = provider.export_nip49(out, password.as_str())?;
+ print_json(&output)
+ }
+ MycCustodyCommand::ImportNip49 {
+ path,
+ password_env,
+ label,
+ ..
+ } => {
+ let password = read_secret_env(password_env.as_str(), "custody import-nip49")?;
+ let output = provider.import_nip49(path, password.as_str(), label)?;
+ print_json(&output)
+ }
+ MycCustodyCommand::Rotate { .. } => {
+ let output = provider.rotate_secret_storage()?;
+ print_json(&output)
+ }
MycCustodyCommand::Select { account_id, .. } => {
let output = provider.select_managed_account(account_id.as_str())?;
print_json(&output)
@@ -606,9 +655,13 @@ fn custody_provider_for_command(
command: &MycCustodyCommand,
) -> Result<crate::custody::MycIdentityProvider, MycError> {
let role = match command {
- MycCustodyCommand::List { role }
+ MycCustodyCommand::Status { role }
+ | MycCustodyCommand::List { role }
| MycCustodyCommand::Generate { role, .. }
| MycCustodyCommand::ImportFile { role, .. }
+ | MycCustodyCommand::ExportNip49 { role, .. }
+ | MycCustodyCommand::ImportNip49 { role, .. }
+ | MycCustodyCommand::Rotate { role }
| MycCustodyCommand::Select { role, .. }
| MycCustodyCommand::Remove { role, .. } => *role,
};
@@ -970,6 +1023,20 @@ fn print_text(value: &str) {
println!("{value}");
}
+fn read_secret_env(name: &str, operation: &str) -> Result<Zeroizing<String>, MycError> {
+ let value = std::env::var(name).map_err(|_| {
+ MycError::InvalidOperation(format!(
+ "{operation} requires environment variable `{name}` to be set"
+ ))
+ })?;
+ if value.is_empty() {
+ return Err(MycError::InvalidOperation(format!(
+ "{operation} requires environment variable `{name}` to be non-empty"
+ )));
+ }
+ Ok(Zeroizing::new(value))
+}
+
#[cfg(test)]
mod tests {
use std::path::PathBuf;
@@ -1310,6 +1377,17 @@ mod tests {
#[test]
fn parses_custody_list_command() {
+ let status = MycCli::try_parse_from(["myc", "custody", "status", "--role", "signer"])
+ .expect("parse custody status");
+ assert!(matches!(
+ status.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::Status {
+ role: MycCustodyRole::Signer
+ }
+ })
+ ));
+
let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"])
.expect("parse custody list");
@@ -1360,5 +1438,63 @@ mod tests {
}
})
));
+
+ let export_nip49 = MycCli::try_parse_from([
+ "myc",
+ "custody",
+ "export-nip49",
+ "--role",
+ "signer",
+ "--out",
+ "/tmp/signer.ncryptsec",
+ "--password-env",
+ "MYC_TEST_PASSWORD",
+ ])
+ .expect("parse custody export-nip49");
+ assert!(matches!(
+ export_nip49.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::ExportNip49 {
+ role: MycCustodyRole::Signer,
+ ..
+ }
+ })
+ ));
+
+ let import_nip49 = MycCli::try_parse_from([
+ "myc",
+ "custody",
+ "import-nip49",
+ "--role",
+ "user",
+ "--path",
+ "/tmp/user.ncryptsec",
+ "--password-env",
+ "MYC_TEST_PASSWORD",
+ "--label",
+ "migrated",
+ ])
+ .expect("parse custody import-nip49");
+ assert!(matches!(
+ import_nip49.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::ImportNip49 {
+ role: MycCustodyRole::User,
+ ..
+ }
+ })
+ ));
+
+ let rotate =
+ MycCli::try_parse_from(["myc", "custody", "rotate", "--role", "discovery-app"])
+ .expect("parse custody rotate");
+ assert!(matches!(
+ rotate.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::Rotate {
+ role: MycCustodyRole::DiscoveryApp
+ }
+ })
+ ));
}
}
diff --git a/src/custody.rs b/src/custody.rs
@@ -16,10 +16,14 @@ use radroots_nostr_accounts::prelude::{
};
use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
use serde::{Deserialize, Serialize};
+use zeroize::Zeroizing;
use crate::config::{MycIdentityBackend, MycIdentitySourceSpec};
use crate::error::MycError;
-use crate::identity_storage::load_encrypted_identity;
+use crate::identity_storage::{
+ load_encrypted_identity, load_identity_profile, rotate_encrypted_identity,
+ store_encrypted_identity, store_identity_profile, store_plaintext_identity, store_secret_text,
+};
#[derive(Clone)]
pub struct MycActiveIdentity {
@@ -85,6 +89,35 @@ pub struct MycManagedAccountMutationOutput {
pub state: MycManagedAccountsOutput,
}
+#[derive(Debug, Clone, Serialize)]
+pub struct MycCustodyExportOutput {
+ pub role: String,
+ pub backend: MycIdentityBackend,
+ pub format: String,
+ pub out: PathBuf,
+ pub identity_id: String,
+ pub public_key_hex: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycCustodyImportOutput {
+ pub role: String,
+ pub backend: MycIdentityBackend,
+ pub format: String,
+ pub account_id: String,
+ pub status: MycIdentityStatusOutput,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycCustodyRotateOutput {
+ pub role: String,
+ pub backend: MycIdentityBackend,
+ pub action: String,
+ pub status: MycIdentityStatusOutput,
+}
+
+const MYC_CUSTODY_FORMAT_NIP49: &str = "nip49";
+
#[derive(Clone)]
pub struct MycIdentityProvider {
role: String,
@@ -666,16 +699,16 @@ impl MycIdentityProvider {
});
}
if let Some(profile_path) = profile_path {
- let profile_identity = RadrootsIdentity::load_from_path_auto(profile_path)?;
- if profile_identity.id() != *account_id {
+ let profile_identity = load_identity_profile(profile_path)?;
+ if profile_identity.id != *account_id {
return Err(MycError::CustodyProfileIdentityMismatch {
role: self.role.clone(),
path: profile_path.clone(),
account_id: account_id.to_string(),
- profile_identity_id: profile_identity.id().to_string(),
+ profile_identity_id: profile_identity.id.to_string(),
});
}
- if let Some(profile) = profile_identity.profile().cloned() {
+ if let Some(profile) = profile_identity.profile {
identity.set_profile(profile);
}
}
@@ -778,6 +811,108 @@ impl MycIdentityProvider {
&self.source
}
+ pub fn status_output(&self) -> MycIdentityStatusOutput {
+ self.probe_status()
+ }
+
+ pub fn export_nip49(
+ &self,
+ out: impl AsRef<std::path::Path>,
+ password: &str,
+ ) -> Result<MycCustodyExportOutput, MycError> {
+ self.ensure_secret_materialized_operation("export NIP-49 secrets")?;
+ let out = out.as_ref();
+ let identity = self.load_identity()?;
+ let payload = identity.encrypt_secret_key_ncryptsec(password)?;
+ store_secret_text(out, payload.as_str())?;
+ Ok(MycCustodyExportOutput {
+ role: self.role.clone(),
+ backend: self.source.backend,
+ format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
+ out: out.to_path_buf(),
+ identity_id: identity.id().to_string(),
+ public_key_hex: identity.public_key_hex(),
+ })
+ }
+
+ pub fn import_nip49(
+ &self,
+ path: impl AsRef<std::path::Path>,
+ password: &str,
+ label: Option<String>,
+ ) -> Result<MycCustodyImportOutput, MycError> {
+ self.ensure_secret_materialized_operation("import NIP-49 secrets")?;
+ let identity = load_identity_from_nip49_file(path.as_ref(), password)?;
+ let account_id = identity.id().to_string();
+ match &self.backend {
+ MycIdentityProviderBackend::ManagedAccount { manager, .. } => {
+ manager
+ .upsert_identity(&identity, label, true)
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?;
+ }
+ _ => {
+ if let Some(label) = label {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `{}` does not support --label for `import-nip49` (got `{label}`)",
+ self.role,
+ self.source.backend.as_str(),
+ )));
+ }
+ self.store_identity(&identity)?;
+ }
+ }
+ Ok(MycCustodyImportOutput {
+ role: self.role.clone(),
+ backend: self.source.backend,
+ format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
+ account_id,
+ status: self.probe_status(),
+ })
+ }
+
+ pub fn rotate_secret_storage(&self) -> Result<MycCustodyRotateOutput, MycError> {
+ match &self.backend {
+ MycIdentityProviderBackend::EncryptedFile { path } => {
+ rotate_encrypted_identity(path)?;
+ }
+ MycIdentityProviderBackend::PlaintextFile { .. } => {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `plaintext_file` does not support `custody rotate`; migrate to `encrypted_file`, `host_vault`, or `managed_account` first",
+ self.role
+ )));
+ }
+ MycIdentityProviderBackend::HostVault { .. } => {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `host_vault` does not define an in-process `custody rotate` action; rotate or re-provision the secret through the host vault itself",
+ self.role
+ )));
+ }
+ MycIdentityProviderBackend::ManagedAccount { .. } => {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `managed_account` does not define an in-process `custody rotate` action; rotate the selected account through the configured host vault policy",
+ self.role
+ )));
+ }
+ MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process and cannot rotate local storage",
+ self.role,
+ command_path.display(),
+ )));
+ }
+ }
+
+ Ok(MycCustodyRotateOutput {
+ role: self.role.clone(),
+ backend: self.source.backend,
+ action: "rotate".to_owned(),
+ status: self.probe_status(),
+ })
+ }
+
pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> {
self.managed_accounts_output()
}
@@ -875,6 +1010,68 @@ impl MycIdentityProvider {
})
}
+ fn store_identity(&self, identity: &RadrootsIdentity) -> Result<(), MycError> {
+ match &self.backend {
+ MycIdentityProviderBackend::EncryptedFile { path } => {
+ store_encrypted_identity(path, identity)
+ }
+ MycIdentityProviderBackend::PlaintextFile { path } => {
+ store_plaintext_identity(path, identity)
+ }
+ MycIdentityProviderBackend::HostVault {
+ account_id,
+ service_name,
+ profile_path,
+ vault,
+ } => {
+ let identity_id = identity.id();
+ if identity_id != *account_id {
+ return Err(MycError::CustodySecretIdentityMismatch {
+ role: self.role.clone(),
+ service_name: service_name.clone(),
+ account_id: account_id.to_string(),
+ resolved_identity_id: identity_id.to_string(),
+ });
+ }
+ let secret_key_hex = Zeroizing::new(identity.secret_key_hex());
+ vault
+ .store_secret(account_id.as_str(), secret_key_hex.as_str())
+ .map_err(|source| MycError::CustodyVault {
+ role: self.role.clone(),
+ source: source.into(),
+ })?;
+ if let Some(profile_path) = profile_path {
+ store_identity_profile(profile_path, identity)?;
+ }
+ Ok(())
+ }
+ MycIdentityProviderBackend::ManagedAccount { .. } => {
+ Err(MycError::InvalidOperation(format!(
+ "{} identity backend `managed_account` requires account-store lifecycle helpers instead of direct identity writes",
+ self.role
+ )))
+ }
+ MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
+ Err(MycError::InvalidOperation(format!(
+ "{} identity backend `external_command` at {} does not support direct secret writes",
+ self.role,
+ command_path.display(),
+ )))
+ }
+ }
+ }
+
+ fn ensure_secret_materialized_operation(&self, operation: &str) -> Result<(), MycError> {
+ if let MycIdentityProviderBackend::ExternalCommand { command_path, .. } = &self.backend {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `external_command` at {} does not support `{operation}` because secret material never enters the myc process",
+ self.role,
+ command_path.display(),
+ )));
+ }
+ Ok(())
+ }
+
fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> {
match &self.backend {
MycIdentityProviderBackend::ExternalCommand {
@@ -1344,6 +1541,24 @@ impl MycActiveIdentity {
}
}
+fn load_identity_from_nip49_file(
+ path: &std::path::Path,
+ password: &str,
+) -> Result<RadrootsIdentity, MycError> {
+ let encoded = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let payload = encoded.trim();
+ if payload.is_empty() {
+ return Err(MycError::InvalidOperation(format!(
+ "NIP-49 payload at {} was empty",
+ path.display()
+ )));
+ }
+ RadrootsIdentity::from_encrypted_secret_key_str(payload, password).map_err(MycError::from)
+}
+
fn validate_external_command_public_identity(
role: &str,
command_path: &PathBuf,
@@ -1662,7 +1877,8 @@ mod tests {
"1111111111111111111111111111111111111111111111111111111111111111",
)
.expect("identity");
- identity.save_json(&profile_path).expect("save profile");
+ crate::identity_storage::store_identity_profile(&profile_path, &identity)
+ .expect("save profile");
let account_id = identity.id();
let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
@@ -1743,6 +1959,43 @@ mod tests {
}
#[test]
+ fn managed_account_provider_supports_nip49_export_and_import() {
+ let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
+ let generated = provider
+ .generate_managed_account(Some("primary".to_owned()), true)
+ .expect("generate");
+ let selected_account_id = generated
+ .state
+ .selected_account_id
+ .clone()
+ .expect("selected account id");
+ let temp = tempfile::tempdir().expect("tempdir");
+ let export_path = temp.path().join("managed-account.ncryptsec");
+
+ let export = provider
+ .export_nip49(&export_path, "test password")
+ .expect("export nip49");
+ assert_eq!(export.format, "nip49");
+ assert_eq!(export.identity_id, selected_account_id);
+
+ provider
+ .remove_managed_account(selected_account_id.as_str())
+ .expect("remove account");
+ let removed_status = provider.probe_status();
+ assert!(!removed_status.resolved);
+
+ let imported = provider
+ .import_nip49(&export_path, "test password", Some("restored".to_owned()))
+ .expect("import nip49");
+ assert_eq!(imported.account_id, export.identity_id);
+ assert!(imported.status.resolved);
+ assert_eq!(
+ imported.status.selected_account_label.as_deref(),
+ Some("restored")
+ );
+ }
+
+ #[test]
fn managed_account_provider_reports_not_configured() {
let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user");
diff --git a/src/identity_storage.rs b/src/identity_storage.rs
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use getrandom::getrandom;
-use radroots_identity::{RadrootsIdentity, RadrootsIdentityFile};
+use radroots_identity::{RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic};
use radroots_protected_store::{
RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH,
RadrootsProtectedStoreEnvelope,
@@ -183,6 +183,50 @@ pub fn store_encrypted_identity(
Ok(())
}
+pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), MycError> {
+ let path = path.as_ref();
+ let identity = load_encrypted_identity(path)?;
+ let envelope_backup = fs::read(path).map_err(|source| MycError::PersistenceIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let key_path = encrypted_identity_wrapping_key_path(path);
+ let key_backup = if key_path.exists() {
+ Some(
+ fs::read(&key_path).map_err(|source| MycError::PersistenceIo {
+ path: key_path.clone(),
+ source,
+ })?,
+ )
+ } else {
+ None
+ };
+
+ if key_path.exists() {
+ fs::remove_file(&key_path).map_err(|source| MycError::PersistenceIo {
+ path: key_path.clone(),
+ source,
+ })?;
+ }
+
+ if let Err(error) = store_encrypted_identity(path, &identity) {
+ let _ = fs::write(path, &envelope_backup);
+ let _ = set_secret_permissions(path);
+ match key_backup {
+ Some(key_backup) => {
+ let _ = fs::write(&key_path, &key_backup);
+ let _ = set_secret_permissions(&key_path);
+ }
+ None => {
+ let _ = fs::remove_file(&key_path);
+ }
+ }
+ return Err(error);
+ }
+
+ Ok(())
+}
+
pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, MycError> {
let path = path.as_ref();
let encoded = fs::read(path).map_err(|source| MycError::PersistenceIo {
@@ -220,6 +264,62 @@ pub fn store_plaintext_identity(
identity.save_json(path).map_err(MycError::from)
}
+pub fn store_identity_profile(
+ path: impl AsRef<Path>,
+ identity: &RadrootsIdentity,
+) -> Result<(), MycError> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+
+ let encoded = serde_json::to_vec_pretty(&identity.to_public())?;
+ fs::write(path, encoded).map_err(|source| MycError::PersistenceIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ set_secret_permissions(path).map_err(secret_permission_error(path))?;
+ Ok(())
+}
+
+pub fn load_identity_profile(path: impl AsRef<Path>) -> Result<RadrootsIdentityPublic, MycError> {
+ let path = path.as_ref();
+ let encoded = fs::read(path).map_err(|source| MycError::PersistenceIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ if let Ok(public_identity) = serde_json::from_slice::<RadrootsIdentityPublic>(&encoded) {
+ return Ok(public_identity);
+ }
+ RadrootsIdentity::load_from_path_auto(path)
+ .map(|identity| identity.to_public())
+ .map_err(MycError::from)
+}
+
+pub fn store_secret_text(path: impl AsRef<Path>, value: &str) -> Result<(), MycError> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+
+ fs::write(path, value).map_err(|source| MycError::PersistenceIo {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ set_secret_permissions(path).map_err(secret_permission_error(path))?;
+ Ok(())
+}
+
fn io_backend_error(source: std::io::Error) -> RadrootsSecretVaultAccessError {
RadrootsSecretVaultAccessError::Backend(source.to_string())
}
@@ -268,4 +368,44 @@ mod tests {
assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
assert!(encrypted_identity_wrapping_key_path(&path).is_file());
}
+
+ #[test]
+ fn encrypted_identity_rotation_rewraps_key() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("identity.enc.json");
+ let identity = RadrootsIdentity::from_secret_key_str(
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )
+ .expect("identity");
+
+ store_encrypted_identity(&path, &identity).expect("store encrypted identity");
+ let key_path = encrypted_identity_wrapping_key_path(&path);
+ let before = fs::read(&key_path).expect("key before");
+
+ rotate_encrypted_identity(&path).expect("rotate encrypted identity");
+
+ let after = fs::read(&key_path).expect("key after");
+ assert_ne!(before, after);
+ let loaded = load_encrypted_identity(&path).expect("load rotated identity");
+ assert_eq!(loaded.id(), identity.id());
+ }
+
+ #[test]
+ fn identity_profile_round_trips_as_public_projection() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("identity.profile.json");
+ let identity = RadrootsIdentity::from_secret_key_str(
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )
+ .expect("identity");
+
+ store_identity_profile(&path, &identity).expect("store profile");
+
+ let encoded = fs::read_to_string(&path).expect("read profile");
+ assert!(!encoded.contains("secret_key"));
+
+ let loaded = load_identity_profile(&path).expect("load profile");
+ assert_eq!(loaded.id, identity.id());
+ assert_eq!(loaded.public_key_hex, identity.public_key_hex());
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
@@ -35,8 +35,9 @@ pub use config::{
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use custody::{
- MycActiveIdentity, MycIdentityProvider, MycIdentityStatusOutput,
- MycManagedAccountMutationOutput, MycManagedAccountSelectionState, MycManagedAccountsOutput,
+ MycActiveIdentity, MycCustodyExportOutput, MycCustodyImportOutput, MycCustodyRotateOutput,
+ MycIdentityProvider, MycIdentityStatusOutput, MycManagedAccountMutationOutput,
+ MycManagedAccountSelectionState, MycManagedAccountsOutput,
};
pub use discovery::{
MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs
@@ -1,3 +1,4 @@
+use std::fs;
use std::path::Path;
use std::process::Command;
@@ -156,3 +157,108 @@ fn metrics_command_emits_json_and_prometheus_formats() {
assert!(rendered.contains("myc_delivery_outbox_total 1"));
assert!(rendered.contains("myc_signer_request_total 0"));
}
+
+#[test]
+fn custody_status_command_reports_role_backend_details() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let env_path = write_env_file(&temp);
+
+ let output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&env_path)
+ .arg("custody")
+ .arg("status")
+ .arg("--role")
+ .arg("signer")
+ .output()
+ .expect("run myc custody status");
+
+ assert!(output.status.success());
+ let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json");
+ assert_eq!(value["backend"], "encrypted_file");
+ assert_eq!(value["resolved"], true);
+ assert_eq!(
+ value["identity_id"],
+ "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
+ );
+}
+
+#[test]
+fn custody_export_import_and_rotate_nip49_for_encrypted_file_backend() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let env_path = write_env_file(&temp);
+ let signer_path = temp.path().join("signer.json");
+ let export_path = temp.path().join("signer.ncryptsec");
+ let key_path = myc::identity_storage::encrypted_identity_wrapping_key_path(&signer_path);
+
+ let export_output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .env("MYC_TEST_PASSWORD", "correct horse battery staple")
+ .arg("--env-file")
+ .arg(&env_path)
+ .arg("custody")
+ .arg("export-nip49")
+ .arg("--role")
+ .arg("signer")
+ .arg("--out")
+ .arg(&export_path)
+ .arg("--password-env")
+ .arg("MYC_TEST_PASSWORD")
+ .output()
+ .expect("run myc custody export-nip49");
+
+ assert!(export_output.status.success());
+ let export_value: Value =
+ serde_json::from_slice(&export_output.stdout).expect("export-nip49 json");
+ assert_eq!(export_value["format"], "nip49");
+ assert_eq!(export_value["out"], export_path.display().to_string());
+ let exported = fs::read_to_string(&export_path).expect("read exported ncryptsec");
+ assert!(exported.starts_with("ncryptsec1"));
+
+ fs::remove_file(&signer_path).expect("remove signer identity");
+ fs::remove_file(&key_path).expect("remove signer wrapping key");
+
+ let import_output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .env("MYC_TEST_PASSWORD", "correct horse battery staple")
+ .arg("--env-file")
+ .arg(&env_path)
+ .arg("custody")
+ .arg("import-nip49")
+ .arg("--role")
+ .arg("signer")
+ .arg("--path")
+ .arg(&export_path)
+ .arg("--password-env")
+ .arg("MYC_TEST_PASSWORD")
+ .output()
+ .expect("run myc custody import-nip49");
+
+ assert!(import_output.status.success());
+ let import_value: Value =
+ serde_json::from_slice(&import_output.stdout).expect("import-nip49 json");
+ assert_eq!(import_value["format"], "nip49");
+ assert_eq!(import_value["status"]["resolved"], true);
+ let restored = myc::identity_storage::load_encrypted_identity(&signer_path)
+ .expect("load restored encrypted identity");
+ assert_eq!(
+ restored.id().to_string(),
+ "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
+ );
+
+ let key_before_rotation = fs::read(&key_path).expect("read key before rotation");
+ let rotate_output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&env_path)
+ .arg("custody")
+ .arg("rotate")
+ .arg("--role")
+ .arg("signer")
+ .output()
+ .expect("run myc custody rotate");
+
+ assert!(rotate_output.status.success());
+ let rotate_value: Value = serde_json::from_slice(&rotate_output.stdout).expect("rotate json");
+ assert_eq!(rotate_value["action"], "rotate");
+ assert_eq!(rotate_value["status"]["resolved"], true);
+ let key_after_rotation = fs::read(&key_path).expect("read key after rotation");
+ assert_ne!(key_before_rotation, key_after_rotation);
+}