myc

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

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:
MCargo.lock | 185+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/cli.rs | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/custody.rs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/identity_storage.rs | 142++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 5+++--
Mtests/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); +}