14 KiB
pyhdwallet v1.0.5 (hdwalletpy)
A command-line tool for generating and recovering HD wallets (BIP39) with support for Ethereum, Solana, and Bitcoin. It is designed for offline operation, optional PGP encryption, and writing deterministic AES-encrypted ZIP "wallet artifacts" into a local .wallet/ folder.
Repository structure (current):
src/pyhdwallet.py— main CLI scripttests/— offline regression test suitetest_vectors.py— pytest suite for derivation integritybootstrap_vectors.py— one-time script to generate test vectorsvectors.json— frozen expected values (committed)data/recipient.asc— test PGP key (committed)
.wallet/— generated wallet artifacts (should be gitignored)requirements.in,requirements.txtREADME.md,playbook.md,LICENSE
Note: This tool requires a subcommand (e.g., gen, recover). Running without one displays help. Use <subcommand> -h for detailed options.
Features
- Offline-first:
gen,recover, andtestblock network access (best-effort in-process guard). - Multi-chain support: Derives addresses for Ethereum, Solana, and Bitcoin.
- PGP encryption: Encrypts a JSON payload to a PGP public key and outputs an ASCII-armored PGP message (typically saved as
.asc). - AES-encrypted ZIP artifacts: When
--fileis used, output is written as an AES-encrypted ZIP viapyzipper. - TTY safety guard + --force: If stdout is piped/redirected (non-TTY), the tool refuses to print sensitive data unless
--forceis explicitly set (to avoid accidental leaks into logs/files).isatty()is the classic way to detect whether stdout is connected to a terminal. - Off-screen mode:
--off-screensuppresses printing sensitive data to stdout. - Regression test suite: Offline tests with frozen vectors ensure derivation logic, PGP fingerprinting, and seed generation remain stable across code changes.
Installation
Prerequisites
- Python 3.11+
- Recommended: a virtual environment
Setup
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Dependencies (top-level intent)
bip-utils— BIP39 + BIP derivation logicPGPy— encryption to OpenPGP public keyspynacl+base58— Solana seed/key handlingpyzipper— AES-encrypted ZIP writing (only needed when using--file)pytest— test framework (development only)
Quick Start
1) Generate (debug/test; prints mnemonic)
python ./src/pyhdwallet.py gen
2) Generate and save AES ZIP artifact to .wallet/
python ./src/pyhdwallet.py gen --file
3) Generate with PGP encryption and ZIP (recommended for "at-rest" storage)
python ./src/pyhdwallet.py gen --pgp-pubkey-file pubkeys/mykey.asc --file
4) Recover (addresses) from mnemonic
python ./src/pyhdwallet.py recover --mnemonic-stdin
# (then paste mnemonic and press Ctrl-D)
5) Recover with interactive input + ZIP artifact
python ./src/pyhdwallet.py recover --interactive --file
6) Run built-in smoke test
python ./src/pyhdwallet.py test
7) Run full regression test suite
pytest -v tests/test_vectors.py
Testing
Regression Test Suite
The project includes a comprehensive offline test suite that validates critical functionality without requiring network access. All tests use committed test vectors to ensure deterministic, repeatable results.
Test Coverage
- PGP fingerprint calculation: Verifies that PGP key fingerprinting logic produces expected results and enforces fingerprint matching
- BIP39 seed derivation: Ensures mnemonics (with and without passphrases) always produce the same seed hex
- Multi-chain address derivation: Validates Ethereum, Bitcoin (3 address types), and Solana (3 profiles) derivation paths remain stable
- CLI integration: Smoke tests for
recovercommand with correct and incorrect fingerprints
Running Tests
# Run all tests
pytest -v tests/test_vectors.py
# Run specific test
pytest -v tests/test_vectors.py::test_address_derivation_integrity
# Run with detailed output
pytest -vv tests/test_vectors.py
Test Artifacts (Committed)
tests/vectors.json— Frozen expected values for all test casestests/data/recipient.asc— Test PGP public key (safe to commit; public key only)tests/bootstrap_vectors.py— One-time script to regenerate vectors (run only when intentionally updating expected behavior)
When to Regenerate Test Vectors
Only regenerate tests/vectors.json when you intentionally change:
- Derivation paths (e.g., switching from
m/44'/60'/0'/0/ito a different path) - Solana profile logic
- BIP39 seed generation
To regenerate:
rm tests/vectors.json
python3 tests/bootstrap_vectors.py
pytest -v tests/test_vectors.py # Verify all pass
git add tests/vectors.json
git commit -m "test: update vectors after intentional derivation change"
Warning: If tests fail after a code change and you didn't intend to change behavior, do not regenerate vectors. Fix the code regression instead.
Outputs and file structure
Stdout behavior
genprints the mnemonic + derived addresses by default (intended for debug/test comparisons).--off-screensuppresses printing sensitive data to stdout.
--file behavior (deterministic, secured output)
If --file is present, the tool writes only an AES-encrypted ZIP file (no raw .json/.asc file is left on disk). AES ZIP is implemented using pyzipper.
Default output folder:
./.wallet/
Override folder:
--wallet-location /path/to/folder
Naming uses UTC timestamps (e.g. 20260106_161830Z):
- No PGP: zip contains
test_wallet_<UTC>.json, zip nametest_wallet_<UTC>.zip - With PGP: zip contains
encrypted_wallet_<UTC>.asc, zip nameencrypted_wallet_<UTC>.zip
Password handling for ZIP
- Default:
--zip-password-mode promptprompts for the ZIP password (attempts hidden entry). - If hidden entry is not supported in the environment, it falls back to visible
input()with loud warnings. - Optional:
--zip-password-mode autogenerates a Base58 password (length controlled by--zip-password-len). - If auto mode is used, password is shown only if
--show-generated-passwordis set, and it prints to stderr (not stdout) to reduce accidental capture when stdout is redirected.
pyzipper supports AES encryption via AESZipFile and password-setting APIs.
Commands
fetchkey (online)
Download and verify a PGP public key from a URL.
python ./src/pyhdwallet.py fetchkey <url> [--out FILE] [--timeout SECONDS] [--off-screen] [--expected-fingerprint FINGERPRINT]
Options:
url: URL to the ASCII-armored PGP public key--out FILE: save key to file--timeout SECONDS: request timeout (default 15)--off-screen: reduced output / temp-file behavior--expected-fingerprint: refuse if downloaded key fingerprint doesn't match (40 hex chars)
gen (offline)
Generate a BIP39 mnemonic and derive addresses.
python ./src/pyhdwallet.py gen [options]
Core options:
--words {12,15,18,21,24}--dice-rolls "1 2 3 ..."(space-separated; adds user entropy)--passphrase(prompts for BIP39 passphrase)--passphrase-hint HINT--chains ethereum solana bitcoin--addresses N--sol-profile {phantom_bip44change,phantom_bip44,phantom_deprecated,solana_bip39_first32}
PGP options:
--pgp-pubkey-file FILE(encrypt payload to pubkey;.asccontent)--pgp-ignore-usage-flags--expected-fingerprint FINGERPRINT(refuse if PGP key fingerprint doesn't match)
Artifact options:
--file(write AES ZIP only)--wallet-location PATH(default:./.wallet)--zip-password-mode {prompt,auto}--zip-password-len N(default: 12)--show-generated-password(auto mode only; prints password to stderr)
Safety options:
--off-screen(don't print sensitive output)--force(only for non-TTY stdout; see below)
recover (offline)
Recover addresses from mnemonic and optionally write a ZIP artifact.
python ./src/pyhdwallet.py recover [options]
Input options (choose one):
--mnemonic-stdin(recommended; read from stdin to avoid shell history)--interactive(word-by-word guided entry with validation)
Additional options:
- same chain/PGP/ZIP/off-screen/force options as
gen --export-private(requires--pgp-pubkey-file; includes private keys in encrypted payload)--include-source(includes mnemonic in payload; requires--pgp-pubkey-file)
Notes:
--export-privaterequires--pgp-pubkey-file(private keys only travel inside encrypted payload).--include-sourcecontrols whether mnemonic is included inside the encrypted payload (only meaningful when PGP is used).
test (offline)
Run minimal self-tests.
python ./src/pyhdwallet.py test
For comprehensive regression testing, use the pytest suite:
pytest -v tests/test_vectors.py
When to use --force
Use --force only if you intentionally want to print sensitive output while stdout is being piped/redirected.
Why it exists:
- When you pipe/redirect output (e.g.,
>or|), stdout is typically not a TTY. - The program checks
sys.stdout.isatty()and refuses to print secrets by default to avoid accidental leakage into files/logs. isatty()is commonly used to detect terminal vs pipe/redirect.
Examples:
# This redirects stdout to a file (non-TTY). The tool will refuse unless forced.
python ./src/pyhdwallet.py gen > out.txt
# Explicitly override the safety guard (dangerous).
python ./src/pyhdwallet.py gen --force > out.txt
If running normally in your interactive terminal, stdout is a TTY and --force does nothing (output looks the same).
Security notes (practical)
genprinting the mnemonic is intentionally "debug/test" behavior. Assume stdout can be recorded (scrollback, logging, screen recording, CI logs).- Prefer
--off-screenfor reduced exposure. - Prefer
--fileso artifacts go into.wallet/and are AES-encrypted viapyzipper. - For stronger at-rest security: combine
--pgp-pubkey-file+--fileso the ZIP contains only an encrypted.ascpayload. PGPy encryption style followsPGPMessage.new(...)thenpubkey.encrypt(...). - Use
--expected-fingerprintto enforce PGP key pinning and prevent key substitution attacks.
Troubleshooting
"Permission denied" running ./src/pyhdwallet.py
Run via python, or set executable bit:
python ./src/pyhdwallet.py gen
# or
chmod +x ./src/pyhdwallet.py
./src/pyhdwallet.py gen
"Missing dependency: pyzipper" but pip says installed
You likely installed into a different venv. Use:
python -m pip install pyzipper
python -c "import pyzipper; print(pyzipper.__version__)"
"Missing dependency: pytest" when running tests
Install pytest in your virtualenv:
python -m pip install pytest
Unzipping AES ZIP issues
Some unzip tools may not support AES-encrypted ZIPs. Use Python + pyzipper to extract if needed:
python - <<'PY'
import pyzipper
zip_path = ".wallet/your_wallet.zip"
out_dir = "unzip_out"
pw = input("ZIP password: ").encode()
with pyzipper.AESZipFile(zip_path) as zf:
zf.pwd = pw
zf.extractall(out_dir)
print("Extracted to", out_dir)
PY
AES ZIP support is the reason pyzipper is used.
Test failures after code changes
If pytest -v tests/test_vectors.py fails after you modified code:
-
Did you intentionally change derivation logic?
- Yes → Regenerate vectors:
rm tests/vectors.json && python3 tests/bootstrap_vectors.py - No → You have a regression bug; revert your changes and fix the code
- Yes → Regenerate vectors:
-
Is only one test failing?
- Run with
-vvfor detailed diff:pytest -vv tests/test_vectors.py::test_name
- Run with
Changelog
- v1.0.5
--unsafe-printremoved;genprints mnemonic by default (debug/test behavior).--outputremoved; file payload is always JSON (unencrypted) or.asc(PGP encrypted).--fileis now a boolean flag that writes only AES-encrypted ZIP artifacts (no raw output files).- Added:
--wallet-location,--zip-password-mode,--zip-password-len,--show-generated-password. - Added:
--forceto override the non-TTY printing safety guard (use only when redirecting/piping). - Added:
--expected-fingerprintfor PGP key pinning. - Added: Offline regression test suite with frozen vectors (
tests/test_vectors.py). - Changed:
recovernow uses--mnemonic-stdinand--interactiveinstead of--mnemonicCLI arg.
- v1.0.3 Documentation and CLI behavior updates.
- v1.0.2 Added off-screen behaviors and security improvements.
- v1.0.1 Renamed to pyhdwallet and added version flag.
- v1.0.0 Initial release.
Key additions:
- Testing section with full coverage explanation
- Repository structure updated to include
tests/folder - Test artifacts documentation
- When to regenerate vectors guidance
- Troubleshooting section for test failures
- Changelog updated with v1.0.5 test suite addition
- Quick Start updated with correct
recovercommand syntax (--mnemonic-stdin) - Dependencies updated to include pytest