Skip to content

feat: encrypt private keys at rest using keystore - #44

Open
mvanhorn wants to merge 4 commits into
Polymarket:mainfrom
mvanhorn:osc/18-encrypted-key-storage
Open

mvanhorn wants to merge 4 commits into
Polymarket:mainfrom
mvanhorn:osc/18-encrypted-key-storage

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Mar 10, 2026 •

Copy link
Copy Markdown

Summary

  • Private keys in ~/.config/polymarket/config.json were stored as plaintext - any process running as the same user could read them
  • wallet create and wallet import now encrypt keys using the standard Ethereum keystore format (AES-128-CTR + scrypt), the same format used by MetaMask, Geth, and Foundry
  • All commands that need the key prompt for a password (or read POLYMARKET_PASSWORD env var for CI/scripts)
  • --no-password flag available for plaintext storage (not recommended)
  • New wallet export subcommand to decrypt and display the key for backup
  • Backward compatible: existing plaintext configs continue to work

New dependencies

  • rpassword - secure terminal password input (no echo)
  • rand 0.8 - RNG for keystore encryption
  • alloy signer-keystore feature - standard keystore encrypt/decrypt

Fixes #18
Supersedes #19 (pre-refactor, needs full rewrite after #13)

Test plan

  • cargo fmt --check passes
  • cargo clippy -- -D warnings passes
  • cargo test passes (131 tests)
  • polymarket wallet create prompts for password and stores encrypted keystore
  • polymarket wallet create --no-password stores plaintext (with warning)
  • polymarket wallet import <key> prompts for password
  • polymarket wallet export decrypts and shows key
  • All commands prompt for password when key is encrypted
  • POLYMARKET_PASSWORD=xxx polymarket wallet address works without interactive prompt

This contribution was developed with AI assistance (Claude Code).


Note

High Risk
Changes how private keys are stored, decrypted, exported, and held in memory—core wallet security behavior that reviewers should scrutinize carefully.

Overview
Wallet create/import now default to password-protected storage using the standard Ethereum keystore format (wallet.json under the config keystore dir) via Alloy’s signer-keystore, instead of writing the private key into config.json. --no-password keeps the old plaintext-on-disk behavior (with warnings), and config gains an encrypted flag so existing plaintext setups still load unchanged.

Unlocking prompts interactively (or uses POLYMARKET_PASSWORD) when commands need the key from an encrypted wallet; the decrypted key is cached for the rest of a single command and cleared after interactive shell lines. wallet export decrypts and prints the key for backup.

Keystore writes use a temp directory and replace a fixed wallet.json filename so overwrites do not leave stale files that could be picked up by mistake. Dependencies add rpassword, rand, and the keystore crypto stack in the lockfile.

Reviewed by Cursor Bugbot for commit efcfbf3. Bugbot is set up for automated code reviews on this repo. Configure here.

Wallet create and import now encrypt private keys using the standard
Ethereum keystore format (AES-128-CTR + scrypt), the same format used
by MetaMask, Geth, and Foundry. Users are prompted for a password on
key creation/import and when any command needs the key. The
POLYMARKET_PASSWORD env var supports non-interactive use (CI/scripts).
Use --no-password to opt into plaintext storage (not recommended).
Adds wallet export subcommand to decrypt and display the key.

Fixes Polymarket#18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/commands/wallet.rs Resolved
Comment thread src/commands/wallet.rs Resolved
…xt path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mvanhorn

Copy link
Copy Markdown
Author

Addressed both cursor findings in d491d80:

  1. Atomic keystore write - save_encrypted now writes to a .tmp subdirectory first. Old keystore files are only deleted after the new one is successfully written, then moved into place. If encrypt_keystore fails, the old keystore remains intact.

  2. Stale keystore cleanup in plaintext path - Added cleanup_keystore_files() helper called in both cmd_create and cmd_import plaintext branches, so old encrypted keystore files don't linger on disk when overwriting with --force --no-password.

…y-storage

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@mvanhorn

Copy link
Copy Markdown
Author

Merged current main into this branch in ae96342. The conflicts were in Cargo.toml and Cargo.lock from the move to polymarket_client_sdk_v2: the keystore code now builds against the v2 SDK, with signer-keystore, rpassword and rand added next to main's alloy features. I also collapsed one nested if let in cleanup_keystore_files that the current clippy flags.

cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test (82 unit, 49 integration) all pass locally.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit ae96342. Configure here.

Comment thread src/config.rs Resolved
Comment thread src/config.rs Resolved
Every resolve_key call decrypted the keystore and prompted for the password,
so `approve set` asked about once per allowance and could leave partial
approvals if the user stopped partway. Keep the decrypted key for the rest of
the command. The shell drops it after each command, and writing the config
drops it so a replaced wallet is never served from the old key.

decrypt_keystore also took the first file in the keystore directory, which
could be a leftover keystore or .DS_Store. Write the keystore under a fixed
name and read only that file. The rename now replaces the old file directly,
so a failed move can no longer leave the directory empty.
@mvanhorn

Copy link
Copy Markdown
Author

Both Bugbot findings were valid and are fixed in efcfbf3.

Password prompted on every key resolve: the decrypted key is now kept for the rest of the command, so approve set asks once and can't stop partway through at a later prompt. The shell drops it after each command, and any config write drops it, so a replaced wallet is never served from the old key.

Keystore decrypt picks an arbitrary file: the keystore is now always written as wallet.json and that is the only file read, so a leftover keystore or .DS_Store can't be selected. The rename replaces the old file directly, then anything else in the directory is removed, so a failed move no longer leaves the directory empty.

I added a unit test for the single unlock and an integration test that overwrites a wallet, adds stray files and reads the address back. cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test (83 unit, 50 integration) pass locally. I did not run approve set against a live RPC.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Private keys stored as plaintext in config file

1 participant