From 59092d60832224162e7df9659b6c42112562e4c0 Mon Sep 17 00:00:00 2001 From: Kevin Arifin Date: Tue, 24 Mar 2026 09:32:14 -0700 Subject: [PATCH] feat: replace plaintext key storage with OWS encrypted vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private keys are no longer stored in the clear in config.json. All key material is managed by the Open Wallet Standard (OWS) encrypted vault (AES-256-GCM, scrypt KDF). - Add `ows_id` field to Config — immutable UUID referencing OWS wallet - Add `src/ows.rs` — OWS backend via `ows-lib` crate (3 tests) - `wallet create` generates keys directly in OWS vault - `wallet import` imports keys into OWS vault - `resolve_key` decrypts from OWS vault when `ows_id` is present - Legacy plaintext `private_key` field still read as fallback --- Cargo.lock | 594 +++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 6 + src/commands/wallet.rs | 28 +- src/config.rs | 55 +++- src/main.rs | 6 + src/ows.rs | 88 ++++++ 6 files changed, 750 insertions(+), 27 deletions(-) create mode 100644 src/ows.rs diff --git a/Cargo.lock b/Cargo.lock index 26a3032..70bbe8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[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", +] + +[[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 = "ahash" version = "0.7.8" @@ -397,7 +432,7 @@ dependencies = [ "serde_json", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tracing", "url", "wasmtimer", @@ -558,7 +593,7 @@ checksum = "fa186e560d523d196580c48bf00f1bf62e63041f28ecf276acc22f8b27bb9f53" dependencies = [ "alloy-json-rpc", "auto_impl", - "base64", + "base64 0.22.1", "derive_more", "futures", "futures-utils-wasm", @@ -567,7 +602,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tower", + "tower 0.5.3", "tracing", "url", "wasmtimer", @@ -584,7 +619,7 @@ dependencies = [ "itertools 0.14.0", "reqwest 0.12.28", "serde_json", - "tower", + "tower 0.5.3", "tracing", "url", ] @@ -974,12 +1009,65 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -992,6 +1080,18 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bit-set" version = "0.8.0" @@ -1041,6 +1141,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1110,6 +1219,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2", + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1235,6 +1354,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.5.60" @@ -1293,6 +1422,57 @@ dependencies = [ "cc", ] +[[package]] +name = "coins-bip32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c43ff7fd9ff522219058808a259e61423335767b1071d5b346de60d9219657" +dependencies = [ + "bs58", + "coins-core", + "digest 0.10.7", + "hmac", + "k256", + "serde", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-bip39" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4587c0b4064da887ed39a6522f577267d57e58bdd583178cd877d721b56a2e" +dependencies = [ + "bitvec", + "coins-bip32", + "hmac", + "once_cell", + "pbkdf2", + "rand 0.8.5", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-core" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3aeeec621f4daec552e9d28befd58020a78cfc364827d06a753e8bc13c6c4b" +dependencies = [ + "base64 0.21.7", + "bech32 0.9.1", + "bs58", + "const-hex", + "digest 0.10.7", + "generic-array", + "ripemd", + "serde", + "sha2", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1437,9 +1617,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.21.3" @@ -1664,6 +1881,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -1798,7 +2039,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1811,6 +2052,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2022,6 +2269,16 @@ 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 = "glob" version = "0.3.3" @@ -2128,6 +2385,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2185,6 +2451,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -2199,6 +2471,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2224,13 +2497,26 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2241,7 +2527,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -2430,6 +2716,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2539,6 +2834,7 @@ dependencies = [ "once_cell", "serdect", "sha2", + "signature", ] [[package]] @@ -2641,6 +2937,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -2783,6 +3085,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 = "openssl-probe" version = "0.2.1" @@ -2795,6 +3103,76 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ows-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0f00266c0839f93538876db165b625fe2a7f181f436c8c4d2d584e997577f50" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "ows-lib" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e52f48c157be2ed6b0f35f612187941bccf749b856d631bee904908538af41a" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "hex", + "ows-core", + "ows-signer", + "prost", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tonic", + "uuid", + "zeroize", +] + +[[package]] +name = "ows-signer" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7705428542500d423211651560732b43d87c45e7bc9e3f161366f1d351349b" +dependencies = [ + "aes-gcm", + "base64 0.22.1", + "bech32 0.11.1", + "blake2", + "bs58", + "coins-bip32", + "coins-bip39", + "digest 0.10.7", + "ed25519-dalek", + "hex", + "hkdf", + "hmac", + "k256", + "libc", + "ows-core", + "rand 0.8.5", + "ripemd", + "scrypt", + "serde", + "serde_json", + "sha2", + "sha3", + "signal-hook", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "papergrid" version = "0.13.0" @@ -2857,12 +3235,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2974,6 +3373,10 @@ dependencies = [ "chrono", "clap", "dirs", + "getrandom 0.2.17", + "hex", + "ows-core", + "ows-lib", "polymarket-client-sdk", "predicates", "rust_decimal", @@ -2982,7 +3385,9 @@ dependencies = [ "serde", "serde_json", "tabled", + "tempfile", "tokio", + "zeroize", ] [[package]] @@ -2994,7 +3399,7 @@ dependencies = [ "alloy", "async-stream", "async-trait", - "base64", + "base64 0.22.1", "bon", "chrono", "dashmap", @@ -3017,6 +3422,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3151,6 +3568,29 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3190,7 +3630,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -3228,7 +3668,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -3428,7 +3868,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -3450,7 +3890,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -3466,7 +3906,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -3492,7 +3932,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -3525,6 +3965,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -3789,6 +4238,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3837,6 +4295,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4021,7 +4491,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -4093,6 +4563,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -4130,6 +4620,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -4433,7 +4933,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4514,6 +5014,56 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -4542,7 +5092,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -4650,6 +5200,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[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 = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index a01bf05..78119b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,18 @@ rust_decimal = "1" anyhow = "1" chrono = "0.4" dirs = "6" +ows-core = "1.0.0" +ows-lib = "1.0.0" +getrandom = "0.2" +hex = "0.4" +zeroize = "1" rustyline = "15" [dev-dependencies] assert_cmd = "2" predicates = "3" rust_decimal_macros = "1" +tempfile = "3" [profile.release] lto = "thin" diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index d54a63b..3026165 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -83,11 +83,22 @@ fn guard_overwrite(force: bool) -> Result<()> { fn cmd_create(output: OutputFormat, force: bool, signature_type: &str) -> Result<()> { guard_overwrite(force)?; - let signer = LocalSigner::random().with_chain_id(Some(POLYGON)); + // Create wallet in OWS vault — key is generated and encrypted internally. + let mut nonce = [0u8; 4]; + getrandom::getrandom(&mut nonce).map_err(|e| anyhow::anyhow!("Failed to generate nonce: {e}"))?; + let ows_id = crate::ows::create_wallet(&format!("polymarket-{}", hex::encode(nonce))) + .context("Failed to create OWS wallet")?; + + // Export briefly to derive the address, then wipe. + let exported = crate::ows::export_private_key(&ows_id)?; + let signer = LocalSigner::from_str(&exported) + .context("Invalid key from OWS")? + .with_chain_id(Some(POLYGON)); let address = signer.address(); - let key_hex = format!("{:#x}", signer.to_bytes()); + drop(exported); + drop(signer); - config::save_wallet(&key_hex, POLYGON, signature_type)?; + config::save_config(POLYGON, signature_type, &ows_id)?; let config_path = config::config_path()?; let proxy_addr = derive_proxy_wallet(address, POLYGON); @@ -112,8 +123,7 @@ fn cmd_create(output: OutputFormat, force: bool, signature_type: &str) -> Result println!("Signature type: {signature_type}"); println!("Config: {}", config_path.display()); println!(); - println!("IMPORTANT: Back up your private key from the config file."); - println!(" If lost, your funds cannot be recovered."); + println!("Private key encrypted in OWS vault."); } } Ok(()) @@ -122,13 +132,15 @@ fn cmd_create(output: OutputFormat, force: bool, signature_type: &str) -> Result fn cmd_import(key: &str, output: OutputFormat, force: bool, signature_type: &str) -> Result<()> { guard_overwrite(force)?; + // Validate the key first. let signer = LocalSigner::from_str(key) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); let address = signer.address(); - let key_hex = format!("{:#x}", signer.to_bytes()); + drop(signer); - config::save_wallet(&key_hex, POLYGON, signature_type)?; + // Import into OWS vault. + config::save_wallet(key, POLYGON, signature_type)?; let config_path = config::config_path()?; let proxy_addr = derive_proxy_wallet(address, POLYGON); @@ -152,6 +164,8 @@ fn cmd_import(key: &str, output: OutputFormat, force: bool, signature_type: &str } println!("Signature type: {signature_type}"); println!("Config: {}", config_path.display()); + println!(); + println!("Private key encrypted in OWS vault."); } } Ok(()) diff --git a/src/config.rs b/src/config.rs index 60c0179..0efabab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,10 +13,15 @@ pub(crate) const NO_WALLET_MSG: &str = #[derive(Serialize, Deserialize)] pub(crate) struct Config { + /// Plaintext private key (legacy — migrated to OWS on first use). + #[serde(default, skip_serializing_if = "String::is_empty")] pub private_key: String, pub chain_id: u64, #[serde(default = "default_signature_type")] pub signature_type: String, + /// OWS wallet UUID for encrypted key storage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ows_id: Option, } fn default_signature_type() -> String { @@ -62,6 +67,30 @@ pub fn delete_config() -> Result<()> { Ok(()) } +/// Migrate plaintext private key to OWS vault. +/// +/// If config has a `private_key` but no `ows_id`, imports the key into +/// OWS and rewrites config without the plaintext key. +pub fn migrate_to_ows() -> Result<()> { + let Some(config) = load_config()? else { + return Ok(()); + }; + if config.ows_id.is_some() || config.private_key.is_empty() { + return Ok(()); + } + + let mut nonce = [0u8; 4]; + getrandom::getrandom(&mut nonce).context("Failed to generate nonce")?; + let wallet_name = format!("polymarket-{}", hex::encode(nonce)); + + let ows_id = crate::ows::import_private_key(&wallet_name, &config.private_key) + .context("Failed to migrate key to OWS vault")?; + + save_config(config.chain_id, &config.signature_type, &ows_id)?; + eprintln!("Migrated wallet key to OWS encrypted vault."); + Ok(()) +} + /// Load config from disk. Returns `Ok(None)` if no config file exists, /// or `Err` if the file exists but can't be read or parsed. pub fn load_config() -> Result> { @@ -95,6 +124,17 @@ pub fn resolve_signature_type(cli_flag: Option<&str>) -> Result { } pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> { + // Store the private key in OWS encrypted vault. + let mut nonce = [0u8; 4]; + getrandom::getrandom(&mut nonce).map_err(|e| anyhow::anyhow!("Failed to generate nonce: {e}"))?; + let ows_id = crate::ows::import_private_key(&format!("polymarket-{}", hex::encode(nonce)), key) + .context("Failed to store key in OWS vault")?; + + save_config(chain_id, signature_type, &ows_id) +} + +/// Save config with an OWS wallet ID (no plaintext key on disk). +pub fn save_config(chain_id: u64, signature_type: &str, ows_id: &str) -> Result<()> { let dir = config_dir()?; fs::create_dir_all(&dir).context("Failed to create config directory")?; @@ -105,9 +145,10 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> } let config = Config { - private_key: key.to_string(), + private_key: String::new(), chain_id, signature_type: signature_type.to_string(), + ows_id: Some(ows_id.to_string()), }; let json = serde_json::to_string_pretty(&config)?; let path = config_path()?; @@ -135,7 +176,7 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> Ok(()) } -/// Priority: CLI flag > env var > config file. +/// Priority: CLI flag > env var > OWS vault (via config ows_id) > legacy config key. pub fn resolve_key(cli_flag: Option<&str>) -> Result<(Option, KeySource)> { if let Some(key) = cli_flag { return Ok((Some(key.to_string()), KeySource::Flag)); @@ -146,7 +187,15 @@ pub fn resolve_key(cli_flag: Option<&str>) -> Result<(Option, KeySource) return Ok((Some(key), KeySource::EnvVar)); } if let Some(config) = load_config()? { - return Ok((Some(config.private_key), KeySource::ConfigFile)); + // OWS vault takes priority over legacy plaintext key. + if let Some(ref ows_id) = config.ows_id { + let key = crate::ows::export_private_key(ows_id) + .context("Failed to decrypt key from OWS vault")?; + return Ok((Some((*key).clone()), KeySource::ConfigFile)); + } + if !config.private_key.is_empty() { + return Ok((Some(config.private_key), KeySource::ConfigFile)); + } } Ok((None, KeySource::None)) } diff --git a/src/main.rs b/src/main.rs index 2abb55f..bdb0b04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod commands; mod config; mod output; +mod ows; mod shell; use std::process::ExitCode; @@ -71,6 +72,11 @@ async fn main() -> ExitCode { let cli = Cli::parse(); let output = cli.output; + // Migrate plaintext keys to OWS vault on startup. + if let Err(e) = config::migrate_to_ows() { + eprintln!("Warning: OWS key migration failed: {e}"); + } + if let Err(e) = run(cli).await { output::print_error(&e, output); return ExitCode::FAILURE; diff --git a/src/ows.rs b/src/ows.rs new file mode 100644 index 0000000..5db4756 --- /dev/null +++ b/src/ows.rs @@ -0,0 +1,88 @@ +//! OWS (Open Wallet Standard) — encrypted key storage. +//! +//! Private keys are encrypted at rest (AES-256-GCM, scrypt KDF) in +//! the OWS vault (`~/.ows/wallets/`). Keys are decrypted only during +//! signing, then wiped from memory. + +use std::path::Path; + +use anyhow::{Context, Result}; +use zeroize::Zeroizing; + +/// Create a new OWS wallet and return its UUID. +pub fn create_wallet(name: &str) -> Result { + create_wallet_in(name, None) +} + +pub fn create_wallet_in(name: &str, vault_path: Option<&Path>) -> Result { + let wallet = ows_lib::create_wallet(name, Some(12), None, vault_path) + .context("OWS wallet creation failed")?; + Ok(wallet.id) +} + +/// Import a private key into OWS. Returns the wallet UUID. +pub fn import_private_key(name: &str, private_key: &str) -> Result { + import_private_key_in(name, private_key, None) +} + +pub fn import_private_key_in( + name: &str, + private_key: &str, + vault_path: Option<&Path>, +) -> Result { + let wallet = + ows_lib::import_wallet_private_key(name, private_key, None, None, vault_path, None, None) + .context("OWS key import failed")?; + Ok(wallet.id) +} + +/// Decrypt the EVM signing key from an OWS wallet. +pub fn export_private_key(name_or_id: &str) -> Result> { + export_private_key_in(name_or_id, None) +} + +pub fn export_private_key_in( + name_or_id: &str, + vault_path: Option<&Path>, +) -> Result> { + let key_bytes = ows_lib::decrypt_signing_key( + name_or_id, + ows_core::ChainType::Evm, + "", + None, + vault_path, + ) + .context("OWS key decryption failed")?; + + let hex = format!("0x{}", hex::encode(key_bytes.expose())); + Ok(Zeroizing::new(hex)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + #[test] + fn import_and_export_round_trip() { + let vault = tempfile::tempdir().unwrap(); + let id = import_private_key_in("test", TEST_KEY, Some(vault.path())).unwrap(); + let exported = export_private_key_in(&id, Some(vault.path())).unwrap(); + assert_eq!(&*exported, TEST_KEY); + } + + #[test] + fn create_returns_uuid() { + let vault = tempfile::tempdir().unwrap(); + let id = create_wallet_in("test", Some(vault.path())).unwrap(); + assert_eq!(id.len(), 36); + } + + #[test] + fn export_nonexistent_fails() { + let vault = tempfile::tempdir().unwrap(); + assert!(export_private_key_in("nope", Some(vault.path())).is_err()); + } +}