From 4a089fed4b7d4c5f63b09b802c65e660c87d1b10 Mon Sep 17 00:00:00 2001 From: dmbuil Date: Tue, 30 Jun 2026 09:11:44 +0200 Subject: [PATCH] feat(compute): Add server password show-plaintext Adds a new `osc compute server password show-plaintext` subcommand that retrieves the Nova-encrypted admin password and decrypts it in-process using a caller-supplied SSH private key. Supports OpenSSH, PEM RSA (PKCS#1), and PKCS#8 key formats. Passphrase-protected OpenSSH keys prompt interactively via dialoguer. Uses pure-Rust `rsa ^0.9` + `ssh-key ^0.6` crates to stay Alpine-compatible (no openssl). The existing `show` subcommand (generated code) is left completely untouched. Signed-off-by: dmbuil --- Cargo.lock | 527 +++++++++++++++++- Cargo.toml | 2 + cli/compute/Cargo.toml | 7 + cli/compute/src/v2/server/server_password.rs | 3 + .../server/server_password/show_plaintext.rs | 329 +++++++++++ 5 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 cli/compute/src/v2/server/server_password/show_plaintext.rs diff --git a/Cargo.lock b/Cargo.lock index c25bccede..b3bd7260b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -324,6 +359,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -336,6 +377,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "base64urlsafedata" version = "0.5.5" @@ -347,6 +394,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -386,6 +444,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" version = "1.12.1" @@ -409,6 +486,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -427,6 +510,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -451,6 +543,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -474,6 +577,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -668,6 +781,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -788,6 +907,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -808,6 +939,15 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -924,6 +1064,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1053,7 +1204,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1109,6 +1262,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "edit" version = "0.1.5" @@ -1125,6 +1292,25 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1224,6 +1410,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1422,6 +1618,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1465,12 +1662,33 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -1584,6 +1802,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1914,6 +2141,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -2132,6 +2369,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2155,6 +2395,12 @@ dependencies = [ "cc", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -2404,6 +2650,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2430,6 +2692,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2437,6 +2710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2484,6 +2758,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -2601,14 +2881,19 @@ dependencies = [ name = "openstack-cli-compute" version = "0.13.7" dependencies = [ + "base64 0.22.1", "clap", + "dialoguer", "eyre", "http", "openstack-cli-core", "openstack_sdk", "openstack_sdk_core", "openstack_types", + "rsa", "serde_json", + "ssh-key", + "tempfile", "tracing", ] @@ -3566,6 +3851,44 @@ dependencies = [ "supports-color 3.0.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3616,6 +3939,24 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3723,12 +4064,56 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3818,6 +4203,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3931,7 +4325,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -4202,6 +4596,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -4226,6 +4630,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "runloop" version = "0.1.0" @@ -4424,6 +4849,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -4675,6 +5114,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4731,6 +5180,72 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20 0.9.1", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5456,6 +5971,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 5f9af4791..ad256b345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,8 @@ openstack_sdk = { version = "0.22.7", path = "openstack_sdk" } openstack_types = { version = "0.22.7", path = "openstack_types" } openstack-types-core = { version = "0.22", path = "types/core" } regex = { version = "^1.12" } +rsa = { version = "^0.9", features = ["getrandom"] } +ssh-key = { version = "^0.6", features = ["rsa", "encryption"] } reqwest = { version = "^0.13", default-features = false } reserve-port = "^2.4" schemars = { version = "^1.0" } diff --git a/cli/compute/Cargo.toml b/cli/compute/Cargo.toml index 7e8c45393..d2ef94006 100644 --- a/cli/compute/Cargo.toml +++ b/cli/compute/Cargo.toml @@ -19,6 +19,13 @@ eyre = { workspace = true } http = { workspace = true } serde_json = {workspace = true} tracing = { workspace = true} +base64 = { workspace = true } +dialoguer = { workspace = true } +rsa = { workspace = true } +ssh-key = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [lints] workspace = true diff --git a/cli/compute/src/v2/server/server_password.rs b/cli/compute/src/v2/server/server_password.rs index 811ffc2c2..41ec91aee 100644 --- a/cli/compute/src/v2/server/server_password.rs +++ b/cli/compute/src/v2/server/server_password.rs @@ -22,6 +22,7 @@ use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; mod delete; mod get; +mod show_plaintext; /// Servers password /// @@ -41,6 +42,7 @@ pub struct PasswordCommand { pub enum PasswordCommands { Delete(delete::ServerPasswordCommand), Show(get::ServerPasswordCommand), + ShowPlaintext(show_plaintext::ShowPlaintextCommand), } impl PasswordCommand { @@ -53,6 +55,7 @@ impl PasswordCommand { match &self.command { PasswordCommands::Delete(cmd) => cmd.take_action(parsed_args, session).await, PasswordCommands::Show(cmd) => cmd.take_action(parsed_args, session).await, + PasswordCommands::ShowPlaintext(cmd) => cmd.take_action(parsed_args, session).await, } } } diff --git a/cli/compute/src/v2/server/server_password/show_plaintext.rs b/cli/compute/src/v2/server/server_password/show_plaintext.rs new file mode 100644 index 000000000..65066cfa5 --- /dev/null +++ b/cli/compute/src/v2/server/server_password/show_plaintext.rs @@ -0,0 +1,329 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Show plaintext server password command + +use clap::Args; +use dialoguer::Password; +use std::path::{Path, PathBuf}; +use tracing::info; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +use openstack_cli_core::cli::CliArgs; +use openstack_cli_core::error::OpenStackCliError; +use openstack_cli_core::output::OutputProcessor; +use openstack_sdk::AsyncOpenStack; + +use openstack_sdk::api::QueryAsync; +use openstack_sdk::api::compute::v2::server::server_password::get; +use openstack_types::compute::v2::server::server_password::response; + +use rsa::pkcs1::DecodeRsaPrivateKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::{Pkcs1v15Encrypt, RsaPrivateKey}; +use ssh_key::PrivateKey; + +/// Retrieve and decrypt the administrative password for a server. +/// +/// The password is encrypted with the SSH public key injected at boot time. +/// Provide the matching SSH private key to obtain the plaintext password. +/// +/// Supports OpenSSH (`BEGIN OPENSSH PRIVATE KEY`), PEM RSA +/// (`BEGIN RSA PRIVATE KEY`), and PKCS#8 (`BEGIN PRIVATE KEY`) key formats. +/// Passphrase-protected OpenSSH keys trigger an interactive prompt. +/// +/// To retrieve the raw (encrypted) password instead, use +/// `osc compute server password show`. +#[derive(Args)] +#[command(about = "Show Server Password (decrypted)")] +pub struct ShowPlaintextCommand { + /// Path parameters + #[command(flatten)] + path: PathParameters, + + /// Path to the SSH private key used to decrypt the password. + /// Supports OpenSSH, PEM RSA, and PKCS#8 key formats. + /// If the key is passphrase-protected you will be prompted interactively. + #[arg( + long, + value_name = "PATH", + value_hint = clap::ValueHint::FilePath, + help_heading = "Decryption" + )] + private_key: PathBuf, +} + +/// Path parameters +#[derive(Args)] +struct PathParameters { + /// server_id parameter for /v2.1/servers/{server_id}/os-server-password API + #[arg( + help_heading = "Path parameters", + id = "path_param_server_id", + value_name = "SERVER_ID" + )] + server_id: String, +} + +impl ShowPlaintextCommand { + /// Perform command action + pub async fn take_action( + &self, + parsed_args: &C, + client: &mut AsyncOpenStack, + ) -> Result<(), OpenStackCliError> { + info!("Show ServerPassword (plaintext)"); + + let op = OutputProcessor::from_args( + parsed_args, + Some("compute.server/server_password"), + Some("get"), + ); + op.validate_args(parsed_args)?; + + let mut ep_builder = get::Request::builder(); + ep_builder.server_id(&self.path.server_id); + let ep = ep_builder + .build() + .map_err(|x| OpenStackCliError::EndpointBuild(x.to_string()))?; + + let data: serde_json::Value = ep.query_async(client).await?; + + let encrypted_b64 = data.get("password").and_then(|v| v.as_str()).unwrap_or(""); + + if encrypted_b64.is_empty() { + return Err(OpenStackCliError::InputParameters( + "no password is set for this server".into(), + )); + } + + let plaintext = decrypt_password(&self.private_key, encrypted_b64)?; + let decrypted = serde_json::json!({ "password": plaintext }); + op.output_single::(decrypted)?; + op.show_command_hint()?; + Ok(()) + } +} + +/// Load an RSA private key from a PEM file. +/// +/// Supported formats: +/// - OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) — passphrase-protected keys prompt +/// interactively. +/// - Traditional PEM RSA (`BEGIN RSA PRIVATE KEY`) +/// - PKCS#8 PEM (`BEGIN PRIVATE KEY`) +/// +/// Encrypted traditional PEM keys return a helpful conversion hint. +fn load_rsa_key(key_path: &Path) -> Result { + let content = std::fs::read_to_string(key_path)?; + + if content.contains("BEGIN OPENSSH PRIVATE KEY") { + let private_key = PrivateKey::from_openssh(&content).map_err(|e| { + OpenStackCliError::InputParameters(format!("failed to parse SSH key: {e}")) + })?; + + let private_key = if private_key.is_encrypted() { + let passphrase = Password::new() + .with_prompt("SSH key passphrase") + .interact()?; + private_key.decrypt(passphrase.as_bytes()).map_err(|e| { + OpenStackCliError::InputParameters(format!( + "failed to decrypt SSH key (wrong passphrase?): {e}" + )) + })? + } else { + private_key + }; + + let rsa_keypair = private_key.key_data().rsa().ok_or_else(|| { + OpenStackCliError::InputParameters( + "private key must be RSA (Ed25519/ECDSA keys are not supported)".into(), + ) + })?; + + RsaPrivateKey::try_from(rsa_keypair).map_err(|e| { + OpenStackCliError::InputParameters(format!("failed to extract RSA key: {e}")) + }) + } else if content.contains("BEGIN RSA PRIVATE KEY") { + RsaPrivateKey::from_pkcs1_pem(&content).map_err(|e| { + OpenStackCliError::InputParameters(format!("failed to parse RSA PEM key: {e}")) + }) + } else if content.contains("BEGIN PRIVATE KEY") { + RsaPrivateKey::from_pkcs8_pem(&content).map_err(|e| { + OpenStackCliError::InputParameters(format!("failed to parse PKCS#8 key: {e}")) + }) + } else if content.contains("ENCRYPTED") { + Err(OpenStackCliError::InputParameters( + "encrypted traditional PEM keys are not supported; \ + convert first with: ssh-keygen -p -m OpenSSH -f " + .into(), + )) + } else { + Err(OpenStackCliError::InputParameters( + "unrecognized private key format (expected OpenSSH, PEM RSA, or PKCS#8)".into(), + )) + } +} + +/// Base64-decode and RSA PKCS#1 v1.5 decrypt a Nova server password. +fn decrypt_password(key_path: &Path, encrypted_b64: &str) -> Result { + let rsa_key = load_rsa_key(key_path)?; + + let ciphertext = BASE64.decode(encrypted_b64)?; + + let plaintext_bytes = rsa_key.decrypt(Pkcs1v15Encrypt, &ciphertext).map_err(|_| { + OpenStackCliError::InputParameters( + "failed to decrypt password — is this the right key?".into(), + ) + })?; + + String::from_utf8(plaintext_bytes).map_err(|_| { + OpenStackCliError::InputParameters("decrypted password is not valid UTF-8".into()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use tempfile::NamedTempFile; + + use rsa::pkcs1::{EncodeRsaPrivateKey, LineEnding as Pkcs1LineEnding}; + use rsa::pkcs8::{EncodePrivateKey, LineEnding as Pkcs8LineEnding}; + use rsa::{RsaPublicKey, rand_core::OsRng}; + + fn make_test_keypair() -> (RsaPrivateKey, RsaPublicKey) { + let private = RsaPrivateKey::new(&mut OsRng, 1024).expect("key generation"); + let public = RsaPublicKey::from(&private); + (private, public) + } + + fn nova_encrypt(public_key: &RsaPublicKey, plaintext: &str) -> String { + let ct = public_key + .encrypt(&mut OsRng, Pkcs1v15Encrypt, plaintext.as_bytes()) + .expect("encrypt"); + BASE64.encode(ct) + } + + #[test] + fn test_decrypt_pkcs1_pem() { + let (priv_key, pub_key) = make_test_keypair(); + let encrypted_b64 = nova_encrypt(&pub_key, "s3cr3t"); + + let pem = priv_key + .to_pkcs1_pem(Pkcs1LineEnding::LF) + .expect("pkcs1 pem"); + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(pem.as_bytes()).expect("write"); + + assert_eq!( + decrypt_password(f.path(), &encrypted_b64).expect("decrypt"), + "s3cr3t" + ); + } + + #[test] + fn test_decrypt_pkcs8_pem() { + let (priv_key, pub_key) = make_test_keypair(); + let encrypted_b64 = nova_encrypt(&pub_key, "p@ssw0rd"); + + let pem = priv_key + .to_pkcs8_pem(Pkcs8LineEnding::LF) + .expect("pkcs8 pem"); + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(pem.as_bytes()).expect("write"); + + assert_eq!( + decrypt_password(f.path(), &encrypted_b64).expect("decrypt"), + "p@ssw0rd" + ); + } + + #[test] + fn test_wrong_key_returns_error() { + let (_, pub_key) = make_test_keypair(); + let encrypted_b64 = nova_encrypt(&pub_key, "secret"); + + let (wrong_priv, _) = make_test_keypair(); + let pem = wrong_priv + .to_pkcs1_pem(Pkcs1LineEnding::LF) + .expect("pkcs1 pem"); + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(pem.as_bytes()).expect("write"); + + let err = decrypt_password(f.path(), &encrypted_b64).expect_err("should fail"); + assert!( + matches!(err, OpenStackCliError::InputParameters(_)), + "expected InputParameters, got {err:?}" + ); + } + + #[test] + fn test_invalid_base64_returns_error() { + let (priv_key, _) = make_test_keypair(); + let pem = priv_key + .to_pkcs1_pem(Pkcs1LineEnding::LF) + .expect("pkcs1 pem"); + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(pem.as_bytes()).expect("write"); + + let err = decrypt_password(f.path(), "not!!valid!!base64").expect_err("should fail"); + assert!( + matches!(err, OpenStackCliError::Base64Decode(_)), + "expected Base64Decode, got {err:?}" + ); + } + + #[test] + fn test_encrypted_traditional_pem_returns_helpful_error() { + let content = + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\nfake\n-----END ENCRYPTED PRIVATE KEY-----\n"; + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(content).expect("write"); + + let err = load_rsa_key(f.path()).expect_err("should fail"); + match err { + OpenStackCliError::InputParameters(msg) => { + assert!( + msg.contains("ssh-keygen"), + "expected ssh-keygen hint in: {msg}" + ); + } + other => panic!("expected InputParameters, got {other:?}"), + } + } + + #[test] + fn test_unrecognized_format_returns_error() { + let mut f = NamedTempFile::new().expect("tempfile"); + f.write_all(b"this is not a key\n").expect("write"); + + let err = load_rsa_key(f.path()).expect_err("should fail"); + assert!( + matches!(err, OpenStackCliError::InputParameters(_)), + "expected InputParameters, got {err:?}" + ); + } + + #[test] + fn test_missing_file_returns_io_error() { + let err = load_rsa_key(Path::new("/nonexistent/key.pem")).expect_err("should fail"); + assert!( + matches!(err, OpenStackCliError::IO { .. }), + "expected IO error, got {err:?}" + ); + } +}