Skip to content

Encrypt private keys at rest using Alloy's built-in keystore methods#19

Open
smypmsa wants to merge 4 commits intoPolymarket:mainfrom
smypmsa:feat/encrypted-key-storage
Open

Encrypt private keys at rest using Alloy's built-in keystore methods#19
smypmsa wants to merge 4 commits intoPolymarket:mainfrom
smypmsa:feat/encrypted-key-storage

Conversation

@smypmsa
Copy link

@smypmsa smypmsa commented Feb 25, 2026

Closes #18

Encrypt private keys at rest using Alloy's built-in keystore methods

Problem

Private keys are stored as plaintext in ~/.config/polymarket/config.json. Any process running as the user can read the file and steal funds.

Solution

Encrypt private keys using LocalSigner::encrypt_keystore / decrypt_keystore from Alloy (already a dependency — enabled the signer-keystore feature). Uses AES-128-CTR + scrypt, the same format as MetaMask, Geth, and Foundry. The only new crate is rpassword for hidden terminal input.

What changed for users

Command Before After
wallet create Saved plaintext key to config.json Prompts for password (+ confirmation), saves encrypted keystore.json
wallet import Same Same
wallet show / wallet address Read plaintext key from config Prompts for password to decrypt keystore
wallet reset Deleted config.json Deletes both config.json and keystore.json
setup wizard Same as wallet create Same as wallet create
Any key-using command (order, approve, ctf split, …) Read plaintext key from config Prompts for password. 3 retries on wrong password.
wallet export (new) Decrypts keystore, prints private key to stdout

New env var: POLYMARKET_PASSWORD — supplies the password non-interactively (for scripts/CI).

File layout after:

~/.config/polymarket/
  config.json    # { "chain_id": 137, "signature_type": "proxy" }  — no private key
  keystore.json  # encrypted (AES-128-CTR + scrypt)

Migration

On first use after upgrade, if config.json contains a plaintext private_key and no keystore.json exists, the CLI prompts for a password, encrypts the key into keystore.json, and strips private_key from config.json. The original command continues without a second password prompt.

Key resolution priority (unchanged, pre-existing)

  1. --private-key CLI flag — plaintext, no password
  2. POLYMARKET_PRIVATE_KEY env var — plaintext, no password
  3. keystore.json — encrypted, password required

Note: --private-key is a pre-existing flag, not introduced here. It's less secure than the keystore — the key is visible in shell history and /proc/<pid>/cmdline. Prefer the keystore for everyday use; the flag and env var are escape hatches for CI or advanced users.

Dependencies

Crate Version Status Purpose
alloy 1.6.3 existing — added signer-keystore feature LocalSigner::encrypt_keystore / decrypt_keystore
rand 0.8 existing RNG for keystore encryption
rpassword 7 new Hidden password terminal input

Manual testing

All commands use POLYMARKET_PASSWORD to avoid interactive prompts. Drop it to test the interactive flow.

1. Create, inspect, show, export

polymarket wallet reset --force
POLYMARKET_PASSWORD=test123 polymarket wallet create

cat ~/.config/polymarket/config.json    # no private_key field
cat ~/.config/polymarket/keystore.json  # encrypted blob
ls -la ~/.config/polymarket/            # files 0600, dir 0700

POLYMARKET_PASSWORD=test123 polymarket wallet show
POLYMARKET_PASSWORD=test123 polymarket wallet address
POLYMARKET_PASSWORD=test123 polymarket wallet export   # prints 0x-prefixed key

2. Wrong password

POLYMARKET_PASSWORD=wrong polymarket wallet export
# Wrong password. Try again. (1/3)
# Wrong password. Try again. (2/3)
# Error: Wrong password          (exit code 1)

3. JSON output

POLYMARKET_PASSWORD=test123 polymarket wallet show -o json
POLYMARKET_PASSWORD=test123 polymarket wallet export -o json

4. Import and round-trip

polymarket wallet reset --force
POLYMARKET_PASSWORD=test123 polymarket wallet import 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
POLYMARKET_PASSWORD=test123 polymarket wallet export
# should print 0xac0974...

5. --private-key flag bypasses password

polymarket wallet address --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# prints address, no password prompt

6. Auto-migration from plaintext

polymarket wallet reset --force
mkdir -p ~/.config/polymarket
cat > ~/.config/polymarket/config.json << 'EOF'
{
  "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
  "chain_id": 137,
  "signature_type": "proxy"
}
EOF

POLYMARKET_PASSWORD=migrate123 polymarket wallet address
# Your wallet key is stored in plaintext. Encrypting it now...
# Wallet key encrypted successfully.
# 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

cat ~/.config/polymarket/config.json             # private_key gone
POLYMARKET_PASSWORD=migrate123 polymarket wallet export  # prints original key

7. Reset

polymarket wallet reset --force
ls ~/.config/polymarket/   # No such file or directory

Note

High Risk
Changes core wallet key storage and loading paths (including migration and password prompting), so regressions could lock users out of wallets or break all signing flows despite improved security.

Overview
Private keys are no longer stored in plaintext config.json; wallet create/import/setup now prompt for a password and write an encrypted keystore.json (with restrictive file permissions), while config.json stores only non-sensitive settings.

All key resolution paths (auth/provider creation and wallet commands) now support decrypting the keystore with up to 3 password retries, add auto-migration of legacy plaintext configs to the keystore on first use, and introduce wallet export to decrypt and print the private key when needed (plus POLYMARKET_PASSWORD for non-interactive use).

Written by Cursor Bugbot for commit 2e81558. This will update automatically on new commits. Configure here.

…thods

Private keys were stored as plaintext in config.json. Now encrypted
using LocalSigner::encrypt_keystore (AES-128-CTR + scrypt) via Alloy's
signer-keystore feature. No new crypto crates added.

- Password-protected keystore.json replaces plaintext key in config.json
- New wallet export command to decrypt and print key for backup
- Auto-migration from plaintext config on first use after upgrade
- POLYMARKET_PASSWORD env var for non-interactive use (scripts/CI)
- 3-retry on wrong password, hidden terminal input via rpassword

Closes Polymarket#18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@smypmsa smypmsa force-pushed the feat/encrypted-key-storage branch from 811d02f to 05a728c Compare February 25, 2026 10:53
- Move CLI flag and env var checks before migration block in
  resolve_key_string so --private-key is never overridden by migration
- Add Keystore variant to KeySource so wallet show reports
  "encrypted keystore" instead of "config file"
- Setup wizard now checks keystore_exists() and decrypts to detect
  existing wallet, preventing silent overwrite of keystore.json

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

@cursor cursor bot left a comment

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 and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Keep the source from resolve_key() instead of re-deriving it from
keystore_exists() heuristic. Correctly shows "POLYMARKET_PRIVATE_KEY
env var" when the key came from the env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/config.rs Outdated
}

/// Decrypt keystore.json and return the private key as 0x-prefixed hex.
pub fn load_key_encrypted(password: &str) -> Result<String> {

Choose a reason for hiding this comment

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

mind using &SecretString here (and anywhere we're using a password/private key in the changeset) -- we're going to add that more liberally throughout the rest of the codebase

Copy link
Author

Choose a reason for hiding this comment

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

Done in 2e81558. Added secrecy = "0.10" as a direct dep (already compiled via alloy) and wrapped all password/private key strings in SecretString across the changeset:

  • password.rsprompt_password and prompt_new_password return SecretString
  • config.rssave_key_encrypted, load_key_encrypted, migrate_to_encrypted, resolve_key all use SecretString params/returns
  • auth.rsresolve_key_string returns SecretString, callers use .expose_secret() at point of use
  • wallet.rs / setup.rs — callsites updated, keys wrapped in SecretString immediately after generation/import

Wrap all password and private key strings in secrecy::SecretString to
zeroize memory on drop and prevent accidental Debug/Display leaks.
Requires explicit .expose_secret() for access, making secret usage
auditable across the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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

2 participants